chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.venv/
venv/
.eggs/
# 环境变量
.env
.env.local
.env.*
# IDE
.vscode/
.idea/
*.swp
*.swo
# 系统
.DS_Store
Thumbs.db
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 前端构建
frontend-agent/dist/
frontend-h5/dist/
# 日志
*.log
logs/
# Docker
*.pid
*.seed
*.pid.lock
# =============================================================================
# P0 安全: SSL 私钥 / 部署包 / 历史日志 (2026-06-14 强化)
# =============================================================================
# SSL 私钥 + 证书请求
*.key
*.pem
*.csr
# docs/ 下所有 servyou.com.cn 证书(目录 + 文件 + zip 全部,** 匹配任意深度)
docs/servyou.com.cn**
docs/*证书*/
docs/*ssl*/
docs/*SSL*/
docs/*证书*.zip
/docs/*证书*.zip
# 部署包 + 部署产物(体积大 + 经常含私钥)
deploy-*.tar
deploy-*.tar.gz
deploy-packages/
deploy-patch.tar
deploy-admin.tar
deploy-agent.tar
deploy-backend.tar
deploy-h5.tar
deploy-portal.tar
*.tar
*.tar.gz
# 历史日志 + 构建日志(stderr 噪声)
*.log.err
build_logs/
agent_build.log.err
build-agent.log.err
build-agent2.log.err
build-h5.log.err
build-h5-fix.log.err
build_all.log.err
# 本地部署脚本 + 部署目录
build.ps1
build_all.ps1
deploy-frontend-nas.bat
deploy-nas/
# 部署产物 zip(体积大 + 含 dist)
frontend-*-dist-fix*.zip
frontend-*-dist-fix-*.zip
it-smart-desk-*-deploy.zip
# deploy-server/ 部署配置(nginx conf 入仓, .env 排除)
deploy-server/**/.env*
deploy-server/**/secrets/
deploy-server/**/*.pem
deploy-server/**/*.key
# SQLite 本地测试库
it_smart_desk.db
*.db
*.sqlite
*.sqlite3
# pytest / 临时
.pytest_cache/
/tmp/
*.pid
.coverage
htmlcov/
+77
View File
@@ -0,0 +1,77 @@
# 2026-05-21 工作记录
## 企微 IT 服务台架构咨询
用户背景:6000 人上市公司,已有企微 + 千问 + RAGFlow + Dify 的 AI IT 助手,痛点在于员工绕过 AI 直接转人工、转人工后需开新窗口、无法跨主体企业共享。
### 三方案可行性分析
- **方案一**(企微员工服务 + 自建应用):不可行,企微员工服务 API 独立,无法与自建应用消息流打通,痛点解决率 1/3
- **方案二**(自建应用消息 + 自研坐席后台):推荐,完整解决三个痛点,需自研坐席后台,开发量中等
- **方案三**(企微 WebView + 开源客服如 Chatwoot):可行,速度快但灵活度受限,跨企业共享有配置复杂度
### 零基础开发能力评估
- 方案三:零基础 + AI 辅助,约 3 个月可上线(推荐入手点)
- 方案二:需 4-6 个月,学习曲线更陡
- 核心学习路径:Python → HTTP/企微 API → Docker 部署 → Flask 消息网关 → AI 集成
### 硬件资源需求
- 方案三:单台 8 核 8GB 内存 100GB SSD 服务器(约 2-4 万元)
- 方案二:2 台服务器,合计 8-16 核 16GB 内存 300GB SSD
- AI 推理(千问):已有设施则不需额外采购;如新采购建议 Qwen2.5-14B + A30/双 RTX 4090
### 用户修正的三步演进路径
- 第一步:测试环境完成企微消息接管 + 极简坐席,先不接入AI,验证消息回调链路
- 第二步:将千问+Dify+RAGFlow机器人消息接入极简坐席
- 第三步:会话日志人工+AI混合标注与校正,迭代优化AI知识库
### 并行协作模式设计
- 用户核心创新:AI和人工并行而非串行,所有会话消息AI全程可见
- 会话标记系统:VIP图标、举手标记(关键词"转人工")、情绪识别(关键词规则优先)、紧急度评分(综合公式)、置顶/代办
- 坐席看板分区:AI自主处理区(折叠)、举手等待区(核心关注)、人工处理区、已结单区
### 双面板AI助手设计(用户已确认)
- 用户端AI助手面板(最右侧,H5双栏方式B):相似问题、审批流程链接、软件下载快捷入口、知识库搜索
- 坐席端AI助手面板(最右侧):AI建议回复(采纳/编辑/忽略)、快速回复模板、问题解决操作步骤、风险提示、用户特点和其他注意事项
- 后端同一AI引擎,按角色路由不同数据schema输出
### 原型优化确认(6/2
- 用户信息去重:左栏去掉办公地点,中栏去掉部门/岗位/用户等级
- 新增"需介入"标签:同一问题追问次数多或AI判断需要人工时自动触发
- 用户端选方式B(H5双栏),功能未实现前预留占位符+"即将上线"提示
- 原型美化:添加emoji图标和颜色区分
### 第一步逐天开发清单(6/2更新)
- 6周30天计划:第1周基础+企微对接 → 第2周消息路由+标记系统 → 第3周坐席工作台 → 第4周AI助手面板(坐席端) → 第5周用户端H5双栏 → 第6周联调测试
- 新增功能:举手/需介入/情绪/VIP标记、彩色标签会话列表、AI助手坐席端5模块、H5双栏+OAuth
- 新增「摇人」按钮(6/2):用户端输入框左侧的转人工快捷键,橙色渐变铃铛+红点+摇晃动画,一键呼叫IT坐席
- 摇人趣味话术体系(6/2):点击→"大哥,俺这就去摇人,稍等...";排队→"人还在路上,别急别急~";接入→"人摇来了!IT坐席为您服务";关键词→"收到!这就帮您摇位大神来";超时→"坐席都在忙,不过AI还在呢";话术存配置表支持后台动态修改
### 开发团队SOP执行(6/2
- 团队:software-it-service-desk,主理人齐活林 + 产品经理许清楚 + 架构师高见远 + 工程师寇豆码 + QA严过关
- PRD完成:`C:\Users\simon\wecom_it_smart_desk\PRD.md`,含31项需求(P0/P1/P2)、7个用户故事、完整数据模型
- 架构设计完成:`ARCHITECTURE.md`9张表DDL + 7组API + 4张时序图 + 5个任务分解
- T01基础设施完成:57个文件(Docker/模型/Schema/前后端脚手架)
- T02后端核心完成:16个文件(企微加解密/消息路由/评分/摇人话术/7组API)
- T03坐席前端完成:20个文件(三栏布局/会话列表/对话区/AI助手5模块/登录页)
- T04 H5用户端完成:12个文件(双栏布局/摇人按钮/审批链接/软件下载/占位符/OAuth2)
- QA测试完成:93个测试用例(7个模块),Bug1(await缺失)已修复
- 已知问题:PostgreSQL特有类型(JSONB/gen_random_uuid)与SQLite测试环境不兼容,需适配
- 用户确认:坐席用户名密码登录、支持文本+图片+文件消息、企微应用已创建有凭证
### 兼容性修复 & database.py 重构(6/3
- 9个模型文件全部兼容SQLiteUUID→String(36)+default=lambda:str(uuid.uuid4())JSONB→JSON,移除server_default/postgresql_using/postgresql_where
- database.py重构为懒加载:_get_engine()和_get_session_factory()延迟创建引擎,避免测试导入时触发asyncpg连接
- main.py和wecom_callback.py已同步更新引用(async_session_factory→_get_session_factory()
- pytest无法在sandbox中运行(子进程输出/文件写入均被拦截),需用户本地终端手动运行验证
- 第一步全部代码完成:110+文件,待本地pytest验证
### 测试验证通过(6/3
- **116/116 pytest 全部通过**(1.71秒),测试过程中发现并修复7个Bug:
1. message_router.py 缺少 await
2. main.py 中文引号导致 SyntaxError
3. wecom_callback.py WecomCrypto 模块级初始化失败 → 懒加载
4. conftest.py Redis mock 路径错误
5. conftest.py create_test_conversation 缺少参数
6. session_service.py UUID/String(36) 类型不匹配
7. scoring_service.py 关键词大小写不敏感 + VIP短路缺失
- **第一步开发完整交付**,可进入部署阶段
+54
View File
@@ -0,0 +1,54 @@
# 2026-06-02 工作日志
## 企微IT智能服务台
- 重绘三张核心原型图(坐席工作台、员工H5端、评分流转)供用户查看
- 根据 PRD + ARCHITECTURE.md 整理了一份面向运维/架构/开发的图文沟通文档,包含:
- **系统架构**Docker Compose 部署拓扑、技术栈、9张表、7组API
- **消息收发**:6步全链路闭环、紧急度评分公式、会话排序规则
- **知识库迭代**:M1→M2→M3 三步演进路径、M3标注闭环流程
- **运维信息**:资源配置、Docker服务清单、关键配置项
- **待办清单**:5项需团队协助的事项
- 文档保存至 `docs/团队沟通文档-架构消息知识库.md`
## 本地环境搭建
- Redis 3.0.504 通过 winget 安装(`C:\Program Files\Redis`),redis-cli ping → PONG
- PostgreSQL 16.14 通过 winget 安装(`C:\Program Files\PostgreSQL\16`),密码=postgres
- PATH 已添加 PostgreSQL bin 目录(用户级)
- 数据库 `it_smart_desk` 已创建
- `.env` 已更新为本地连接:`postgresql://postgres:postgres@localhost:5432/it_smart_desk`
- Docker Desktop 29.4.3 已就绪,但国内镜像拉取失败,PostgreSQL/Redis 改用原生安装
- 后端 pip install 尚未完成(用户切换到复用评估任务)
## 现有系统复用评估
- 读取了交接文档(IT智能在线咨询交接文档-tm.docx)和现有代码(db_query_project_v8.tar
- 现有系统技术栈:Django 3.2 + PG 11.8 + Redis + Bootstrap + ECharts
- 核心可复用:Dify Workflow、dify2openai桥接、RAGFlow知识库、Qwen3-30B大模型、Dify只读数据库
- 基础设施可复用:10.80.0.86服务器、域名dc.servyou-it.com、Redis实例、Docker Compose模式
- 代码层面复用率约15%(业务逻辑参考),基础设施+AI能力复用率约70%
- 关键对接参数已整理(dify2openai API URL/Key、RAGFlow地址、大模型地址、数据库连接等)
- 文档保存至 `docs/现有系统复用评估报告.md`
## 前端启动 & 登录500调试(下午至晚间)
- 前端 `frontend-agent` (Element Plus, port 5173) 和 `frontend-h5` (Vant, port 5174) npm install + npm run dev 成功
- 登录 `/api/agents/login` 持续返回 500,排查过程:
1. Redis 错误容错 → 未解决
2. catch-all 异常处理器 → 代码正确但未生效(旧进程)
3. 中间件级异常捕获 → 同上
4. 诊断脚本发现根 `.env` 的 DATABASE_URL 指向 Docker 主机名 `@postgres` → 修复为 `localhost`
5. 修复后重启仍 500 → 端口 8000 被旧进程僵尸 socket 占据(`[Errno 10048]`),新进程无法绑定
- **根本原因**:端口 8000 僵尸 socket + 旧进程用修复前的 .env
- **解决方案**:换端口 8001 + 修复 .env + 修复 QuickReplyPanel.vue 语法错误(`{{{ }}}``{{ }}`
- 当前运行:后端 localhost:8001, 前端 localhost:5173(代理指向 8001
- 添加了诊断端点 `/api/test-ping``/api/test-error`(调试用,生产前需删除)
- `vite.config.ts` 代理端口已从 8000 改为 8001
## H5 员工端启动 & 修复(晚间)
- `frontend-h5` (Vant, port 5174) npm install + npm run dev 成功
- 初始报错"未授权"H5 端走企微 OAuth2 但本地无 `VITE_WECOM_CORP_ID` → 路由守卫已添加 mock `employee_id`
- `fetchUserInfo` 在开发模式下 API 失败时使用 mock 数据兜底,不阻塞初始化
- 后端返回 `{"items": [...]}` 格式但前端直接赋值导致 `is not iterable` 错误:
- `getApprovalLinks`:提取 `data?.items || data || []`
- `getSoftwareDownloads`:同上
- `pollMessages`:同上
- H5 前端 `vite.config.ts` 代理端口也已从 8000 改为 8001
- 当前完整运行状态:后端 8001 + 坐席端 5173 + 员工端 5174
+358
View File
@@ -0,0 +1,358 @@
# 2026-06-03 工作日志
## Docker Compose 部署编排完善
- 重写 `docker-compose.yml`PostgreSQL 16-alpine + Redis 7-alpine + 后端 + Nginx 四服务
- 增强 nginx.conf:新增 WebSocket 路径代理、HTTPS 模板(含 SSL 安全配置和安全头)
- 创建 `.env.production`:生产环境变量模板(企微凭证、数据库、域名、SSL 路径)
- 创建 `scripts/build.sh`:一键构建两个前端(agent + h5)
- 创建 `scripts/deploy.sh`:一键部署(检查环境 → 构建前端 → 启动服务 → 健康检查)
- 后端容器端口不暴露(仅 Nginx 入口),数据库/Redis 端口默认不暴露(安全策略)
- 所有服务配置日志轮转(10-20MB/文件,3-5个文件)
## US-7 模型层准备(上下游互联)
- 创建 `Employee` 模型(`employees` 表):corp_id + employee_id 复合唯一键,支持跨企业员工
- `Conversation` 模型新增 `corp_id` 字段 + 索引(默认空字符串,不破坏现有数据)
- 创建 Alembic 迁移 `001_add_employees_table.py`:创建 employees 表 + 为 conversations 添加 corp_id
- 更新 `models/__init__.py``alembic/env.py` 注册 Employee 模型
- 所有模型导入和应用创建验证通过
## 文档清理
- `现有系统复用评估报告.md` 已在之前的合并操作中删除(内容合并至团队沟通文档第7章)
## 摇人功能可行性评估
- 分析两种场景:
- 情况1(创建企微群+拉员工入群):技术上可行(appchat API),但聊天记录转发体验差、跨企业受限、群生命周期管理复杂。**用户暂缓确认。**
- 情况2(坐席B进入同一会话协作):纯内部扩展,成本低体验好。**用户确认优先开发。**
- 输出详细技术方案到 `docs/摇人-多坐席协作-技术方案.md`,覆盖模型/API/WS/前端全链路
- 核心设计:Conversation 新增 `collaborating_agent_ids` (JSON),协作坐席可查看+回复但不能结单/转接,不占负载
- 预留情况1接口待用户确认
## 正式环境独立部署架构方案
- 用户要求以"影响最小、责任清晰、避免系统混搭"为原则,给出正式环境部署建议
- **核心决策:物理隔离 > 逻辑隔离**,修正了原复用评估中的共享建议
- 方案要点:
- **独立服务器**:不共用 10.80.0.86,申请新 VM4C8G/100GB 以上)
- **独立数据库**:独立 PostgreSQL 16 容器(不复用旧 PG13 实例)
- **独立 Redis**:独立 Redis 7 容器(不复用旧实例,避免 db 号隔离不彻底)
- **独立 Nginx + 子域名**`itdesk.dc.servyou-it.com`,变更不影响旧系统
- **仅共享外部服务**:企微应用凭证(只读)、AI 服务(HTTP 调用)、SSL 证书(只读文件)
- 输出完整方案文档 `docs/正式环境独立部署架构方案.md`:含资源申请清单、网络拓扑、容器拓扑、部署步骤、回滚方案、运维责任矩阵、风险矩阵、退化方案
## 摇人(情况2)多坐席协作 全链路实现
- **13个文件改动**,完整前后端实现:
- **模型**`conversation.py` 新增 `collaborating_agent_ids` (JSON, default list)
- **Schema**:新增 `ConversationInvite``ConversationResponse` 扩展 `collaborating_agent_ids/names``is_collaborator`
- **SessionService**:新增 `invite_collaborator()`(6步校验 + WS广播+定向推送)和 `leave_collaboration()`4步校验 + WS广播)
- **API**:新增 `POST /conversations/{id}/invite`(错误码3020-3024)、`POST /conversations/{id}/leave`(错误码3025-3026);列表接口扩展协作字段
- **前端 API**`inviteCollaborator()``leaveCollaboration()`Conversation 类型扩展
- **Store**:新增 `collaboratingConversations` 计算属性、`inviteToConversation()``leaveConvCollaboration()``handleCollaboratorInvited()``handleCollaboratorChanged()` WS处理器
- **WebSocket**:处理 `collaborator_invited`(弹窗通知被邀请坐席)、`collaborator_joined``collaborator_left` 事件
- **新组件 InviteDialog.vue**:搜索在线坐席→选中→确认邀请,排除主责/协作坐席/自己
- **ConversationList**:新增「协作会话」分区(排在「我的会话」之后),支持退出按钮
- **ConversationItem**:新增 `showLeave` prop + 退出按钮样式
- **ChatArea**:新增「🤝 摇人」按钮(仅 serving 且 is_mine/is_collaborator 时显示)、协作信息行(主责+协作坐席展示)、InviteDialog 集成
- **权限矩阵已落地**:主责坐席可做一切;协作坐席可查看+回复+再摇人,不能结单/转接/标记;不占协作坐席负载
## 应急预案(应急模式)— 方案B:纯应急 + 手动启停
- 决策:先选方案B(员工服务常态隐藏,需要时手动开启),后续条件成熟再升级方案C(自动降级)
- **后端**
- `main.py` 默认配置新增 `emergency_mode`(默认 false
- 新建 `app/api/system.py`GET/PUT `/api/system/emergency-mode` 查询/切换应急模式
-`router.py` 注册系统管理路由
- **前端坐席端**
- 新建 `api/system.ts`:封装 `getEmergencyMode()` / `toggleEmergencyMode()`
- `Workspace.vue` 顶部栏新增「启用应急模式」按钮(常态隐藏);开启后显示红色应急横幅 + 「关闭应急模式」按钮
- 开启/关闭均需二次确认,防止误操作
- Phase 2(待条件成熟):服务挂掉时 H5 页面自动显示引导提示走员工服务
## H5员工端「摇人」→「双手敲桌子」改造
- 用户反馈:前端未发现「举手」和「摇人」功能变更 → 经核查,坐席端「摇人」已完整实现,H5员工端「举手」缺少专用按钮
- 用户决策:将 H5 员工端现有「摇人」按钮改为「双手敲桌子」
- **ShakeButton.vue 完全重写**
- 图标从 🔔 改为 👊👊 双拳
- CSS 动画:交替敲击(左右拳各3轮,0.8s)+ 按钮水平震动(模拟桌子晃动)+ 静止时呼吸浮动
- 按钮底色从 #FF6B35#FF8F5E 改为 #FF5722#FF7043(更深的紧急感)
- 防抖逻辑保持不变
- **InputBar.vue**:引导条文案「急需 IT 支持?👊👊 敲桌子呼叫坐席」
- **ChatPanel.vue**:空状态提示「输入问题咨询,或 👊👊 敲桌子呼叫坐席」
- **后端 h5.py**:注释/日志从「摇人」改为「举手/敲桌子」
- **前端注释批量更新**H5 api/conversation.ts、stores/conversation.ts、frontend-agent conversation.ts
## H5员工端「呼叫坐席」完整改造(三步流程 + 七种动画)
### 核心设计变更
- 用户决策:呼叫坐席必须有前置条件——用户先描述问题,AI 复述确认后再呼叫,避免无效转人工
- 动画触发方式:随机选择(方案C),每次点击随机出现7种动画之一,增加趣味性
- 话术与场景一一对应,不同紧急程度有不同表达
### 三步流程(CallAgentModal.vue
1. **描述问题**TextArea 输入(上限500字),引导员工说清楚问题
2. **AI 复述确认**:调后端 API 让 AI 用自己的话复述,用户确认无误后进入下一步
3. **播放动画 + 发请求**:随机选场景播放动画,同时发 shake 请求
### 七种呼叫场景(权重随机)
| # | 场景 | 话术 | 权重 | 核心动画 |
|---|------|------|------|----------|
| 1 | 🙋 举手 | "看这里!…我有个问题!" | 3.0 | 右手臂上下挥动 + 气泡 |
| 2 | 🪑 拍桌子 | "快快快!我等不及了!" | 3.0 | 双拳交替敲击 + 桌面震动 |
| 3 | 💀 劈稻草人 | "不解决我要爆炸了💥" | 1.5 | 挥刀 + 稻草人抖动 + 爆炸光效 |
| 4 | 🍉 砍西瓜 | "IT救我!卡住了🍉" | 1.5 | 刀砍 + 汁水飞溅 |
| 5 | 🔔 摇铃铛 | "叮叮叮!有人吗!" | 1.0 | 双铃铛摆动 + 声波扩散 |
| 6 | 💣 大炮发射 | "开炮!必须解决了!" | 1.5 | 引信燃烧 + 炮弹飞行 + 爆炸+靶子抖动 |
| 7 | 🚀 导弹发射 | "发射!呼叫IT特种部队!" | 1.5 | 导弹上升 + 尾焰闪烁 + 烟雾扩散 + 按钮闪烁 |
### 技术实现
- **CallAgentModal.vue**:全新组件,Teleport 到 body,三步骤状态机
- **ShakeButton.vue** 重构:从直接发请求 → 只触发弹窗(emit 'trigger'
- **ChatPanel.vue**:承载弹窗,监听 call-agent 事件
- **InputBar.vue**:向上传递 trigger 事件
- 所有 SVG 场景内联绘制,CSS @keyframes 驱动动画,无外部图片依赖
- 构建验证通过(CSS 从 26.52 kB → 30.16 kBChatView JS 从 48.38 kB → 53.49 kB
## 呼叫坐席流程重设计:按钮条件显隐 + 弹窗简化(2026-06-03 下午)
### 需求
1. 初始隐藏「呼叫坐席」按钮,AI 实质性回复 >= 3 次后才出现
2. 打招呼(你好/hi等)和直接呼叫人工(人工/转人工等)不计数,AI 回复引导话术
3. CallAgentModal 简化为单步动画,去掉"描述问题"和"AI复述确认"步骤
### 后端改动
- **conversation.py**:新增 `ai_substantive_reply_count` 字段(Integer, default=0
- **h5.py**
- `_get_current_employee()`:新增 `X-Employee-Id` 头 fallback(开发降级)
- 新增 `_is_greeting()` / `_is_call_human()` 检测函数(关键字匹配)
- `h5_send_message()` 完全重写:检测消息类型→生成AI回复→计数→返回 `{user_message, ai_reply, is_guidance, ai_reply_count, can_call_agent}`
- `GET /h5/conversations/current`:返回 `can_call_agent``ai_substantive_reply_count`
- `POST /h5/conversations/current/shake`:新增前置校验 `ai_substantive_reply_count >= 3`(含无会话场景兜底),不满足返回错误码 1003
- **.env**`DATABASE_URL` 改为绝对路径 `sqlite+aiosqlite:///C:/Users/simon/wecom_it_smart_desk/backend/it_smart_desk.db`
### 前端改动
- **api/conversation.ts**:新增 `SendMessageResponse` 类型,`sendMessage()` 返回双消息结构
- **stores/conversation.ts**:新增 `canCallAgent` ref`sendNewMessage()` 处理双消息响应;`fetchCurrentConversation()` 同步 canCallAgent
- **InputBar.vue**`ShakeButton` `v-if="store.canCallAgent"` 条件渲染;底部文案动态切换(默认提示→橙色脉冲「呼叫坐席通道已开启」)
- **CallAgentModal.vue**:完全重写为单步动画模式,`watch(visible)` 自动触发 shake,4秒后自动关闭
### 修复的 Bug
1. `_get_current_employee` 只支持 Bearer Token → 添加 `X-Employee-Id` 开发降级 fallback
2. `.env` 相对路径 `./it_smart_desk.db` → 改为绝对路径
3. shake 端点无会话时直接创建新会话绕过阈值 → 统一拒绝 code=1003
4. 编辑 cut-paste 残留垃圾代码 → 清理修复
### 验证结果
- 后端 API 全链路测试通过(打招呼引导 + 计数递增 + can_call_agent 阈值 + shake 拒绝/接受)
- 前端 `npm run build` 通过(ChatView JS: 53.49 kB → 48.82 kB
- 本地环境:后端 `:8000` + 前端 `:5173` 运行中
- 测试指南:`TESTING_CALL_AGENT.md`
## 部署就绪性完善(2026-06-03 晚)
### 修复的问题
1. **nginx.conf 端口 80 重复监听**:两个 server 块都 `listen 80; server_name _;` → 重写为单一 HTTP server 块 + 注释模板 HTTPS server 块
2. **frontend-agent ConversationList.vue 重复 import**`import type { Conversation }` 出现两次 → 删除重复行
3. **alembic.ini 日志格式错误**`[%(name)]``s` → 修正为 `[%(name)s]`;中文注释导致 Windows GBK 解码失败 → 改为英文注释
4. **alembic 迁移目录缺失**env.py 不存在,`docker compose up` 会因 `alembic upgrade head` 失败 → 创建完整 alembic 环境
### 新建文件
- **alembic/env.py**:从环境变量读取 DATABASE_URL,自动转换异步驱动→同步驱动(aiosqlite→sqlite, asyncpg→psycopg2
- **alembic/script.py.mako**:标准迁移脚本模板
- **alembic/versions/6d5520491644_initial_all_tables.py**:初始迁移(9张表 + 所有索引)
- **scripts/deploy.sh**:一键部署脚本(--build/--up/--down/--status 四种模式)
- **docs/DEPLOY_NAS.md**:群晖 NAS 部署指南(SSH + Container Manager 两种方式)
### 构建验证
- frontend-h5: `vite build` 通过(10 个文件)
- frontend-agent: `vite build` 通过(8 个文件,1.2MB JS 含 Element Plus
- alembic migration: `upgrade head` 执行成功,9 张表全部创建
### 部署架构决策
- 基于日均 37 次会话的负载分析,现有 4 容器方案(PG + Redis + Backend + Nginx)完全够用
- 暂无需拆分为更复杂的微服务架构
## 共享域名部署适配(2026-06-03 晚)
### 需求
- 与 IT 数据查询平台共享域名 `http://it-dataquery.dc.servyou-it.com/`
- 路径路由:`/itdesk/`(H5员工端) + `/itagent/`(坐席端) + `/api/`(后端) + `/`(数据平台)
### 前端改动
- **frontend-h5/vite.config.ts**:添加 `base: '/itdesk/'`
- **frontend-h5/src/router/index.ts**`createWebHistory('/h5/')``createWebHistory('/itdesk/')`
- **frontend-h5/src/stores/employee.ts**OAuth2 回调 URI `/h5/``/itdesk/`
- **frontend-agent/vite.config.ts**:添加 `base: '/itagent/'`
- **frontend-agent/src/router/index.ts**`createWebHistory()``createWebHistory('/itagent/')`
- **frontend-agent/index.html**favicon 路径 `/vite.svg``/itagent/vite.svg`
- 两个前端 dist 重新构建验证通过
### Nginx 改动
- **nginx.conf** 完全重写:
- `location /itdesk/` → H5 SPAalias + try_files fallback
- `location /itagent/` → Agent SPAalias + try_files fallback
- `location /api/` → backend:8000 反代
- `location /ws/` → WebSocket 反代
- `location /` → dataquery:80 反代(兜底到数据平台)
### Docker Compose 改动
- **docker-compose.yml** 重写:
- nginx 挂载 `frontend-h5/dist → /usr/share/nginx/html/itdesk`
- nginx 挂载 `frontend-agent/dist → /usr/share/nginx/html/itagent`
- 添加 `it-desk-internal` 内部网络(PG + Redis + Backend + Nginx
- 添加 `it-platform-net` 外部网络(与数据平台互联)
- nginx 暴露 `18080:80`(临时端口,供数据平台反代或直接测试)
### 部署文件
- **scripts/deploy.sh**:更新输出信息 + 添加 `--pack` 打包模式
- **docs/DEPLOY_NAS.md**:重写为远程服务器部署指南(含两种网络接入方式)
- **.env.production**:域名改为 `it-dataquery.dc.servyou-it.com`
## T02 后端核心服务 — AI 回复集成(Dify 接入)
### 修改文件清单(11 个文件)
**配置层:**
- `backend/app/config.py` — 新增 3 个 Dify 配置项:`dify_api_url``dify_api_key``dify_timeout`
- `backend/.env` — 新增 DIFY_API_URL/KEY/TIMEOUT 环境变量
- `backend/.env.example` — 新建环境变量模板
- `.env.production` — 新增 DIFY 配置段
- `docker-compose.yml` — backend 容器新增 DIFY_* 环境变量传递
**模型层:**
- `backend/app/models/conversation.py` — 新增 `dify_conversation_id` 字段(String 128nullable),用于 Dify 多轮对话上下文
**服务层(核心):**
- `backend/app/services/message_router.py` — 完整重写,接入 Dify AI:
- `__init__` 新增 `ai_service` 参数(可选,None 时跳过 AI)
- `route_message` 流程重排:举手优先判断(跳过AI)→ AI 回复(仅 ai_handling 状态)→ 标记检测 → 评分
- 新增 `_try_ai_reply` 方法:调 Dify → 命中则通过企微发回复 + 创建 AI 消息记录 + ai_substantive_reply_count++,未命中则转 queued + 发引导文案
- `_find_or_create_conversation`:新会话默认 `ai_handling`(非 queued),活跃会话查找包含 ai_handling
- `backend/app/services/ai_service.py` — 修复配置读取:`getattr(settings, ...)``settings.dify_api_url`(直接用 pydantic 属性)
- `backend/app/services/session_service.py` — 会话排序新增 `ai_handling` (权重 25),介于 queued(30) 和 serving(20) 之间
**API 层:**
- `backend/app/api/wecom_callback.py` — 注入 AIService 到 MessageRouter,回调结束时关闭 ai_service
- `backend/app/api/h5.py` — H5 消息发送重写:
- 会话查找包含 ai_handling 状态(4处)
- `h5_send_message`:实质问题调用 Dify API 替代硬编码模板;打招呼/呼叫人工保持引导话术;Dify 异常降级到模板回复
- 响应新增 `conversation_status` 字段
- `backend/app/api/conversations.py` — status 过滤描述新增 ai_handling
### 前端适配评估
- 坐席工作台:已完整支持 ai_handling 状态——ConversationList 有「AI处理区」分区,ChatArea 有状态标签和颜色,Store 有状态排序权重
- H5 员工端:无需额外改动——通过 can_call_agent 和 ai_reply_count 驱动 UI,状态变化对 H5 透明
### AI 回复流程(全链路)
```
员工发消息(企微/H5
→ 新会话 → ai_handling
→ 举手? → 跳过AI,直接 queued
→ 调 Dify API
→ 命中 → 企微发回复 + 消息入库 + ai_count++ + 保持 ai_handling
→ 未命中 → 发引导文案 + 转 queued
→ 异常 → 降级处理 + 转 queued
→ ai_count >= 3 → H5 显示「呼叫坐席」按钮
```
## 产物文档合并与部署架构修正(2026-06-03 晚)
### 产物文档合并
- 新建 **README.md**:按阅读对象组织(新人/开发/运维/测试),含项目背景、实现进度、快速启动、API概览、已知问题
- 文档体系分层:README(入口)→ ARCHITECTURE.md(架构细节)→ docs/(专题文档)
### 部署架构偏差修正
用户指出预生产实际部署与文档描述存在偏差,已调整:
**关键偏差**:文档假设智能咨询系统与数据平台在同一 Docker 主机(通过 `it-platform-net` 共享网络互联),但预生产实际是**不同主机、仅共用域名**。正式环境会迁移到 K8s。
**修正内容**7个文件):
- **README.md**:部署章节明确「预生产独立主机,正式环境 K8s」,部署前必须先改 DATAQUERY_HOST
- **docker-compose.yml**:移除 `it-platform-net` 外部网络(Docker 网络无法跨主机),backend 和 nginx 仅连 `it-desk-internal`
- **nginx/nginx.conf**header 注释重写为「预生产·独立主机版」,`upstream dataquery` 改为 `DATAQUERY_HOST` 占位符(需替换为数据平台实际 IP),注释说明远程反代替代 Docker 网络
- **docs/01-项目总览与部署手册.md**:2.1 节新增「预生产 vs 正式环境」对比表,架构图标注跨主机代理;6.2 节「创建共享网络」→「配置数据平台反代地址」;常见问题更新
- **docs/DEPLOY_NAS.md**:网络互联部分重写,移除 it-platform-net 步骤
- **docs/团队沟通文档-架构消息知识库.md**:部署架构描述补充「预生产独立主机,正式 K8s」
- **ARCHITECTURE.md**:部署模式说明更新
### Simon→宋献 署名统一(6个文件)
- README.md、docs/01-项目总览与部署手册.md、docs/正式环境独立部署架构方案.md、docs/团队沟通文档-架构消息知识库.md、PRD.md 中所有署名「Simon」→「宋献」
- node_modules/ 中第三方库的 Simon 引用不修改(与项目署名无关)
### 新建 ai_service.py
- `backend/app/services/ai_service.py`:封装 Dify API 调用(非流式+流式),含知识库命中检测、错误降级回复
## 企微原生群聊方案可行性分析
### 背景
用户提出:能否用企微原生应用创建群聊/推送消息替代现有 H5 嵌入式员工端
### 初步结论(已修正)
- **完全可行**,企微提供两套原生 API:
- 群聊会话 API`/cgi-bin/appchat/*`):创建/修改/获取群聊 + 群内推送消息
- 应用消息 API`/cgi-bin/message/send`):1对1 推送(项目已在用),消息出现在与该应用的1对1聊天窗口中
- 关键 API 限制:appchat ≤1000群/天、appchat/send ≤2万人次/分、message/send ≤账号上限×200人次/天
- 群聊 API 要求:仅自建应用、可见范围必须根部门、只能操作本应用创建的群
- 之前"摇人功能评估"(情况1:创建企微群)已分析过 appchat 方案,用户当时暂缓确认
### 用户修正(关键纠错)
1. **员工可以看到自己发的消息** — 企微1对1应用聊天窗口中,员工自己发的和应用回复的都在同一窗口(我之前错误判断为看不到)
2. **方案B交互路径修正** — 不是"每次咨询都创建群聊",而是:
- 主流程:员工↔自建应用1对1交互,AI+坐席都走 `/message/send` → 同一窗口
- 群聊(appchat)仅在坐席需要外援时创建 → 新窗口,非常态
3. **方案A的跨平台移植便利性** — H5可嵌入企微/钉钉/飞书/浏览器,一次开发多处部署
4. **方案A可跨主体企微支持** — 非静默登录时切换其他认证方式(手机号+验证码/SSO),原生方案无法跨主体
### 修正后结论
- 方案B可行性**大幅提升**:主流程无需群聊,不需要会话存档权限,员工体验最佳
- 方案A的独特价值**被低估**:跨平台移植和跨主体支持是原生方案无法替代的
- 方案C是方案B的子集,不存在独立选型意义
- **推荐 A+B 渐进式**:先上方案B做MVP(改动极小,已在用回调+message/send),H5保留为扩展层
## 方案B文档纳入(4个文件更新)
### PRD.md §3 方案可行性判断
- 新增"方式五:企微原生1对1 + 外援群聊"到方案对比表
- 新增 §3.2 方式五详解:架构原理、交互路径、API清单、与方式四对比、关键结论
- 更新 §3.3 最终方案:从"方式四"改为"方式四+五混合演进",含选型决策逻辑
### 01-项目总览与部署手册.md §7 运维管理
- 新增 §7.5 应急预案可选技术项
- §7.5.1 备用方案概述:5种应急场景→备用方案动作映射
- §7.5.2 备用方案技术架构:交互流程图 + 已有能力 + 仅需新增
- §7.5.3 切换流程:H5→原生(5步,前3步零代码)/ 原生→H5(3步)
- §7.5.4 企微API限制与容量评估:4项API限额 vs 当前业务量
- §7.5.5 备用方案局限性与适用边界:5项局限 + 决策建议
### ARCHITECTURE.md §1.2 核心技术挑战
- 表格新增第9项"员工端架构选型":主方案H5,备选原生1对1
- 新增 §1.2.1 员工端架构双方案设计:方案A/B对比、API清单、决策建议
### 团队沟通文档-架构消息知识库.md §3.6
- 新增员工端架构双方案对比表、方案B交互路径、选型决策、运维应急引用
## 共享基础设施代码修复(2026-06-03 下午-2
### 背景
用户决定暂不选择A/B方案,先做两方案共享的基础设施工作。审计4个领域(回调服务器/后端服务/坐席前端/AI集成),发现11个需修复问题。
### 已完成的修复(主理人直接执行)
1. **Task 9: 启动时校验关键配置非占位符**`main.py` 新增 `_validate_config()`,启动时检查 wecom_corp_id/wecom_secret/wecom_token/wecom_encoding_aes_key 是否仍为占位符值,醒目警告
2. **Task 7: 坐席登录安全加固**`agents.py``agent_login` 新增企微通讯录验证:调用 WecomService.get_user_info() 校验 user_id 是否存在,验证通过后用企微返回的真实姓名覆盖前端输入(防冒用);企微API不可达时降级放行+警告日志;Login.vue 提示文案更新
3. **Message 模型扩展**Task 4前置) — `message.py` 新增5个字段:media_id(企微媒体ID)、media_url(本地存储URL)、file_name、file_size、extra_data(JSON扩展元数据);新建 Alembic 迁移 `002_add_media_fields.py`
### 企微消息XML结构调研
- 回调支持6种消息类型:text/image/voice/video/location/link
- 文件消息(file)不在回调文档中(企微可能不支持接收file类型回调)
- MediaId 仅3天有效,需收到后立即下载保存
- 所有消息都有MsgId字段(可用于去重)
### 工程师(寇豆码)进行中的任务
- Task 1: 修复H5端AI降级回复误计数
- Task 2: 统一AI调用逻辑为共享服务
- Task 3: 修复资源泄漏(callback/h5改用DI
### 待处理任务
- Task 4: 补全回调非文本消息处理
- Task 5: 添加消息去重(MsgId检查)
- Task 6: 修复ScoringService硬编码关键词+需介入检测逻辑
- Task 8: 补全会话状态机校验+消除绕过
- Task 10: 补全回调事件处理业务逻辑
- Task 11: QA验证
+81
View File
@@ -0,0 +1,81 @@
# 2026-06-04 工作日志
## AI Wingman 坐席智能辅助设计(调研+方案+文档化)
### 背景
用户提出设计逻辑:IT智能咨询不仅要帮助员工,也要帮助坐席人员摆脱机械重复工作和情绪消耗。基于此进行了行业调研和方案设计。
### 行业调研
调研了 7 家主流解决方案:
- NiCE Copilot — 实时辅导+情绪分析+自动摘要
- Helpshift AI Copilot — 情绪推送+建议回复+自动化
- Zendesk Agent Assist — 知识推荐+工单自动化
- 天润融通 — 智能填单(1分钟→10秒)、话术推荐
- 循环智能 — 流程引导+SOP导航(新人上手-50%)
- 合力亿捷 — 自动摘要(70%文书时间节省)
- Assembled — 7种copilot功能对比
### 设计方案
- **三层架构**:效率层(消灭重复)/ 认知层(降低认知负荷)/ 情感层(减少情绪消耗)
- **5大设计原则**:非侵入式、坐席主导、反馈闭环、上下文继承、渐进式赋能
- **双区布局**:内嵌区(AI草稿回复)+ 侧栏区(摘要/标签/知识推荐)
- **底层实现**:扩展现有Dify,新增坐席端Wingman Agent(与员工端Agent共用知识库)
### 用户确认的方案选择
1. **实施阶段**:全部都要,但先做MVP(Phase 1 效率层)
2. **AI方案**:扩展现有Dify(新增assistant类型Agent
3. **呈现方式**:针对性回复内嵌、通用功能侧栏
### 文档更新
- **PRD.md**:新增 §14 AI Wingman 坐席智能辅助(设计理念/行业验证/用户故事/实施方案/需求池)
- **ARCHITECTURE.md**:新增 §1.2.2 坐席端AI Wingman智能辅助架构(双区布局+三层架构+AI Agent架构)
- **团队沟通文档**:新增 §4.6 AI Wingman坐席端智能辅助(三层渐进式+双区布局+实现方案)
## 共享基础设施修复(续昨日)
### 已完成(含今日)
- ✅ Task 7: 坐席登录安全加固(企微通讯录验证)
- ✅ Task 9: 启动时配置占位符校验
- ✅ Message模型扩展(media_id等5个字段 + Alembic迁移)
- ✅ Task 1: 修复H5端AI降级回复误计数 — 降级/打招呼/呼叫人工均不计数,仅AI命中+1
- ✅ Task 2: 统一AI调用逻辑 — 新建 ai_handler.pyAIHandler),h5.py和message_router.py共用
- ✅ Task 3: 修复资源泄漏 — 新建 dependencies.py(共享服务DI),callback/h5不再手动创建实例
- 🔧 主理人补修:main.py 接入 init_shared_services()/cleanup_shared_services()
### 关键文件变更
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `backend/app/services/ai_handler.py` | **新建** | 统一AI处理器:打招呼/呼叫人工/AI调用/计数/转人工 |
| `backend/app/dependencies.py` | **新建** | 共享服务DI管理:Redis/AIService/WecomService/AIHandler |
| `backend/app/services/message_router.py` | 重构 | 替换 ai_service → ai_handler,计数逻辑统一 |
| `backend/app/api/h5.py` | 重构 | 移除本地AI逻辑/Redis管理,全面改用AIHandler+DI |
| `backend/app/api/wecom_callback.py` | 重构 | 移除手动创建服务,改用 get_shared_*() |
| `backend/app/main.py` | 修改 | lifespan接入共享服务初始化/清理 |
### 待处理
- Task 4: 非文本消息处理(图片/文件/语音)
- Task 5: 消息去重(MsgId检查)
- Task 6: ScoringService硬编码关键词修复
- Task 8: 状态机校验补全
- Task 10: 回调事件处理业务逻辑
- Task 11: QA验证
## AI Wingman Phase 1 代码实现(完成 ✅)
### 后端
- `backend/app/services/wingman_service.py`**新建** WingmanService`generate_draft()` / `generate_summary()` / `suggest_tags()`,含 JSON 解析、置信度估算、API 降级处理
- `backend/app/api/wingman.py`**新建** 3个API端点:`/api/conversations/{id}/wingman/draft|summary|tags`
- `backend/app/config.py` — 新增 `dify_wingman_api_url` / `dify_wingman_api_key` / `dify_wingman_timeout` 配置项
### 后端测试
- `backend/tests/test_wingman_service.py` — 32 个单元测试(消息映射/JSON解析/置信度/降级/初始化)
- `backend/tests/test_wingman.py` — 12 个 API 端点测试(正常路径/认证/404/降级)
- **44/44 全部通过** ✅
### 前端
- `frontend-agent/src/api/wingman.ts` — Wingman API 调用封装
- 坐席端双区布局(内嵌AI草稿 + 侧栏摘要/标签)
### QA 验证
- 严过关请求因 DNS 解析不到 `copilot.tencent.com` 报错,非代码质量问题
- 本地跑全部 44 个测试通过,确认功能正常
+67
View File
@@ -0,0 +1,67 @@
# 2026-06-05 工作日志
## 部署上线 - Bug 修复
### Bug 1: nginx `set` 指令位置错误
- **现象**: `"set" directive is not allowed here in nginx.conf:21`
- **原因**: `set` 只能在 `server`/`location` 块内使用,不能放全局
- **修复**: 移除全局的 `env DATAQUERY_HOST;``set $dataquery_host` 两行(`proxy_pass` 已硬编码 IP
### Bug 2: alembic 找不到 `app` 模块
- **现象**: `ModuleNotFoundError: No module named 'app'`
- **原因**: alembic 命令执行时 PYTHONPATH 未设置
- **修复**: docker-compose.yml command 改为 `cd /app && PYTHONPATH=/app alembic upgrade head`
### Bug 3: 前端 301 重定向死循环
- **现象**: `/itdesk/``/itagent/` 返回 301,跟随重定向后仍 301
- **原因**: `alias` + `try_files $uri $uri/` 组合触发 nginx 目录重定向
- **修复**: `try_files` 移除 `$uri/`,改为 `try_files $uri /itdesk/index.html`
### Bug 4: system_configs 重复插入(未修复,不影响功能)
- **现象**: `duplicate key value violates unique constraint "system_configs_config_key_key"`
- **影响**: 每次重启会报错但服务正常启动(第二条 `Application startup complete.`
- **待修**: INSERT 应改为 `INSERT ... ON CONFLICT DO NOTHING`(幂等插入)
## 当前部署状态
- **服务器**: 10.80.0.129:18080G端)
- **容器**: 4/4 全部 Upbackend 标记 unhealthy,功能正常)
- **前端**: /itdesk/ ✅ /itagent/ ✅
- **API**: /api/health ✅
- **数据平台**: / 代理到 10.80.0.130:8080(对方 nginx 未配业务,返回默认页)
- **待办**: 配置企微回调 URL + 验证
## Bug 5: API 路由 404 — 双重 `/api` 前缀
- **现象**: 所有 API 端点返回 404curl `/api/test-ping` → 404
- **原因**: nginx `proxy_pass` 已剥离 `/api/` 前缀,但 FastAPI `app.include_router(api_router, prefix="/api")` 又加了一次 → 实际请求路径变成了 `/api/test-ping`404
- **修复**: main.py 移除 `prefix="/api"` → 仅 `app.include_router(api_router)`
- 同时修复了 `@app.get("/api/test-ping")``@app.get("/test-ping")` 等直接路由
## Bug 6: Docker build 网络不通(G端无法访问 deb.debian.org
- **现象**: Docker build 在服务器上超时
- **解决**: 本地 Windows 构建镜像 → `docker save` → 上传 tar → 服务器 `docker load -i` 导入
## 数据库修复 — dify_conversation_id 列缺失
- **现象**: H5 AI 对话 500 报错 `column conversations.dify_conversation_id does not exist`
- **原因**: 数据库是通过 SQLAlchemy 模型直接创建的(非 alembic 迁移),model 里加了列但 DB 没有
- **修复**: `psql -U postgres -d it_smart_desk -c "ALTER TABLE conversations ADD COLUMN IF NOT EXISTS dify_conversation_id VARCHAR(128);"`
- **发现**: 服务器 `.env` 不存在,PG 只有 `postgres` 用户(默认值 `wecom` 未生效),数据库名 `it_smart_desk`
## 反向代理申请清单
- 已输出 `反向代理开通申请清单.md`,含 nginx 配置片段、网络要求、防火墙规则
- 入口:通过 `it-dataquery.dc.servyou-it.com``/itdesk/` `/itagent/` `/api/` `/ws/` 路径路由
## 本地开发环境搭建(2026-06-05 下午)
- ✅ SQLite schema 修复:conversations 表 + dify_conversation_idmessages 表 + 6 列
- ✅ Python 3.12 venv 搭建,全部依赖安装(含补装的 aiosqlite)
- ✅ Docker Redis 本地容器启动(localhost:6379 无密码)
- ✅ 后端 FastAPI 启动(localhost:80006 核心服务就绪)
- ✅ H5 前端 dev server 启动(localhost:5174.env.development 禁用 OAuth2
- ✅ 核心 AI 对话管道验证通过(H5 → 后端 → Dify → 回复)
- ⚠️ AI 回复内容显示 `[object Object]` — Dify 响应解析 bug,待修
## IT 支持知识库导入快速回复模块
- **源文件**`IT支持知识库2026-4-24.docx`830 段落,178 个知识条目)
- **导入结果**178 条全部导入 quick_reply_templates 表
- **分类分布**:硬件(13)、网络(30)、软件(46)、安全(13)、账号(2)、通用(82)
- **Category 映射**:办公电脑→硬件,软件工具→软件,办公设备→硬件,办公网络→网络,终端安全→安全,资产管理+其他业务→通用
- **API 验证**GET /quick-replies 返回 186 条(8 条预置 + 178 条导入)
+209
View File
@@ -0,0 +1,209 @@
# 2026-06-06 工作日志
## 坐席工作台原型迭代 (v5.2 → v5.3)
### v5.3 调整内容
- **排查步骤重构**:栏位始终显示不可收起,仅全流程图默认收起可通过按钮展开
- 去掉了整个排查步骤栏位的 collapse 功能
- 标题栏右侧改为「▶ 展开全流程图」/「▼ 收起全流程图」按钮
- 最优路径横向方块始终显示
- 流程图展开/收起带 max-height 过渡动画
- **系统名称确认**:顶部栏 → "IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"
- 系统名使用渐变色突出显示
- 新增 tagline 副标题表达平台定位
### 前端报错排查(17:29
- **现象**:前端报 `todo.ts:66 请求失败` + `agent.ts:131 未授权`
- **根因**:后端 FastAPI 服务未运行,Vite proxy 转发请求到 localhost:8000 被拒
- **修复**:启动后端 `uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`
- **验证**`/todo-items`(200,8条数据) `/agents/login`(200,返回token) `/agents/me`(200需token) 全部正常
- Redis (Docker): `it-desk-redis` 已确认运行中
### 页面与v5.3原型差异排查(17:35)
- **现象**:用户截图显示页面与v5.3原型差异很大
- **分析**:经逐项对比PRD和实际代码,主要差异只有2处:
1. TopBar Logo方块尺寸32px→PRD要求26px
2. UserInfoBar chips行缺少IT等级chipPRD要求chips行包含😟情绪/⏱时长/💬轮次/IT等级/🔁重复)
- **修复**
- `TopBar.vue`: `.logo-block` width/height 32px → 26px
- `UserInfoBar.vue`: chips行新增 `🖥 {{ levelName }} Lv.{{ levelNumber }}` chip,样式 `.info-chip--accent`accent色底+边框)
- 其余组件(Workspace/ConversationList/ChatArea/AiAssistantPanel/global.css/子组件)均符合v5.3 PRD规范
- 截图中空状态(暂无会话/暂无推荐/暂无待办)是因为数据库无数据,属正常行为
### 用户四问题修复(17:47
**问题**:1.标题栏没置顶 2.无双色切换开关 3.应急模式未取消 4.每种类型状态Mock数据不够
**修复**
1. **标题栏置顶**`Workspace.vue` 布局改为上下结构(TopBar在上,新增 `.workspace-body` div包裹三栏);`global.css` 新增 `.workspace-body` 样式(`flex:1;display:flex;overflow:hidden`
2. **双色切换开关**`TopBar.vue` 中主题按钮替换为 `el-switch`,带Sunny/Moon图标 + 深色(#0f1923)/浅色(#f5f7fa)双色背景;添加 `themeSwitchValue` ref + `watch` 同步 + `onThemeSwitch` 回调
3. **应急模式移除**TopBar.vue 删除应急横幅/开关按钮/`handleEmergencyToggle`/`checkEmergencyMode`/`defineExpose`/相关样式;Workspace.vue 删除 `checkEmergencyMode()` 调用;`ElMessageBox` 补回(logout还在用)
4. **Mock数据扩充**
- `todo_items.py`: MOCK_TODO_ITEMS 8→20条(覆盖全部类型ticket/approval/device × 状态pending/processing/resolved),日期 2025→2026
- `seed_conversations.py`(新建): 往SQLite写入15条会话Mock(覆盖queued/serving/ai_handling/resolved + VIP/情绪/阻断属性)
### 会话列表API 500错误 + WebSocket连接失败(18:20
**现象**
- `GET /api/conversations?page=1&page_size=100` 返回 500
- `WebSocket connection to 'ws://localhost:5173/ws/740' failed`
**根因分析**
1. **500错误**`session_service.py``get_conversations()` 用 SQL 侧 `case()` + `tags["hand_raise"].as_boolean()` 排序,SQLite 不支持 JSONB 操作符,SQL 执行报错
2. **WebSocket失败**`useWebSocket.ts``window.location.host`(前端 5173),Vite `/ws` 代理转发有兼容性问题
**修复**
1. **会话排序改为 Python 侧**`session_service.py` 第629-712行):
- 移除 SQL 侧 `case()` + JSON 操作符
- 数据库侧只做基础排序(置顶+紧急度+状态+时间)
- Python 侧 `_sort_key()` 函数完整实现 PRD 排序规则(置顶→紧急度5→举手→需介入→紧急度4→情绪→紧急度3→排队→AI处理→服务中→已结单)
- 支持 SQLite(开发)和 PostgreSQL(生产)
2. **WebSocket 直连后端**`useWebSocket.ts` 第96-100行):
- 开发环境(`import.meta.env.DEV`):`ws://localhost:8000/ws/{agentId}`
- 生产环境:同源 `wss://` 通过 nginx 代理
- 移除对 Vite `/ws` 代理的依赖
3. **重启后端**使代码生效
### 快速回复三层渐进导航重构(18:46)
**用户需求**
1. L1目录不用滑动条,1~2行显示完全;目录名中去掉"Alt+N",改为数字图标;搜索栏显示使用说明
2. 快速回复按知识库三层结构逐步缩小范围,Alt+数字→数字→数字→Enter 填入
**实现**
- **CSS重构**`.qr-tabs``.qr-l1-grid`2列grid,无滚动)/ `.qr-l2-row`flex-wrap chip,无滚动)/ `.qr-l3-list`(纵向滚动列表)
- **HTML重构**:搜索栏 placeholder→"搜索快速回复 / Alt+目录数字";面包屑导航+返回按钮;L1/L2/L3三层渲染容器;选中预览条
- **JS重构**`qrData` 88条三级结构化数据(8大类×20子类×约50条回复)
- 8个L1分类:安全🛡/网络🌐/邮箱📧/系统💻/账号🔑/硬件🖥/数据💾/话术💬
- 键盘导航:Alt+1~8选L1 → 数字1~N选L2 → 数字1~N选L3 → Enter填入输入框
- Esc/Backspace 返回上级;"/" 聚焦搜索框
- **文件**`agent-workspace-v5_3.html` 直接修改
### 快速回复数据源替换为真实知识库(18:57)
**用户需求**:按《IT支持知识库2026-4-24.docx》真实目录结构和内容替换三层快速回复
**实现**
- 用 python-docx 读取 `C:\Users\simon\Downloads\IT支持知识库2026-4-24.docx`
- 提取文档目录结构:Heading1(L1)→Heading2(L2)→Heading3(L3=问题)→正文(答案)
- 生成独立数据文件 `qr_data.js`35KB180条)
- **7大L1分类**:办公电脑💻(3子类12条) / 软件工具🛠(8子类47条) / 办公外设🖨(5子类27条) / 办公网络🌐(2子类28条) / 终端安全🛡(4子类13条) / 资产管理📊(3子类31条) / 其他业务📋(8子类22条)
- HTML 中移除内联数据(~230行),改为 `<script src="qr_data.js"></script>` 外部引用
- L1网格改为3列(7项=3+3+1=3行),Alt+1~7快捷键
- 临时提取脚本 `extract_qr.py` 已清理
### 原型同步至 Vue 3 开发代码(19:56~20:10
**背景**:用户确认原型 v5.3,要求同步快速回复三层渐进导航至 `wecom_it_smart_desk` 项目。
**同步内容**
1. **新增** `frontend-agent/src/data/qrData.ts` — 7大类层级数据(电脑/软件/外设/网络/安全/资产/其他),含 TypeScript 类型定义(QrCategory/QrSubCategory/QrItem
2. **重写** `frontend-agent/src/components/assistant/QuickReplyPanel.vue` — 三层渐进导航:
- L1: 7列 grid,按钮上下排列(数字在上/名称在下),无 icon
- L2: chip 横向流式布局
- L3: 纵向列表 + 选中预览条
- 面包屑导航 + 返回按钮
- 搜索过滤(跨层级)
- 保持 `emit('use-template', content)` 接口不变
3. **更新** `frontend-agent/src/composables/useKeyboardShortcuts.ts`
- Alt+1~7 扩展至 7 个分类
- 新增 `onQuickReplyDigit`(数字键 1-9
- 新增 `onQuickReplyBack`(←/Backspace 返回)
- 保持对输入框聚焦的智能过滤
4. **清理** 工作区临时 Python 修复脚本(qrData 引号修复相关)
**验证**:vue-tsc 编译通过,无新增 TS 错误(预存错误 5 个,与本次修改无关)
**文件清单**
- `C:\Users\simon\wecom_it_smart_desk\frontend-agent\src\data\qrData.ts`(新建)
- `C:\Users\simon\wecom_it_smart_desk\frontend-agent\src\components\assistant\QuickReplyPanel.vue`(重写)
- `C:\Users\simon\wecom_it_smart_desk\frontend-agent\src\composables\useKeyboardShortcuts.ts`(更新)
### 完整 Mock 数据基建 + Quick Reply 数据同步(20:14~20:35
- 从 IT支持知识库2026-4-24.docx 重新提取完整 180 条数据(python-docx + json.dumps 安全转义)
- 名称简化为电脑/软件/外设/网络/安全/资产/其他 → 同步至原型 HTML + `src/data/qrData.ts`
- **新建** `src/mock/data.ts` — 统一 mock 数据源:
- 10会话(queued/serving/ai_handling/resolved + blocking/VIP/pinned/举手/需介入/情绪标签)
- 12消息(text/image/system/ai_suggestion + employee/agent/ai/system
- 5待办(ticket/approval/device + urgent/high/normal/done
- 5坐席(online/busy/offline)、用户画像(张伟档案)、AI推荐(4条 + summary + tags
- **更新 Stores** mock fallback(仅 DEV 环境 + API 失败时):
- `conversation.ts`: fetchConversations/fetchMessages
- `todo.ts`: fetchTodoList
- `agent.ts`: login/refreshAgentInfo/loadAvailableAgents
- **修复** `TodoPanel.vue` agent stats 从 getAgentStats() 读取
- **原型 HTML 丰富**+3会话(不同状态) + 2待办(normal/done) + AI内联建议 + 2条AI推荐 + 新CSS类
- vue-tsc 编译通过(仅预存6错误)
### 原型布局调整:排查步骤栏上移(22:30)
- **用户需求**:排查步骤栏从输入框下方移到人员信息栏下方、消息区域上方
- **实现**:Node.js 脚本精确移除→插入,追踪嵌套 div 深度定位闭合标签
- **最终布局**user-info-bar → user-detail-panel → **troubleshoot-bar** → chat-messages → chat-input-area
### 原型调整:AI推荐归位 + 输入框自适应(22:51)
- **用户需求1**:AI智能推荐从中间栏移回右边栏
- 移除 `ai-recommend-inline`chat-messages 内联回复选项)
- 移除 `msg-ai-suggestion`AI建议横幅)
- 右边栏 `ai-recommend-section` 保持不变
- **用户需求2**:输入框随内容自动调节高度 + 支持手动拖拽
- CSS: `resize: none``resize: vertical``max-height: 100px``300px`,新增 `overflow-y: auto`
- JS: `autoResize()` 函数(监听 input → `scrollHeight` 自适应,上限300px
- `fillInput()` 调用 `autoResize()` 同步更新
### Vue 3 项目修复:AI推荐回归右边栏 + 右边栏默认可见(22:55)
- **根因**`ChatArea.vue` 中包含 `<AiRecommendInline />` 内联组件,导致AI推荐同时出现在中间栏和右边栏
- **修复**
- `ChatArea.vue`: 移除 `AiRecommendInline` 模板使用、import、ref声明、`onAiRecommend` 快捷键绑定
- `Workspace.vue`: `assistantVisible` 默认值 `false``true`,右边栏默认可见
- **验证**: vue-tsc 无新增错误(仅预存5错误)
### 原型HTML结构修复 + Vue 3 会话加载修复(23:04
- **原型根因**`chat-view` div 缺少 `</div>` 闭合标签,导致浏览器解析将 `sidebar-right`AI推荐+快速回复)嵌套到 `center-column` 内部,显示在中栏
- **原型修复**:在 `chat-input-area` 关闭后补 `</div>` 闭合 `chat-view`,使 `sidebar-right` 成为 `center-column` 的兄弟元素
- **Vue 3 根因**`Workspace.vue` onMounted 未调用 `fetchConversations()``currentConversation` 始终为 null`ChatArea` 不渲染
- **Vue 3 修复**onMounted 中添加 `await conversationStore.fetchConversations()` + 自动选中第一个会话
- **需重启 dev server 生效**
### wecom_it_smart_desk 目录清理(23:50
**执行背景**:项目目录 524 MB,95% 为缓存/日志/过期产物,需要精简后迁移
**清理结果**
- 删除:~499 MB95% 精简)
- 保留:~25 MB(核心代码 + 文档 + 数据库)
- 根目录文件:102 个 → ~25 个
**已删除分类**
1. 缓存/构建产物(519 MB):`itdesk-images.tar`222MB)、`itdesk.tar.gz``node_modules/`2个,202MB)、`venv/`94MB)、`dist/`2个)、`__pycache__/``pytest_cache/`
2. 空文件(~10 个):所有 0 字节 `.txt` 文件
3. 根目录重复脚本(~50 个 `.py`):`run_tests*.py``diagnose*.py``test_*.py``check_*.py``fix_*.py``restart_*.py`
4. 后端根目录诊断脚本(`_*.py`~20 个)
5. 过期日志/输出文件(`*.txt`~20 个)
6. 遗留系统代码:`docs/existing_system_code/`2.3 MB,旧 Django 项目)
**已归档**`scripts/archive/`5 个有用脚本:`simulate_wecom*.py``import_knowledge_base.py``start_8001.py``analyze_report.py`
**保留文件**:核心代码(backend/app/、frontend-agent/src/、frontend-h5/src/)、文档(PRD.md、ARCHITECTURE.md、QA_TEST_REPORT.md)、数据库(`it_smart_desk.db`)、配置(`.env``.env.example``docker-compose.yml``nginx.conf`
**迁移注意**:目标机器需重新执行 `npm install`2 个前端)、`python -m venv venv && pip install -r requirements.txt`(后端)
**清理报告**`C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\wecom_it_smart_desk-清理报告.md`
### Vue 3 项目同步:排查步骤合并+展开箭头修正(23:25)
- **TroubleshootBar.vue**
- 路径步骤从独立 `.troubleshoot-bar__path` 区域合并到 `.troubleshoot-bar__header` 同一行
- 展开按钮从 `el-button` 文字按钮简化为三角图标 `▶`/`▼``.troubleshoot-bar__toggle`
- CSS 重构:紧凑行布局(`min-height: 36px`),内联步骤标签 `.path-step-inline`,内联箭头 `.path-arrow-inline`
- **UserInfoBar.vue**
- 收起时 `▶`(向右=可展开),展开时 `▼`(向下,`rotate(90deg)`
- 之前方向反了:`▼``▲``rotate(180deg)`
- **验证**:vue-tsc 无新增错误(仅预存5错误)
- **需重启 dev server 生效**
- **排查步骤栏**:路径图(①②③④⑤)合并到标题栏同一行,展开全流程图按钮简化为三角图标 ▶/▼
- ts-header 改为紧凑行:`[🔧 排查步骤] [①→②→③→④→⑤] [▶]`
- 移除独立 ts-path-view 区域,改为 ts-path-inline 内联
- 移除 ts-flowchart-btn 按钮样式,改为纯图标 ts-flowchart-toggle
- toggleFlowchart() 简化为 textContent 切换
- **用户信息栏**:展开箭头方向修正
- 收起时 ▶(向右,表示可展开)→ 展开时 ▼(向下,rotate(90deg)
- 之前是收起时 ▼ 展开时 ▲(方向反了)
- **原型根因**`chat-view` div 缺少 `</div>` 闭合标签,导致浏览器解析将 `sidebar-right`AI推荐+快速回复)嵌套到 `center-column` 内部,显示在中栏
- **原型修复**:在 `chat-input-area` 关闭后补 `</div>` 闭合 `chat-view`,使 `sidebar-right` 成为 `center-column` 的兄弟元素
- **Vue 3 根因**`Workspace.vue` onMounted 未调用 `fetchConversations()``currentConversation` 始终为 null`ChatArea` 不渲染
- **Vue 3 修复**onMounted 中添加 `await conversationStore.fetchConversations()` + 自动选中第一个会话
- **需重启 dev server 生效**
+581
View File
@@ -0,0 +1,581 @@
# 2026-06-07 工作日志
## 工作空间合并
**目标**:将 `C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\` 的内容按类型并入 `C:\Users\simon\wecom_it_smart_desk\`,统一为单工作空间。
**合并清单**
| 来源 | 文件数 | 目标位置 |
|------|--------|----------|
| `.workbuddy/memory/` | 7 个 md | `wecom_it_smart_desk/.workbuddy/memory/` |
| HTML 原型 + 数据 | 6 HTML + 2 数据 | `wecom_it_smart_desk/docs/prototypes/` |
| 项目文档 | 4 个 md | `wecom_it_smart_desk/docs/` |
| 活跃脚本 | move_ts_bar.py | `wecom_it_smart_desk/scripts/` |
| 归档脚本 | 4 个 py | `wecom_it_smart_desk/scripts/archive/` |
| 历史日志 | 11 个 txt | `wecom_it_smart_desk/scripts/archive/logs/` |
**额外清理**
- 移除 `frontend-agent/node_modules/`115MBnpm install 重建)
- 移除 `backend/venv/`14MBpip install 重建)
- 最终目录大小:~2.9MB(纯代码+文档,无依赖)
## 文档迁移与目录整理(2026-06-07 08:50
**目标**:将根目录文档按类型迁移至 docs/ 对应子目录,规范项目结构。
**执行操作清单**
| 操作 | 文件/目录 | 目标位置 | 状态 |
|------|-----------|----------|------|
| 移动 | PRD.md | docs/PRD.md | ✅ 完成 |
| 移动 | ARCHITECTURE.md | docs/ARCHITECTURE.md | ✅ 完成 |
| 移动 | QA_TEST_REPORT.md | docs/testing/QA_TEST_REPORT.md | ✅ 完成 |
| 移动 | QA_WS_Test_Report.md | docs/testing/QA_WS_Test_Report.md | ✅ 完成 |
| 移动 | TESTING_CALL_AGENT.md | docs/testing/TESTING_CALL_AGENT.md | ✅ 完成 |
| 移动 | docs/*.mermaid (5个) | docs/diagrams/ | ✅ 完成 |
| 归档 | gent-workspace-v3~v5_2.html (5个) | docs/prototypes/archive/ | ✅ 完成 |
| 删除 | pi_test_*.json (6个) | — | ✅ 完成 |
| 删除 | ackend_log_8001.txt | — | ✅ 完成 |
| 更新 | README.md 中 ARCHITECTURE.md 链接 | 更新为 docs/ARCHITECTURE.md | ✅ 完成 |
**新建目录**
- docs/testing/ — 测试报告专用目录
- docs/diagrams/ — Mermaid 图表专用目录
- docs/prototypes/archive/ — 历史原型归档目录
**README.md 链接更新**:共6处引用 ARCHITECTURE.md,已全部更新为 docs/ARCHITECTURE.md。
**记忆文件整理**
- 检查 .workbuddy/memory/*.md,所有文件均在30天以内(最新2026-05-21),无需蒸馏。
- 更新 MEMORY.md,添加文档管理规则:「后续所有新建文档统一保存在 docs/ 目录下」。
**锁定决策**
- 项目文档规则已写入 MEMORY.md 的「锁定的设计决策」章节,后续新建文档必须遵守。
## QA 报告合并与脚本迁移(2026-06-07 09:13
### QA 报告合并
- **原因**`docs/testing/QA_TEST_REPORT.md`2026-06-03WebSocket 功能)与 `docs/testing/QA_WS_Test_Report.md`2025-07-04,v5.3 坐席工作台)内容不重复,但同属 QA 报告
- **操作**:合并为 `docs/testing/QA_COMPREHENSIVE_REPORT.md`,按时间倒序排列,含报告索引表
- **删除原文件**`QA_TEST_REPORT.md``QA_WS_Test_Report.md`
### 脚本迁移
- **原因**`start_backend.bat``restart_backend.ps1` 散落在根目录,应归入 `scripts/`
- **操作**:已迁移至 `scripts/`
- **注意**:两个脚本含硬编码路径(`C:\Users\simon\wecom_it_smart_desk\...`),后续需改为相对路径
### 当前根目录剩余文件
- `README.md` — 必须保留在根目录
- `docker-compose.yml` — 必须保留在根目录
- `docs/` — 文档目录
- `scripts/` — 脚本目录(含迁移后的两个脚本)
- `backend/``frontend-agent/``frontend-h5/` — 代码目录
- `.workbuddy/` — 工作记忆目录
## 脚本路径修复与文档重命名(2026-06-07 09:18
### 修复 start_backend.bat
- **问题**:第2行 cd /d C:\Users\simon\wecom_it_smart_desk\backend 为硬编码绝对路径;第3行 Python 路径硬编码
- **修复**
- 使用 %~dp0 获取脚本所在目录,计算项目根目录(scripts 上级目录)
- Python 执行文件优先使用 env\Scripts\python.exe,找不到则使用 PATH 中的 python
- **效果**:脚本可从任意位置运行,不再依赖固定安装路径
### 修复 restart_backend.ps1
- **问题**PostgreSQL/Redis/Python/backend 目录均为硬编码绝对路径
- **修复**
- 使用 $MyInvocation.MyCommand.Path 获取脚本路径,动态计算项目根目录
- PostgreSQL:尝试常见安装路径 + Get-Command psql 查找
- Redis:尝试常见安装路径 + Get-Command redis-cli 查找
- Python:优先 env\Scripts\python.exe,其次 PATH 中的 python
- backend 目录:通过项目根目录拼接,不再硬编码
- **效果**:脚本在任意机器上均可运行(前提是 PostgreSQL/Redis 已安装且在 PATH 中)
### 文档重命名
- docs/overview.md → docs/开发交付概览.md(文件名与内容主题一致)
## 架构文档合并(2026-06-07 09:34
### 背景
- 两份架构文档:ARCHITECTURE.md(标记 v1.0,实际未上线)和 ARCHITECTURE-v53-incremental.mdv5.3 增量,状态"待评审"
- 用户确认:功能未正式上线,未达 v1.0,两份文档均为"同类成果",可以合并为同一版本
### 执行操作
1. **更新 ARCHITECTURE.md 头部信息**
- 版本改为:`v0.9(合并版)`
- 状态改为:`草稿(未上线,待评审)`
- 新增说明行:`说明: 本文档已合并原 ARCHITECTURE-v53-incremental.md 内容(v5.3 坐席工作台增量架构),合并日期 2026-06-07。`
- 目录新增第9章:`9. [v5.3 坐席工作台增量架构](#9-v53-坐席工作台增量架构)`
2. **将增量文档作为第9章合并入 ARCHITECTURE.md**
- 去掉增量文档头部(第1-9行:标题/版本/日期/作者/状态/基线)
- 增量文档正文作为 `## 9. v5.3 坐席工作台增量架构` 追加到主文档末尾(原"文档结束"行之前)
- 章节编号保持原样(§1~§7),在第9章开头加说明:"章节编号保持原样以便对照原文档"
3. **归档增量文档**
-`docs/ARCHITECTURE-v53-incremental.md` 已移至 `docs/archive/`
4. **更新 docs/开发交付概览.md**
- 第26-63行:项目结构树已更新为当前实际目录结构
- 第12行:`ARCHITECTURE.md` 引用已修正为 `docs/ARCHITECTURE.md`
### 合并后文档结构
```
ARCHITECTURE.mdv0.9 合并版)
├── 第1章 实现方案与框架选型(原主文档)
├── 第2章 文件列表(原主文档)
├── 第3章 数据结构与接口(类图)(原主文档 + 增量类图)
├── 第4章 程序调用流程(时序图)(原主文档 + 增量时序图)
├── 第5章 任务列表(原主文档)
├── 第6章 依赖包列表(原主文档)
├── 第7章 共享知识(原主文档)
├── 第8章 待明确事项(原主文档)
└── 第9章 v5.3 坐席工作台增量架构(原增量文档,章节编号保持原样)
├── §1 实现方案与框架选型(增量)
├── §2 文件列表(增量)
├── §3 数据结构与接口(增量)
├── §4 程序调用流程(增量)
├── §5 任务列表(增量)
├── §6 共享知识(增量)
├── §7 待明确事项(增量)
├── 附录 C:关键组件 Props/Emits 定义(增量)
└── 附录 D:数据库迁移注意事项(增量)
```
### 注意事项
- 第9章内部章节编号与主文档第1~8章不连续(主文档 §1~§8,第9章内 §1~§7)
- 附录编号顺延:原主文档附录 A/B,增量文档附录 A/B 改为附录 C/D
- 合并后 ARCHITECTURE.md 总行数约 2690 行(原 1775 行 + 增量 915 行)
## PRD 文档合并(2026-06-07 10:00
### 背景
- 两份 PRD 文档:`PRD.md`v1.0,标记"已确认")和 `PRD-v53-incremental.md`v5.3 增量,状态"待评审"
- 用户确认:功能未正式上线,未达 v1.0,两份文档均为"同类成果",可以合并为同一版本
### 执行操作
1. **更新 PRD.md 头部信息**
- 版本改为:`v0.9(合并版)`
- 状态改为:`草稿(未上线,待评审)`
- 新增说明行:`说明: 本文档已合并原 PRD-v53-incremental.md 内容(v5.3 坐席工作台增量需求),合并日期 2026-06-07。`
- 目录新增第15章:`15. [v5.3 坐席工作台增量需求](#15-v53-坐席工作台增量需求)`
2. **将增量文档作为第15章合并入 PRD.md**
- 去掉增量文档头部(第1-8行:标题/版本/日期/作者/状态/目录)
- 增量文档正文作为 `## 15. v5.3 坐席工作台增量需求` 追加到主文档末尾(原"文档结束"行之前)
- 章节编号保持原样(§1~§9),在第15章开头加说明
3. **归档增量文档**
-`docs/PRD-v53-incremental.md` 已移至 `docs/archive/`
### 合并后文档结构
```
PRD.mdv0.9 合并版)
├── 第1章 项目信息(原主文档)
├── 第2章 项目背景(原主文档)
├── ...
├── 第14章 AI Wingman — 坐席智能辅助设计(原主文档)
└── 第15章 v5.3 坐席工作台增量需求(原增量文档,章节编号保持原样)
├── §1 项目信息(增量)
├── §2 原始需求复述(增量)
├── ...
└── §9 交付检验(增量)
```
## 开发交付概览合并到项目总览手册(2026-06-07 10:15
### 背景
- `docs/开发交付概览.md`:开发交付状态(TL;DR / 交付状态 / Bug 修复清单 / 下一步操作)
- `docs/01-项目总览与部署手册.md`:管理者/运维视角(项目概述 / 系统架构 / 部署操作手册 / 运维管理 / 附录)
- 两者为互补关系(非重复),"开发交付状态"可作为"项目总览"的新章节
### 执行操作
1. **将 `开发交付概览.md` 作为第8章合并入 `01-项目总览与部署手册.md`**
- 插入位置:"七、运维管理"之后、"八、附录"之前
- 原"八、附录"改为"九、附录"(章节编号连续)
- 新章节标题:`## 八、开发交付状态`
- 原文件中的二级标题(## TL;DR / ## 交付状态 / ...)改为三级标题(### TL;DR / ### 交付状态 / ...
2. **更新 `01-项目总览与部署手册.md` 目录**
- 添加第8章:`8. [开发交付状态](#八开发交付状态)`
- 原第8章(附录)改为第9章:`9. [附录](#九附录)`
3. **归档原文件**
-`docs/开发交付概览.md` 已移至 `docs/archive/`
### 合并后文档结构
```
01-项目总览与部署手册.md(v2.1)
├── 一、项目概述
├── 二、系统架构
├── 三、三步演进路径
├── 四、现有系统复用评估
├── 五、正式环境部署方案
├── 六、部署操作手册
├── 七、运维管理
├── 八、开发交付状态(原 开发交付概览.md)
└── 九、附录
```
## 当前 docs/ 目录文档关系总结(2026-06-07 10:20
### 已合并文档对
| 主文档 | 增量文档 | 合并后位置 | 增量文档处理 |
|---------|-----------|------------|--------------|
| `docs/PRD.md` | `docs/PRD-v53-incremental.md` | 第15章 | 归档到 `docs/archive/` |
| `docs/ARCHITECTURE.md` | `docs/ARCHITECTURE-v53-incremental.md` | 第9章 | 归档到 `docs/archive/` |
| `docs/01-项目总览与部署手册.md` | `docs/开发交付概览.md` | 第8章 | 归档到 `docs/archive/` |
### 未合并文档(独立)
| 文件 | 定位 | 说明 |
|------|------|------|
| `docs/README.md`(根目录) | 项目主文档(GitHub 首页) | 必须保留在根目录,已更新内部链接 |
| `docs/IT智能服务台-项目迁移文档.md` | 工作区迁移记录 | 独立文档,无需合并 |
| `docs/wecom_it_smart_desk-清理报告.md` | 一次性清理操作记录 | 建议归档到 `docs/archive/`(已执行?) |
| `docs/摇人-多坐席协作-技术方案.md` | 技术方案文档 | 独立文档,无需合并 |
| `docs/正式环境独立部署架构方案.md` | 部署方案文档 | 独立文档,无需合并 |
| `docs/DEPLOY_NAS.md` | NAS 部署文档 | 独立文档,无需合并 |
| `docs/团队沟通文档-架构消息知识库.md` | 团队沟通记录 | 独立文档,无需合并 |
| `docs/反向代理开通申请清单.md` | 运维申请清单 | 独立文档,无需合并 |
| `docs/testing/QA_COMPREHENSIVE_REPORT.md` | 综合测试报告 | 已合并(之前将两份QA报告合并为此文件) |
### 下一步建议
1. **归档 `wecom_it_smart_desk-清理报告.md`**(一次性操作记录,无长期参考价值的)→ 移到 `docs/archive/`
2. **合并 `README.md` 与 `01-项目总览与部署手册.md`** → 不建议,因为 `README.md` 必须保留在根目录(GitHub 首页),但可以减少 `README.md` 中的重复内容,改为指向 `docs/01-项目总览与部署手册.md`
## 清理报告归档(2026-06-07 10:30
### 执行操作
- **文件**`docs/wecom_it_smart_desk-清理报告.md`
- **原因**:一次性清理操作记录,无长期参考价.值,属于"已执行完毕"的历史记录
- **操作**:已移至 `docs/archive/wecom_it_smart_desk-清理报告.md`
- **验证**:Glob 确认源文件已不存在,archive 目录中存在该文件
### 当前 docs/ 根目录文件清单(归档后)
| 文件 | 状态 | 说明 |
|------|------|------|
| `PRD.md` | ✅ 合并版 | 含第15章增量 |
| `ARCHITECTURE.md` | ✅ 合并版 | 含第9章增量 |
| `01-项目总览与部署手册.md` | ✅ 合并版 | 含第8章交付状态 |
| `IT智能服务台-项目迁移文档.md` | 独立 | 迁移记录,无需合并 |
| `摇人-多坐席协作-技术方案.md` | 独立 | 技术方案,无需合并 |
| `正式环境独立部署架构方案.md` | 独立 | 部署方案,无需合并 |
| `DEPLOY_NAS.md` | 独立 | NAS部署,无需合并 |
| `团队沟通文档-架构消息知识库.md` | 独立 | 沟通记录,无需合并 |
| `反向代理开通申请清单.md` | 独立 | 运维清单,无需合并 |
| `testing/` | 目录 | 测试报告 |
| `diagrams/` | 目录 | Mermaid图表 |
| `prototypes/` | 目录 | 原型文件 |
| `archive/` | 目录 | 历史归档(含3个增量文档+清理报告) |
### 合并工作总结
| 合并批次 | 主文档 | 增量文档 | 完成时间 |
|----------|---------|----------|----------|
| 第1批 | `ARCHITECTURE.md` | `ARCHITECTURE-v53-incremental.md` | 09:34 |
| 第2批 | `PRD.md` | `PRD-v53-incremental.md` | 10:00 |
| 第3批 | `01-项目总览与部署手册.md` | `开发交付概览.md` | 10:15 |
| 第4批 | 归档 `wecom_it_smart_desk-清理报告.md` | — | 10:30 |
**所有"版本不同或存在包含关系"的文档已全部合并/归档完成。**
## PRD 痛点补充校正(2026-06-07 11:26
### 背景
用户补充了4条深层痛点(管理与人效层),原PRD仅有3条体验层痛点。
### 新增痛点(2.1.2 深层痛点)
| # | 痛点 | 说明 |
|---|------|------|
| 4 | 人工咨询依赖个人能力和经验 | 容易受个人情绪和状态影响 |
| 5 | 实习生成长慢、辅导价值低 | 在岗时间短且不稳定,辅导老师投入和工作价值缺乏优势 |
| 6 | 个人经验无法积累传承 | 坐席人员个人经验和成果无法有效积累、传承、迭代更新 |
| 7 | 缺乏数据支撑的管理盲区 | 坐席人员能力和绩效、IT支持员工满意度缺乏有效数据支撑 |
### 文档修改清单
1. **§2.1 标题**"三大痛点" → "痛点分析",拆分为两个子章节:
- `2.1.1 现有痛点(体验层)`:原痛点1-3
- `2.1.2 深层痛点(管理与人效层)`:新增痛点4-7
2. **痛点关系说明**:新增段落解释痛点1-7之间的因果关系链
3. **§3.1 方案对比表**:从3列扩展为7列(新增痛点4-7),更新各方案对深层痛点的覆盖评估
4. **原始需求描述**:更新为"七项痛点"
## PRD §3 方案章节重构(2026-06-07 11:42
### 背景
原PRD §3仅详解方式五,方式四作为当前推进方案反而没有详细说明。用户明确:
- 方式四才是当前推进的主方案,应重点讲解
- 方式五是应急备选方案(AI服务不可用时切换)
- 若方式四整体故障,则退回"企微-员工服务-桌面IT支持"仅人工最简方式
- 其他方式也应简要描述原理和优劣
### 文档修改清单
1. **§3.1 方案对比表**:方式四标注为"当前推进方案",方式五改为"应急备选"
2. **新增 §3.2 各方案原理与优劣**:每个方式独立子章节,含原理说明、优缺点表格、结论
- 方式一/二/三:简要描述原理+优劣+结论
- 方式四:⭐重点详解(架构图+交互路径+三步演进+优缺点+关键API+结论)
- 方式五:定位为应急备选,保留架构图+优缺点+API清单+与方式四对比表
3. **新增 §3.3 降级应急预案**:L0正常→L1 AI降级→L2 方式五切换→L3 完全回退
4. **删除原 §3.2/3.2.1~3.2.4/3.3**:内容已重新组织到新结构中
## PRD + ARCHITECTURE 文档更新 — 现状对比+5阶段演进+H5推送(2026-06-07 12:47
### 背景
1. 用户确认员工端H5 WebView已设置,坐席主动发消息能通过企微 `/message/send` 推送通知给员工
2. 但H5页面内不会自动刷新(当前仅轮询),需补充WebSocket实时推送方案
3. 现有生产环境(企微AI机器人+RAGFlow+Dify+千问+员工服务)需在PRD中体现并对比
4. 用户明确5阶段演进路径,替代原有3步演进
### PRD.md 修改清单
1. **§2 项目背景** — 新增 §2.1 现有生产环境现状(架构图+组件表+核心问题表),原 §2.1 痛点分析改为 §2.2
2. **§3.1 方案对比表** — 新增"现有生产环境"行作为对比基准,增加关键差异说明
3. **§3 方式四** — 新增 H5端实时消息推送方案(3种机制对比+双通道通知策略+WS技术方案+现有系统对比表)
4. **§5 演进路径** — 从3步改为5阶段:①AI机器人接入(按服务对象) ②迁移和集成面向员工的智能咨询功能 ③面向坐席的辅助回复和辅助判断 ④日志标准和AI知识库迭代 ⑤自动/辅助审核开单结单
5. **§13 里程碑** — 对齐5阶段演进,增加"现有系统变化"列
6. **文档版本** — v0.9 → v0.10
### ARCHITECTURE.md 修改清单
1. **§1.2.1a** — 新增现有生产环境架构(架构图+与新系统对比表+AI引擎复用决策)
2. **§1.2.1b** — 新增 H5 端 WebSocket 实时推送架构(双通道策略图+WS端点设计+前端实现+与现有代码的关系)
3. **文档版本** — v0.9 → v0.10
### 关键设计决策
- **AI引擎复用,不替换**:现有RAGFlow+Dify+千问继续使用,仅迁移员工入口和坐席工具
- **双通道通知策略**:企微 `/message/send`(必达)+ H5 WebSocket(即时),互为补充
- **5阶段渐进演进**:每个阶段现有生产环境保持可用作为降级通道
## PRD 痛点分析与阶段对应关系更新(2026-06-07 13:50
### 背景
用户反馈:痛点分析中的痛点需要与"开发升级功能"(五阶段演进)建立对应关系,便于追溯每条痛点在哪个阶段被解决。
### PRD.md 修改清单
#### 1. §2.2 痛点分析表格 — 新增「解决阶段」列
| # | 痛点 | 解决阶段 |
|---|------|---------|
| 1 | 员工绕过AI直接进人工 | **阶段二** |
| 2 | 需另开窗口 | **阶段二** |
| 3 | 无法跨主体共享 | **阶段二** |
| 4 | 人工咨询依赖个人能力和经验 | **阶段三** |
| 5 | 实习生成长慢、辅导价值低 | **阶段三** |
| 6 | 个人经验无法积累传承 | **阶段四** |
| 7 | 缺乏数据支撑的管理盲区 | **阶段四** |
#### 2. §2.2 痛点关系说明 — 更新阶段标注
原:`痛点1-3为员工体验层问题,痛点4-7为管理与人效层问题...`
改:`痛点1-3为员工体验层问题(阶段二解决),痛点4-5为坐席能力层问题(阶段三解决),痛点6-7为管理迭代层问题(阶段四解决)。阶段五主要解决多系统切换效率问题`
#### 3. §5.1 阶段总览表 — 新增「解决痛点」列
| 阶段 | 解决痛点 |
|------|---------|
| 阶段一 | 痛点1(部分)、API入口统一 |
| 阶段二 | **痛点1/2/3** |
| 阶段三 | **痛点4/5** |
| 阶段四 | **痛点6/7** |
| 阶段五 | 多系统切换效率问题 |
#### 4. §5.2 各阶段详细规划 — 每个阶段开头新增「本阶段解决痛点」引用块
- 阶段一:`> **本阶段解决痛点**:API入口统一(为阶段二打基础),按服务对象路由。`
- 阶段二:`> **本阶段解决痛点**:痛点1(绕过AI)、痛点2(另开窗口)、痛点3(无法跨主体共享)。`
- 阶段三:`> **本阶段解决痛点**:痛点4(人工咨询依赖个人能力)、痛点5(实习生成长慢)。`
- 阶段四:`> **本阶段解决痛点**:痛点6(个人经验无法积累传承)、痛点7(缺乏数据支撑的管理盲区)。`
- 阶段五:`> **本阶段解决痛点**:多系统切换效率问题(延伸痛点4/5,进一步提升人效)。`
### 修改方法笔记
- Edit 工具对长字符串匹配容易失败,采用逐行精确替换策略(每次只替换1行表格数据)
- Bash/PowerShell 工具在 Windows 上执行 Python 脚本均失败,最终采用逐行 Edit 完成
- §2.2 表格逐行替换成功(8次 Edit 调用:1次表头 + 7次数据行)
- §5.1 表格逐行替换成功(6次 Edit 调用:1次表头 + 5次数据行)
- §5.2 各阶段标注成功(5次 Edit 调用)
## PRD 痛点归纳压缩(2026-06-07 14:10
### 背景
用户反馈:痛点分析项太多(原7条),应进行归纳总结和压缩,减少痛点数量。
### 归纳方案(7条 → 4条核心痛点)
| 新# | 核心痛点 | 归纳自原痛点 | 解决阶段 |
|-----|------------|---------------|---------|
| 1 | **员工入口体验差** | 原1(绕过AI)+ 原2(另开窗口)+ 原3(无法跨主体) | 阶段二 |
| 2 | **坐席能力不稳定** | 原4(人工咨询依赖个人能力)+ 原5(实习生成长慢) | 阶段三 |
| 3 | **知识无法积累传承** | 原6(个人经验无法积累传承) | 阶段四 |
| 4 | **管理缺乏数据支撑** | 原7(缺乏数据支撑的管理盲区) | 阶段四 |
### PRD.md 修改清单
1. **§2.2 痛点分析表格** — 7行 → 4行,新增「具体表现」列(归纳说明)
2. **§2.2 痛点关系说明** — 更新为「痛点1(员工体验层)→ 阶段二;痛点2(坐席能力层)→ 阶段三;痛点3~4(管理迭代层)→ 阶段四」
3. **§3.1 方案对比表** — 7列痛点 → 4列痛点(痛点1~4),重新评估每个方案的 ✅/❌/⚠️
4. **§3.2 各方案原理与优劣** — 更新说明部分(引用痛点1~4,不再引用痛点1-7)
5. **§5.1 阶段总览表** — 「解决痛点」列更新为新的4条痛点编号
6. **§5.2 各阶段详细规划** — 每个阶段开头的「本阶段解决痛点」引用块更新
7. **文档版本** — v0.10 → v0.11
### 修改方法
- Edit 工具逐行替换(每次1行),§3.1 表头+6数据行均成功
- §3.2 中4处"痛点4-7"引用全部更新为"痛点2-4"
- 所有修改均在单次对话内完成,未使用 Python 脚本
## PRD 阶段一范围扩大 — 坐席工作台MVP前移(2026-06-07 16:30
### 背景
用户明确阶段一方案:继续使用企微AI机器人接入本地Dify+RAGFlow+千问大模型,将AI机器人转人工的链接从"企微员工服务"改为新的H5 WebView(嵌入企微自建应用),同时交付坐席自研工作台MVP。坐席能摆脱企微内置员工服务的限制,使用快速回复等新功能。
用户确认阶段一坐席工作台采用**MVP最小可用**范围:会话列表+聊天窗口+发送消息+快速回复面板(三级导航)。复杂功能(AI推荐、排查步骤、待办面板)留到阶段二/三。
### PRD.md 修改清单(v0.11 → v0.12
1. **§3 方式四总览表** — 阶段一坐席端从"无(保留员工服务后台)"改为"自研工作台MVP(会话列表+聊天+快速回复)"
2. **§5.1 阶段总览表** — 阶段一核心变更更新为"将AI机器人转人工链接改为H5自建应用+交付坐席自研工作台MVP"
3. **§5.2 阶段一详细规划** — 完全重写:
- 标题改为"AI机器人接入+坐席工作台MVP"
- 现状→目标对比表:转人工行从"暂保留关键字触发→推送链接"改为"关键字触发→推送H5链接+坐席自研工作台接入";新增坐席端、快速回复行
- 范围拆分为员工端(H5)、坐席端(自研工作台MVP)、后端变更三部分
- 完成标准更新为包含坐席工作台的完整流程
- 开发周期从4-6周调整为6-8周
4. **§5.2 阶段二详细规划** — 移除"坐席工作台MVP"(已前移),新增坐席AI建议面板+用户信息栏+会话标记;开发计划从6周缩短为5周
5. **§13 里程碑表** — 阶段一/二交付物和周期更新
### ARCHITECTURE.md 修改清单(v0.10 → v0.11
1. **文档版本** — v0.10 → v0.11,说明更新
2. **§1.2.1a** — 关键决策段落后新增"阶段一实施路径"说明
### MEMORY.md 更新
- 五阶段演进路径中阶段一/二描述更新
## PRD 阶段一范围精准化(2026-06-07 16:40
### 背景
用户纠正理解偏差:企微AI机器人+Dify+RAGFlow+千问**本来就在用**,不存在"接入"动作。阶段一只做三件事:①员工端H5登录+身份识别 ②转人工链接改H5 ③坐席自研工作台MVP(会话+快速回复,不含AI)。
### 修改
- PRD.md §3/§5.1/§5.2 阶段一 — 标题改为"转人工改H5+坐席工作台MVP",新增"关键前提"引用块,AI引擎行标"不变",坐席AI能力"暂不接入"
- PRD.md §5.2 阶段二 — 坐席端增强移除AI建议面板,明确"不含AI"
- ARCHITECTURE.md §1.2.1a — 阶段一实施路径重写
- MEMORY.md — 阶段一/二描述精准化
## 本地测试环境启动(2026-06-07 17:30
### 操作步骤
1. Docker Compose 4容器启动:postgres, redis, backend, nginx(端口 18080
2. 创建前端 dist/ 占位目录 → 启动 Docker → 占位 index.html
3. npm install + npx vite build 构建两个前端(跳过 vue-tsc 类型检查)
4. docker restart nginx 加载新构建产物
### 构建结果
- 坐席端 frontend-agent1739 modules, 4.6s, 构建成功
- H5员工端 frontend-h5414 modules, 1.45s, 构建成功
### 服务状态
| 容器 | 状态 | 端口 |
|------|------|------|
| wecom_it_nginx | healthy | 18080→80 |
| wecom_it_postgres | healthy | 5432(内部) |
| wecom_it_redis | healthy | 6379(内部) |
| wecom_it_backend | 运行中(API正常) | 8000(内部) |
| it-desk-redis | 运行中(旧容器) | 6379→6379 |
- 后端 API `/api/health` 返回 `{"status":"ok","service":"wecom-it-smart-desk"}`
- Docker healthcheck 显示 backend "unhealthy"(初始化重复数据错误导致首次检测失败),但实际服务正常
- 旧容器 `it-desk-redis` 疑为之前配置遗留,不影响当前服务
### 访问地址
- 坐席工作台:http://localhost:18080/itagent/
- H5员工端:http://localhost:18080/itdesk/
- 后端APIhttp://localhost:18080/api/health
### 待修复
- backend TypeScript 错误(5处):vue-tsc 失败,需修复后才能用 `npm run build`
- 旧容器 `it-desk-redis` 需清理
## NAS+Cloudflare Tunnel+未认证企微 部署方案(2026-06-07 18:05
### 背景
用户确认用群晖NAS+Cloudflare Tunnel+未认证企微推进阶段一功能测试。
- 域名:amanzac.com(已托管Cloudflare
- NAS:群晖 Container Manager 可用
- 企微:有管理后台权限
## Mock 登录模式实现(2026-06-07 23:45
### 背景
未认证企微无法配置可信域名(备案主体不匹配),OAuth2 网页授权不可用。
### 解决方案
实现 Mock 登录模式:后端新增 `/api/h5/mock-login` 端点,生成真实 Bearer Token 并存入 Redis,跳过企微 OAuth2 流程。
### 修改文件
1. `backend/app/config.py` — 新增 `mock_login_enabled: bool = False`
2. `backend/app/api/h5.py` — 新增 `POST /api/h5/mock-login` 端点
3. `frontend-h5/src/api/employee.ts` — 新增 `mockLogin()` API 函数
4. `frontend-h5/src/stores/employee.ts` — 新增 `mockLogin()` store 方法
5. `frontend-h5/src/views/Login.vue` — 改为调用后端 mock-login 获取真实 token
6. `.env.nas` — 新增 `MOCK_LOGIN_ENABLED=true`
7. `docker-compose.nas.yml` — 传递 `MOCK_LOGIN_ENABLED` 环境变量
### Mock 登录流程
员工输入 UserID → 前端调用 `/api/h5/mock-login` → 后端生成 Bearer Token → 存入 Redis → 返回 token + 员工信息 → 前端保存 token → 后续 API 正常走 Bearer 认证
### ZIP 包已重新打包(0.9MB
桌面 `wecom-it-desk-nas.zip` 已更新,包含 Mock 登录相关代码。
### 关键结论
- 未认证企微对**内部自建应用**无API限制(OAuth2/消息发送/回调全可用)
- 未认证仅限制第三方应用开发,200人上限对测试够用
- Cloudflare Tunnel 解决公网HTTPS回调问题,无需公网IP/SSL证书/开放端口
### 新增文件
1. `docker-compose.nas.yml` — NAS专用Docker Compose5容器:cloudflared+nginx+backend+postgres+redis
2. `nginx/nginx-nas.conf` — NAS专用Nginx配置(移除数据平台反代,增加CF真实IP还原,X-Forwarded-Proto https
3. `.env.nas` — NAS部署环境变量模板
4. `docs/NAS部署指南.md` — 完整分步操作指南(含Cloudflare配置+企微配置+测试清单)
### 架构
互联网 → Cloudflare Edge(HTTPS) → Cloudflare Tunnel → NAS Docker nginx:80 → { /itdesk/, /itagent/, /api/, /ws/ }
### 待用户操作
1. 在Cloudflare Dashboard创建Tunnel(获取Token
2. 配置Public Hostnameitdesk.amanzac.com → HTTP → nginx:80
3. 将项目文件部署到NAS
4. 企微管理后台配置自建应用
## 坐席端+H5端深浅色主题修复与开发(2026-06-07 17:23
### 任务1:坐席端主题切换样式修复(Task #23)
**问题**:坐席端 TopBar 使用 Element Plus `el-switch` 组件做主题切换,与原型 v5.3 的自定义滑轨样式不一致。
**修改文件**`frontend-agent/src/components/layout/TopBar.vue`
**修改内容**
1.`el-switch` + `el-tooltip` 替换为自定义 `div.theme-switch`(☀️ + switch-track + switch-thumb + 🌙)
2. 移除 `Sunny`/`Moon` 图标导入和 `themeSwitchValue` ref/watch
3. 将 el-switch 样式覆盖替换为原型 v5.3 的自定义滑轨 CSS40x22px track + 18x18px thumb + translateX(18px) 深色状态)
### 任务2:H5员工端深浅色切换功能开发(Task #24)
**新增文件**
1. `frontend-h5/src/composables/useTheme.ts` — 主题切换 composableapplyTheme + getInitialTheme + 系统偏好检测)
2. `frontend-h5/src/stores/theme.ts` — 主题 Pinia StorecurrentTheme + toggleTheme + initTheme
**修改文件**(硬编码颜色 → CSS 变量):
1. `frontend-h5/src/styles/global.css` — 完全重写:浅色 `:root` + 深色 `[data-theme="dark"]` 双主题变量体系 + 主题切换滑轨 CSS
2. `frontend-h5/src/App.vue` — 用 `<van-config-provider :theme="themeStore.currentTheme">` 包裹 + onMounted 初始化主题
3. `frontend-h5/src/components/chat/ChatPanel.vue` — 标题栏添加主题切换按钮(☀️滑轨🌙)+ 替换5处硬编码颜色 + 新增 header-actions 容器
4. `frontend-h5/src/views/ChatView.vue` — 替换3处硬编码颜色(bg-primary/border-color/accent 渐变)
5. `frontend-h5/src/components/chat/MessageBubble.vue` — 替换9处硬编码颜色(employee-bg/agent-bg/ai-bg/ai-text/system-text 等)
6. `frontend-h5/src/components/chat/InputBar.vue` — 替换7处硬编码颜色(bg-tertiary/border-color/accent/text-primary 等)
7. `frontend-h5/src/components/assistant/AiHelperPanel.vue` — 替换3处硬编码颜色
8. `frontend-h5/src/components/chat/CallAgentModal.vue` — 替换5处 UI 颜色(modal bg/text/btnSVG 动画颜色保留)
9. `frontend-h5/src/components/assistant/ComingSoon.vue` — 替换2处颜色
10. `frontend-h5/src/components/assistant/ApprovalLinks.vue` — 替换1处颜色
11. `frontend-h5/src/components/assistant/SoftwareDownloads.vue` — 替换2处颜色
12. `frontend-h5/src/views/Login.vue` — 替换4处颜色
**保留的硬编码颜色**(功能性/装饰性,不随主题变化):
- 员工消息气泡文字 `#ffffff`(蓝底白字)
- CallAgentModal SVG 动画 fill 颜色(插画内容)
- ShakeButton 红点 `#ee0a24`(功能性指示)
- 浮动按钮文字 `#ffffff`(蓝底白字)
**构建验证**
- H5 端:`npm run build` ✅ 成功
- 坐席端:5 处预先存在的 TS 错误(与本次修改无关),TopBar.vue 无新增错误
+170
View File
@@ -0,0 +1,170 @@
# 2026-06-08 工作日志
## 主要工作:NAS部署调试 + PRD审读
### 解决的问题
1. **部署包目录结构问题** — 之前用 PowerShell `Compress-Archive` 打包时,`nginx/nginx-nas.conf` 被压到了 zip 根目录,导致 NAS 解压后路径错误。已改用 Python `zipfile` 重新打包,确保目录结构正确。
2. **Windows `\r\n` 换行符问题**`.env` 文件从 Windows 上传到 NAS (Linux) 后,`\r` 被当成普通字符读入变量值,导致:
- `POSTGRES_DB=wecom_it_desk\r` → 后端连接数据库 `wecom` 失败
- 修复方法:`sed -i 's/\r$//' .env`
3. **postgres 健康检查发现错误数据库**`docker-compose.nas.yml` 第61行:
```yaml
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wecom}"]
```
`pg_isready` 默认连接与用户名同名的数据库 `wecom`,但数据库实际叫 `wecom_it_desk`。
修复:改为 `pg_isready -U ${POSTGRES_USER:-wecom} -d ${POSTGRES_DB:-wecom_it_desk}`
4. **Docker 卷清理问题** — `docker volume rm` 需要容器先停止才能删除。正确方法:`docker compose -f docker-compose.nas.yml down -v`(加 `-v` 参数会连卷一起删除)
### 最终成功标志
后端启动日志显示:
```
✅ 使用 PostgreSQL 数据库: postgres:5432/wecom_it_desk
✅ 数据库表检查/创建完成
✅ 默认数据初始化完成
✅ Application startup complete.
```
### 待确认
- [ ] nginx 容器是否正常启动(之前报错 `nginx-nas.conf does not exist`
- [ ] Cloudflare Tunnel 是否正常转发流量
- [ ] Mock 登录页面是否能正常访问
### 下一步
1. 确认 nginx 状态,若配置文件缺失则重新上传
2. 测试 Mock 登录功能(访问 `https://itdesk.amanzac.com/`
3. 配置企微 AI 机器人转人工链接为 H5 页面
### 下午追加 — API 响应格式修复
4. **Cloudflare Tunnel 503 修复** — cloudflared 加 `--url http://nginx:80` 参数解决 "No ingress rules"
5. **Mock 登录端点 404** — 后端 Docker 镜像是旧代码,`build --no-cache` + `up -d` 重建
6. **Mock 登录参数** — 正确参数名 `employee_id`(非 `user_id`
7. **数据库缺列** — conversations 表缺 impact_scope/is_blocking/emotion_state/dify_conversation_idALTER TABLE SQL 已提供
8. **API 响应格式不统一** — todo_items.py / troubleshooting_templates.py / employees.py 三个文件直接返回 Pydantic 模型,未用 `success_response()` 包裹为 `{code:0, data:{}, message:"success"}`,导致前端拦截器 `res.code !== 0` 报"请求失败"。已全部修复为 `success_response(data=...)` 格式
9. **员工端超时** — H5 前端调用 `/h5/conversations/current`,需先执行 ALTER TABLE 修复缺列才能正常工作
10. **企微IP白名单** — NAS 出口 IP 117.147.35.138 未加白名单(errcode=60020),后端已降级放行
### 深浅色主题同步 v5.3(下午继续)
11. **CSS变量体系完全同步原型v5.3**:
- accent 统一为 `#3b82f6`(替换 Agent端 `#409eff` + H5端 `#1989FA`
- 新增变量:`--border`/`--text-muted`/`--success-soft`/`--danger-soft`/`--warning-soft`/`--accent-soft`/`--purple`/`--orange`/`--shadow`/`--transition`/`--radius`
- 深色模式色值同步:bg-primary=#0f1923, bg-secondary=#151f2b, bg-tertiary=#1a2736 等
12. **Agent端 87+ 处硬编码颜色全部替换为CSS变量**:
- 影响组件:UserInfoPanel(15) / RiskAlert(12) / InviteDialog(11) / UserInfoBar(10) / MessageBubble(7) / FlowchartNode(7) / AiDraftBubble(5) / TopBar(4) / QuickReplyPanel(2) / ReplyBox(1) / TodoPanel(2) / TroubleshootBar(1) / ApprovalDetail(4) / TicketDetail(2) / DeviceDetail(2) / AiRecommendInline(1) / Login
- global.css 中 tag-badge-*/urgency-star/message-agent/ai-tag/conversation-avatar/it-badge 的 #fff → var(--bg-secondary)
13. **H5端 10 处硬编码颜色替换**:
- ChatView(2) / ChatPanel(1) / MessageBubble(1) / AiHelperPanel(1) / ComingSoon(1) / ShakeButton(2) / CallAgentModal(2)
14. **两端构建验证通过**
- Agent: `dist/index.html` + `dist/assets/Workspace-*.css` + `dist/assets/index-*.js`
- H5: `dist/index.html` + `dist/assets/ChatView-*.css` + `dist/assets/index-*.js`
- ⏳ 待部署到 NASscp 上传 + `docker restart wecom_it_nginx`
15. **NAS部署完成**(内网IP 192.168.3.200,非 10.80.0.129):
- scp 上传 H5 + Agent dist 至 NAS
- nginx 重启后前端生效
16. **代码同步检查与修复**(NAS更新后验证本地代码一致性):
22. **坐席工作台原型 v5.4 调整**:
- 基于 v5.3 创建 `agent-workspace-v5_4.html`
- 左栏会话列表新增:头像 + 新消息圆点指示器(3色:紧急红/普通蓝/低优灰)+ 处理对象缩略头像
- 我的会话:左侧头像(员工)+ 圆点(有/无新消息)+ 右侧缩略头像(处理对象=员工本人)
- 同事会话:左侧头像(员工)+ 圆点 + 右侧缩略头像(处理坐席)
- 待办事项:右侧新增 ki-avatar 缩略头像(处理对象=上报人/部门)
- 历史会话:仅头像,无圆点,无缩略头像
- 举手图标沿用原有 `conv-tag-urgent` 样式
- 所有原有样式和内容完整保留
- 修复 ConversationItem.vue 遗漏 2 处:`#9b59b6` → `var(--purple)`、`#c0c4cc` → `var(--text-placeholder)`
- 修复 H5 MessageBubble.vue 注释:`#1989FA` → `var(--accent)`
- 全量扫描确认:所有残留硬编码色值均为可保留项(Login渐变 + SVG插图)
24. **Vue3 前端代码同步 v5.4 原型改动**:
- ConversationItem.vue 重写:头像渐变色(av-blue~av-pink 7色) + 新消息圆点(dot-urgent红/dot-normal蓝/dot-muted灰3色) + 处理对象缩略头像(ta-blue~ta-pink) + section prop(my/colleague/history)控制缩略头像逻辑
- ConversationList.vue 重写:取消三段折叠(section-header/ArrowDown/myExpanded等全部移除),三区始终展开扁平显示
- TodoPanel.vue:待办条目右侧新增 ki-avatar 缩略头像(ka-blue~ka-red 5色,hash分配)
- ReplyBox.vue 重写:上方4px拖拽手柄(调整输入区高度+textarea同步) + 快捷工具栏(表情/图片/截图/文件/语音/远程协助/快速回复 7个按钮+分隔线+hover提示气泡+三角箭头) + 输入框+发送按钮合为圆角卡片(.chat-input-card) + textarea resize:none + 发送按钮渐变蓝紫(accent→purple) + 聚焦时卡片蓝色描边+外发光
- Workspace.vue 重写:左右栏border移除+6px拖拽手柄替代(resize-handle) + 可拖拽调整左栏/右栏宽度(200~500px) + mousedown/mousemove/mouseup事件处理 + body光标切换+userSelect控制
- global.css 更新:workspace-sidebar/assistant 去掉border+添加position:relative + 新增resize-handle样式(hover变蓝+::after显示⋮) + conversation-item 改为flex+gap+圆角+border + 新增conv-avatar-wrap/new-msg-dot(3色)/conv-target-avatar(7色) + 新增ki-avatar(5色) + conversation-info改为flex:1+min-width:0
23. **坐席工作台原型 v5.4 二次调整**:
- 取消会话分类折叠:我的会话/同事会话/历史会话全部始终展开,移除折叠箭头和 collapsed 类
- 消息输入框:padding 上下间距调整,上边框从 1px 改为 3px solid var(--border) 做视觉分隔
- 中间栏左右边框:改为拖拽手柄(6px宽),鼠标悬停变蓝+显示拖拽指示符,可手动拖拽调整左栏/右栏宽度(范围200~500px)
- 原型 v5.3 accent=#3b82f6 已与代码完全一致
- 后端/配置文件不涉及主题变更,无需更新
17. **企微内嵌网页无法加载修复**:
- 根因1:OAuth2 回调地址不匹配 — 后端默认构造 `/h5/`Nginx 只有 `/itdesk/`
- 修复1(前端):`employee.ts` 的 `getOAuthAuthorizeUrl()` 传入 `redirect_uri` 参数
- 修复2(后端):`h5.py` 默认回调从 `/h5/` 改为 `/itdesk/`
- 根因2:可信域名/OAuth2回调域需备案主体匹配 → 当前域名无法通过验证
- 方案B落地:创建 `.env.production` 清空 `VITE_WECOM_CORP_ID`,关闭 OAuth2 走 Mock 登录
- H5 构建通过,待部署至 NAS
- 后续拿到公司备案域名后删除 `.env.production` 即可切回 OAuth2
19. **PRD 审读与问题标注**
- 全面审读 PRD.md1552行),对比 15个API文件、13个模型文件、9个Service类
- 发现 31 项需明确/细化问题:P0×5 + P1×11 + P2×15
- P0 核心矛盾:PRD 定义"阶段一不接AI/不用WebSocket",但代码已深度集成
- P0 最大阻断:OAuth2不可用 + 端到端流程从未验证
- P1 关键缺失:H5 WebSocket未实现、排队系统未实现、满意度评分未实现、数据模型文档严重滞后
- 建议:PRD 升版到 v1.0,新增 Non-goals/Launch Criteria/安全/监控章节
20. **战略观点确认与PRD v1.0更新**
- 用户确认四个战略观点:①资源审批期并行推进 ②管理后台为第三端 ③AI混合策略 ④零基础原则
- 确认三系统集成:Dify管配置/RAGFlow阈值自动推送/数据平台短期DB只读+iframe长期API
- 确认管理后台10大模块(功能开关P0/坐席管理P0/分配模式P1/快速回复P1/主题P2/会话监控P1/数据看板P1/流程图P1/知识库P2/外部集成P0~P2
- 确认消息分配6种模式(轮询/手动/最少活跃/加权/技能匹配/优先队列)渐次启用
- 确认AI混合策略L1~L4四层架构:标注粒度B(标注+实际回复内容),迭代触发B(阈值推荐)
- 确认阶段细化:1A/1B/1C → 2A/2B/2C/2D → 3A/3B/3C → 4A/4B/4C
- 确认零基础边界:管理后台配置一切,代码修改需开发但控制颗粒度,操作者=坐席组长
- PRD 升版至 v1.0,新增 §18管理后台远景规划 + §19系统生态与集成规划 + §20阶段细化与并行推进策略
- MEMORY.md 同步更新:五阶段细化 + 管理后台 + AI混合策略 + 系统生态 + 零基础原则
21. **现实校准更新**
- 消息分配模式:当前1人足够,手动接单完全满足,6种模式为远景按坐席规模渐次解锁
- 排查流程图+Dify实现路径确认4步:JSON导入导出→Dify变量/知识条目导出→HTTP回调分支→可视化拖拽
- PRD §18.3 更新为"手动接单优先+远景渐次解锁",§19.7 细化为分阶段实现路径
- §20.2B 和 §20.3 推荐事项同步更新
15. **H5端API超时问题确认已解决**:
- 后端日志显示 `/h5/user` → 200, `/h5/conversations/current` → 200
- nginx 代理链路正常:`localhost:18080/api/h5/approval-links` → HTTP 200 + 数据
- 之前超时是后端重启未就绪的瞬时问题
---
## 技术笔记
### NAS 部署关键配置
- **Cloudflare Tunnel Token** 已配置:`CF_TUNNEL_TOKEN=eyJhIjoi...`
- **企微配置** 已填入:
- `WECOM_CORP_ID=wwa8c87970b2011f41`
- `WECOM_AGENT_ID=1000133`
- `WECOM_SECRET=EOtQslW7WD8Rna8Nm9WnwCW-ozHP3tustL4mFnet6O8`
- **Mock 登录已启用**`MOCK_LOGIN_ENABLED=true`
### 文件位置(NAS
```
/volume1/docker/wecom-it-desk/
├── docker-compose.nas.yml
├── .env # 从 .env.nas 复制并填入真实值
├── nginx/
│ └── nginx-nas.conf # ← 之前缺失,已重新打包
├── frontend-h5/dist/ # H5 员工端静态文件
├── frontend-agent/dist/ # 坐席工作台静态文件
└── backend/ # 后端源码(会构建为 Docker 镜像)
```
+143
View File
@@ -0,0 +1,143 @@
# 2026-06-09 工作日志
## H5用户端原型创建
- 创建 `docs/prototypes/h5-user-v1.html` — H5用户端完整原型(移动端单栏)
- 包含组件:顶部标题栏(坐席在线状态+主题切换) / 消息列表(AI+员工+坐席+系统) / 排查步骤交互卡片(决策节点+步骤节点) / 底部输入栏(敲桌子+3行输入+发送) / 呼叫坐席弹窗
## H5用户端主设备确认 + 双布局原型
- 用户确认:H5用户端~70%从企微桌面端自建应用进入,非手机端为主
- 锁定决策:H5响应式布局 — ≥500px双栏(消息+右侧排查面板),≤480px单栏(排查步骤内嵌)
- 坐席工作台阶段一仅桌面端
- 创建 `docs/prototypes/h5-user-v1_1.html` — 双布局对比原型
- 左:企微桌面端模拟(720×560) — 双栏布局(消息+排查面板+用户信息卡)
- 右:企微手机端模拟(375×740) — 单栏布局(排查步骤内嵌消息流)
- 差异标注:桌面端排查面板始终可见+用户信息卡+设备状态图标 / 手机端排查内嵌+无用户卡
## H5用户端右侧面板调整(v1.2)
- 用户需求调整:桌面端右侧面板改为三段式布局
- 上方:AI推送区(根据排查步骤和会话内容动态推送相似问题处理指南、申请流程入口、软件下载地址等)
- 中部:固定常用资源标签页(资源申请流程入口、常用必装软件)
- 下方:趣味问答(答对可提高用户积分和等级)
- 手机端:隐藏右侧面板,排查步骤内嵌消息流
- 新增规则:影响显示效果的代码更新前,必须先通过原型图确认
- 创建 `docs/prototypes/h5-user-v1_2.html` — 三段式右侧面板原型
- 更新项目记忆锁定设计决策
## H5用户端排查步骤位置调整(v1.3)
- 用户需求调整:电脑端(桌面端)也需要将排查步骤卡片嵌入会话流,而非放在右侧面板
- 桌面端+手机端统一:排查步骤作为卡片出现在消息列表中(紧跟坐席消息之后)
- 右侧面板专注于三段式布局(AI推送/常用资源/趣味问答),不再包含排查步骤
- 创建 `docs/prototypes/h5-user-v1_3.html` — 排查步骤嵌入会话流原型
- 更新项目记忆:排查步骤卡片嵌入会话流确认
## H5用户端v1.4三项需求调整
- 创建 `docs/prototypes/h5-user-v1_4.html` — 三项调整原型
- 调整1:桌面端无消息发送功能,底部改为只读消息展示框(默认3行可见,高度随内容自适应)
- 调整2:敲桌子按钮取消,回归摇铃🔔呼叫人工坐席(桌面端在标题栏,手机端在输入栏)
- 调整3:桌面端消息框和侧边栏都可手动拖拽调节(左右栏拖拽手柄+底部消息框上下拖拽手柄)
- 更新项目记忆锁定设计决策
## H5用户端v1.5 排查步骤固定+输入栏优化
- 创建 `docs/prototypes/h5-user-v1_5.html` — 核心调整原型
- 调整1:排查步骤从会话流移出,固定在消息框顶部(桌面端+手机端统一),始终可见不随滚动消失,可收起/展开
- 调整2:桌面端仍无消息发送功能(确认不变)
- 调整3:手机端输入栏增加工具栏(表情😊/图片🖼️/文件📎/拍照📸)
- 调整4:摇铃🔔与发送按钮➤同侧右侧排列
- 提供3种手机端输入栏布局方案对比:
- 方案A(推荐):工具栏+输入行分离,摇铃与发送同侧右侧
- 方案B:单行紧凑+展开项,+号展开更多工具
- 方案C:摇铃在工具栏最左,发送独立右端
- 更新项目记忆锁定设计决策
## H5用户端v1.6 排查步骤置顶+桌面端输入框
- 创建 `docs/prototypes/h5-user-v1_6.html` — 核心调整原型
- 调整1:排查步骤从消息框顶部上移至消息区顶部(标题栏下方、所有消息之上),固定不随滚动消失,桌面端+手机端统一
- 调整2:桌面端添加完整消息输入框(含表情😊/图片🖼️/文件📎/拍照📸工具栏 + 🔔摇铃 + ➤发送),修正v1.4的"无发送功能"决策
- 调整3:手机端确认方案A(工具栏+输入行分离,摇铃与发送同侧右侧),移除方案对比卡片
- 桌面端与手机端输入栏布局完全统一(方案A)
- 更新项目记忆锁定设计决策
## H5用户端v1.7 桌面端拉长+手机端摇铃上移
- 创建 `docs/prototypes/h5-user-v1_7.html` — 两项修复
- 修复1:桌面端原型从560px拉长至820px,确保输入框(工具栏+输入+🔔+➤)完整可见
- 修复2:手机端摇铃按钮从输入栏移至标题栏坐席状态右侧(🔔呼叫 胶囊按钮),与桌面端一致
- 手机端输入栏简化:仅工具栏+输入框+➤发送(无摇铃)
- 更新项目记忆锁定设计决策
## H5用户端v1.8 修复桌面端截断
- 创建 `docs/prototypes/h5-user-v1_8.html` — 修复v1.7显示问题
- 根因:`.desktop-shell` 固定高度820px + `overflow:hidden`,但内部内容实际总高约853px(企微顶栏36+标题栏42+排查步骤165+消息区部分+输入栏95+右侧面板510),导致输入框和趣味问答被裁掉不可见
- 修复:桌面端壳体高度从820px→940px,确保所有内容完整可见
- 更新项目记忆:原型版本锁定为v1.8
## H5用户端原型拆分为独立页面
- 根因:v1.8仍无法完整显示桌面端输入框和趣味问答(固定壳体高度+overflow:hidden反复导致底部截断)
- 解决方案:桌面端和手机端原型拆分为独立HTML文件,各自撑满视口,彻底消除高度截断问题
- 创建 `docs/prototypes/h5-user-desktop-v1.html` — 桌面端独立原型
- 使用 100vh 全视口高度,无固定壳体高度限制
- 企微顶栏模拟 → 标题栏 → 排查步骤(固定顶部) → 消息流 → 输入栏(工具栏+输入+🔔+➤) | 拖拽 | 右侧三段式面板(AI推送/资源/趣味问答)
- 输入框、趣味问答完整可见
- 创建 `docs/prototypes/h5-user-mobile-v1.html` — 手机端独立原型
- 375×812 手机壳居中展示
- 标题栏(坐席在线+🔔呼叫+主题) → 排查步骤(固定顶部) → 消息流 → 输入栏(工具栏+输入+➤)
- 摇铃在标题栏(与桌面端一致),输入栏仅工具栏+输入框+发送
- 更新项目记忆:原型版本锁定为v1(独立页面版)
## H5用户端v1.1 修复(桌面端输入栏+拖拽)
- 修复 `docs/prototypes/h5-user-desktop-v1.html`
- 修复1:输入栏摇铃按钮移除 — 只保留标题栏的 🔔呼叫 胶囊按钮,输入栏仅保留工具栏+输入框+➤发送
- 修复2:拖拽逻辑重写 — 根因:原逻辑同时固定左右两侧宽度,计算偏差导致右侧留白;修复:只固定左侧宽度,右侧 `flex:1` 自动填满剩余空间,彻底消除拖拽后右侧空白
- 手机端 `h5-user-mobile-v1.html` 无需修改(输入栏原本就无摇铃)
- 更新项目记忆:原型版本更新为 v1.1(修复版)
## H5用户端原型图 → Vue3代码实现
- 根据已锁定的原型图 v1.1 修复版,开始将设计实现为 Vue3 代码
- 修改 `frontend-h5/src/components/chat/ChatPanel.vue`
- 标题栏重构:左侧(标题+坐席在线/离线状态胶囊) + 右侧(🔔呼叫按钮+主题切换)
- 🔔摇铃按钮从输入栏移至标题栏(桌面端+手机端统一)
- 排查步骤固定在消息区顶部(不随滚动消失),从消息列表内移出
- 移除 InputBar 的 @call-agent 事件(摇铃已在标题栏直接控制 CallAgentModal
- 修改 `frontend-h5/src/components/chat/InputBar.vue`
- 移除摇铃按钮及相关 CSSbell-btn/bell-icon/bell-idle/bell-ring 动画)
- 新增工具栏:😊表情/🖼️图片/📎文件/📸拍照(4个圆形按钮)
- 布局改为两行:工具栏(上) + 输入行(输入框+发送按钮)(下)
- 新增 handleEmoji/handleImage/handleFile/handleCamera 方法(阶段二实现具体功能)
- 引导条文案更新:"点击标题栏铃铛呼叫 IT 坐席"
- 创建 `frontend-h5/src/components/assistant/RightPanel.vue`
- 三段式面板:AI推送区 / 常用资源标签页(申请流程/必装软件) / 趣味问答
- AI推送区:3种卡片类型(guide/process/download) + 动态图标+颜色
- 常用资源:2个Tab(申请流程/必装软件) + 资源列表(4项)
- 趣味问答:题目+4选项+积分+答题结果反馈
- 阶段一使用静态数据,阶段二接入Dify动态推送
- 修改 `frontend-h5/src/views/ChatView.vue`
- 替换 AiHelperPanel → RightPanel(三段式面板)
- 响应式断点从768px改为500px(与原型图对齐)
- 移动端(<500px)不显示右侧面板
- 拖拽逻辑修复:只固定左侧宽度,右侧 flex:1 自动填满(消除拖拽后空白)
- 移除移动端浮动AI助手按钮(已不需要)
- 修改 `frontend-h5/src/stores/conversation.ts`
- 新增 agentOnline 状态(默认true,阶段一简化处理)
- 在 return 语句中暴露 agentOnline
## H5原型→代码实现 收尾
- CSS变量修复:global.css 补充 `--color-success-soft`/`--color-warning-soft`/`--color-danger-soft` 变量(浅色+深色双主题),ChatPanel.vue 坐席状态胶囊引用了 `--color-success-soft` 但 global.css 中只有 `--success-soft`
- TS错误修复:
- RightPanel.vue:注释掉未使用的 `store``useConversationStore` import(阶段二启用)
- InputBar.vue`const emit = defineEmits``defineEmits`(消除 TS6133 未使用变量警告)
- 构建验证:`vue-tsc --noEmit` 类型检查通过 + `vite build` 构建成功(1.44s
- 旧组件 AiHelperPanel.vue 保留但不再被引用(ChatView 已改用 RightPanel
## NAS 部署准备
- 创建部署目录 `deploy-nas/`,整理后端代码+前端dist+Docker/Nginx配置+deploy.sh一键脚本
- 生成部署包 `it-smart-desk-nas-deploy.zip`0.78MB),通过 File Station 上传到 NAS `/volume1/docker/wecom-it-desk/`
## 资源申请清单重命名+扩充
- `docs/反向代理开通申请清单.md``docs/资源申请清单.md`
- 扩充内容:新增服务器资源(预生产G端+生产NAS两套环境)、域名资源(内网域名+CF Tunnel域名)、生产环境Nginx路由表、NAS网络连通性要求、双环境验证地址
## H5 端认证逻辑修复(2026-06-09 晚)
- 根因:isAuthenticated 只检查 employee_id 不检查 h5_token,导致路由守卫错误放行
- 修复:employee.ts 的 isAuthenticated 改为只检查 token.value
- 修复:api/index.ts 的 401 拦截器在 mock 模式下跳转 /itdesk/login
- 修复包:frontend-h5-dist-fix-v2.zip127KB),待上传 NAS
- 部署后需清除浏览器 LocalStorage 或换无痕窗口测试
+68
View File
@@ -0,0 +1,68 @@
# 2026-06-10 工作日志
## 截图功能不可用 & 无法粘贴图片文件 — 修复(23:19)
### 问题1:截图功能不可用
- **根因**:两个 ScreenshotEditor.vue 根 div 都有 `v-if="visible"`,但父组件没传 `visible` prop
- 父组件用 `v-if="showScreenshotEditor"` 控制渲染,子组件的 `v-if="visible"` 冗余且导致内容永远隐藏
- **修复**:删除 `frontend-agent/src/components/chat/ScreenshotEditor.vue` 第10行 和 `frontend-h5/src/components/chat/ScreenshotEditor.vue` 第10行 的 `v-if="visible"`
### 问题2:会话框无法粘贴图片、文件
- **坐席端根因**`handlePaste` 只处理 `image/*` 类型,非图片文件无法粘贴
- **坐席端修复**
- `handlePaste` 改为检查 `item.kind === 'file'` 处理所有文件类型
- 新增 `handleFileUpload()` 函数:上传非图片文件并发送 `file` 类型消息
- **H5端根因**
- `handlePaste` 只处理 `image/*`
- `handleImageUpload` 上传后没有调用发送(只 console.log
- **H5端修复**
- `handlePaste` 支持所有文件类型
- `handleImageUpload` 上传后调用 `store.sendNewMessage()` 发送图片链接
- 新增 `handleFileUpload()` 处理非图片文件
### 修改文件清单
- `frontend-agent/src/components/chat/ScreenshotEditor.vue` — 删除 `v-if="visible"`
- `frontend-h5/src/components/chat/ScreenshotEditor.vue` — 删除 `v-if="visible"`
- `frontend-agent/src/components/chat/ReplyBox.vue` — 修复 `handlePaste`,新增 `handleFileUpload()`
- `frontend-h5/src/components/chat/InputBar.vue` — 修复 `handlePaste`,修复 `handleImageUpload`,新增 `handleFileUpload()`
### 构建状态
- 坐席端:`npx vite build` ✅ 成功
- H5端:`npx vite build` ✅ 成功
---
## 422错误 + 截图发送失败 + H5截图无法选中 — 修复(23:50)
### 问题1:文件粘贴请求失败422 + 截图发送失败
- **根因1**`uploadFile()` 中 Blob 被 append 了**两次**(第一次没文件名,第二次有文件名)
- 坐席端 `upload.ts`:先 `formData.append('file', file)` 无条件 append 一次,然后 `if (Blob)` 再 append 一次
- FormData 中有两个 `file` 字段,FastAPI 可能取到第一个(无文件名),导致解析失败
- **根因2**:手动设 `Content-Type: multipart/form-data` **覆盖了浏览器自动生成的 boundary**
- 发送 FormData 时浏览器会自动生成 `Content-Type: multipart/form-data; boundary=----xxx`
- 手动设 `headers: { 'Content-Type': 'multipart/form-data' }` 会丢弃 boundary
- 后端无法解析没有 boundary 的 multipart 请求体 → 422 Unprocessable Entity
- **修复**
- `frontend-agent/src/api/upload.ts`:去掉无条件 append,改为 if/else 分支;删除 `Content-Type`
- `frontend-h5/src/api/upload.ts`:删除 `Content-Type`
### 问题2:H5截图无法选中(暗色遮罩阻挡 + passive事件)
- **根因1**`.screenshot-dark-overlay` 在选区绘制层内部,拦截了所有触摸事件
- 修复:加 `pointer-events: none`,让触摸事件穿透遮罩到达选区层
- **根因2**`onTouchStart`/`onTouchMove` 调用 `e.preventDefault()` 但 Vue 在移动端默认用 passive 模式绑定触摸事件
- passive 模式下 `preventDefault()` 无效且报 warning
- 修复:模板中移除 `@touchstart`/`@touchmove`,改为 `onMounted` 中用 `addEventListener` 手动绑定非 passive 监听器
### 问题3:坐席端截图选区也可能被遮罩阻挡
- **根因**:坐席端 ScreenshotEditor 的 `.screenshot-dark-overlay` 也缺少 `pointer-events: none`
- **修复**:坐席端同样加 `pointer-events: none`
### 修改文件清单
- `frontend-agent/src/api/upload.ts` — 修复双重 append + 删除手动 Content-Type
- `frontend-h5/src/api/upload.ts` — 删除手动 Content-Type
- `frontend-agent/src/components/chat/ScreenshotEditor.vue` — 暗色遮罩加 `pointer-events: none`
- `frontend-h5/src/components/chat/ScreenshotEditor.vue` — 暗色遮罩加 `pointer-events: none`;触摸事件改为非 passive 手动绑定
### 构建状态
- 坐席端:`npx vite build` ✅ 成功
- H5端:`npx vite build` ✅ 成功
+20
View File
@@ -0,0 +1,20 @@
# 2026-06-11 工作日志
## 联软LV7000前端集成 + 后端修复
- **Integrations.vue**:添加 account_password 模式对话框(Base URL + API账号 + API密码 + 验证密钥),联软测试连接按钮,保存处理函数;更新默认数据 liansoft→lianruan (config_type: account_password);更新图标映射和通用测试函数
- **IntegrationCard.vue**:添加 account_password 模式显示逻辑(URL + 账号配置状态)
- **lianruan/config.py**:修复 `_get_config_map` 引用不存在的问题,改用直接查询 SystemConfig 表的 `_get_lianruan_config_value` 辅助函数;修正配置键前缀为 `integration_lianruan_`(与 admin_service 一致)
- **MEMORY.md**:从209行精简到~70行,去除重复和过时信息
## 验证结果
- 后端5个Python文件 py_compile ✅
- 前端 vite build ✅ (4.76s)
---
# 2026-06-12 工作日志
## 集成凭据配置脚本
- 创建 `scripts/setup_integrations.py`:安全填入火绒/联软凭据后一键写入数据库
- 创建 `.gitignore`:排除 .env、setup_integrations.py 等敏感文件
- 脚本 py_compile ✅
+272
View File
@@ -0,0 +1,272 @@
# 2026-06-12 工作记录
## H5端邀请功能WebSocket事件实现
### 后端改动
1. **ws_manager.py** — 扩展 ConnectionManager 支持H5员工连接:
- 新增 `employee_connections: Dict[str, WebSocket]` 员工连接映射表
- 新增 `connect_employee()` / `disconnect_employee()` 员工连接注册/注销
- 新增 `send_to_employee()` / `broadcast_to_employees()` 员工定向/批量推送
- 新增 `is_employee_online()` 在线状态检查
2. **session_service.py** — 邀请相关事件广播:
- `_broadcast_participant_change()` 广播给坐席 + 推送给相关H5员工
- 事件类型:participant_invited / joined / removed / left / new_message
3. **H5前端 composable** — 新增 `useH5WebSocket.ts`
- 与坐席端 `useWebSocket.ts` 对齐
- 端点:`/ws/h5/{employee_id}?token=xxx`
- 认证:Redis `employee:token:{token}` → employee_id 一致性校验
- 降级策略:WS断连→3秒轮询;WS重连→停止轮询
4. **后端 OAuth2 接口** — 支持 code 换身份流程:
- `GET /api/h5/oauth/authorize` — 获取授权URL
- `POST /api/h5/oauth/callback` — code 换 token + 员工信息
- Token 存入 Redis8小时TTL
---
## 企微环境限制部署 — 方案B验证通过(21:46-21:54
### 部署过程
- 5个部署包通过堡垒机上传到 `/tmp/`deploy-h5.tar / deploy-agent.tar / deploy-admin.tar / deploy-backend.tar / deploy.sh
- 执行 `bash /tmp/deploy.sh`,完整流程:备份 → 解压前端 → 更新后端 → 关闭Mock登录 → 重建镜像 → 重启容器 → 健康检查
- Mock登录已关闭:`MOCK_LOGIN_ENABLED=false`
### 验证结果
- ✅ 外部浏览器访问 `https://itsupport.servyou.com.cn/itdesk/` → 拦截页面「请在企业微信中打开」
- ✅ 企微桌面端工作台 → IT支持服务 → 自动进入H5页面,显示「IT智能服务台」+「坐席在线」
- ✅ 后端OAuth2接口UA校验(authorize/callback)已生效
- ✅ localhost开发环境自动豁免检测
### 涉及文件
- 新增:`frontend-h5/src/views/WeworkOnly.vue`(拦截页面)
- 修改:`frontend-h5/src/router/index.ts`(路由守卫UA检测)
- 修改:`backend/app/api/h5.py`OAuth2接口UA校验)
- 新增:`deploy-server/deploy.sh`(一键部署脚本)
---
## 安全风险评估与修复(21:00-22:00
### 安全审计结果
对项目进行全面安全审计,发现 17 项安全风险(3严重/5高/5中/4低)。
### 已完成的修复(严重+高风险)
1. **C-1**: `.env.example` 替换为占位符值
2. **C-2**: `config.py` 移除硬编码 Dify API Key(默认值改为空字符串)
3. **H-1**: `deploy-server/docker-compose.yml` Mock 登录默认值 `true``false`
4. **H-2**: 坐席企微验证降级放行修复(新注册必须验证,已注册才允许降级)
5. **H-3**: H5 端 `X-Employee-Id` 明文头仅在 `mock_login_enabled=true` 时允许
6. **H-4**: WebSocket 认证 Redis 降级放行修复(故障时拒绝连接)
7. **H-5**: 添加 slowapi 速率限制(登录10/minMock登录5/minOAuth回调20/min
### 遇到的问题
- Windows `python` 命令指向 Microsoft Store 占位符,实际 Python 路径:`C:\Users\simon\AppData\Local\Programs\Python\Python312\python.exe`
- slowapi 的 `Limiter()` 会尝试读取 `.env` 文件,Windows GBK 编码无法解码中文注释,需加 `env_file=None` 参数
### 待处理(中/低风险)
- Redis 设置密码、PostgreSQL 强密码、CORS 收紧、Nginx CSP/HSTS 安全头等
---
## 统一入口架构设计(22:00-22:40
### 设计决策
- **统一入口**:所有用户必须通过企微工作台 → IT智能服务台应用进入
- **路由选择页**:独立页面 `/itportal/`,卡片选择 UI
- **角色体系**user(默认)/ agent(企微标签映射)/ admin(手动绑定)
- **Token 统一**:合并为 `user:token:{token}`,包含角色信息
- **管理端访问控制**:仅限内网/VPN 访问,Nginx IP 白名单
- **坐席端改造**:支持企微桌面端 + 独立浏览器扫码登录
- **API 认证**:保留独立 API Key 通道,与用户认证分离
### 技术设计文档
已创建 `docs/统一入口技术设计文档.md`,包含:
- 系统架构图、角色路由逻辑
- 数据库设计(roles/user_roles/role_mapping_rules 表)
- API 设计(Portal API、角色管理 API、认证中间件)
- 前端设计(Portal Vue 应用、角色选择 UI、坐席端改造)
- 安全设计(认证安全、角色安全、API 安全)
- 实施计划(4阶段,约66工时)
### 用户确认的关键决策
- 企微标签配置:用户是企微超管,可直接创建标签组
- eHR 对接:先用企微标签映射,eHR 后续补充
- 管理端紧急通道:保留管理员密码登录,仅内网/VPN 访问,需二次验证(待设计)
- 坐席端使用场景:支持企微桌面端 + 独立浏览器扫码登录
---
## 统一入口 Phase 1 实施(23:00-00:00
### 已完成的工作
1. **数据库模型** — 创建角色系统三张表:
- `roles` — 角色定义表(user/agent/admin
- `user_roles` — 用户角色关联表(支持多角色)
- `role_mapping_rules` — 角色映射规则表(企微标签/eHR字段 → 角色)
- Alembic 迁移脚本:`007_role_system.py`(含预置数据)
2. **Pydantic Schema**`schemas/role.py`,包含:
- RoleResponse / UserRoleResponse
- RoleAssignRequest / RoleRevokeRequest
- RoleMappingRuleRequest / RoleMappingRuleResponse
- PortalUserInfo / SwitchRoleRequest / SwitchRoleResponse
3. **API 端点**
- `portal.py` — Portal 统一入口 API(获取角色、切换角色、获取入口URL)
- `admin_roles.py` — 管理后台角色管理 API(CRUD、分配/撤销、映射规则管理)
- `router.py` — 注册新路由
4. **服务层**
- `role_mapping_service.py` — 角色映射服务(企微标签 → 角色)
- `token_service.py` — 统一 Token 服务(创建、验证、切换角色、兼容旧格式)
5. **认证中间件**`dependencies.py`,包含:
- `get_current_user` — 统一认证依赖(支持新旧 Token 格式)
- `require_role` — 角色验证装饰器
- `require_admin` — 管理员权限验证装饰器
6. **坐席认证改造**`agents.py`
- `get_current_agent` 支持新旧两种 Token 格式
- 坐席登录使用统一 Token 服务创建 Token
### 文件清单
**新增文件**
- `backend/app/models/role.py`
- `backend/app/models/user_role.py`
- `backend/app/models/role_mapping_rule.py`
- `backend/app/schemas/role.py`
- `backend/app/services/role_mapping_service.py`
- `backend/app/services/token_service.py`
- `backend/app/api/portal.py`
- `backend/app/api/admin_roles.py`
- `backend/alembic/versions/007_role_system.py`
**修改文件**
- `backend/app/models/__init__.py` — 注册新模型
- `backend/app/api/router.py` — 注册新路由
- `backend/app/api/agents.py` — 认证改造
- `backend/app/dependencies.py` — 统一认证中间件
### 下一步
- 运行 Alembic 迁移创建表
- 测试新 API 端点
- 开始 Phase 2:路由选择页前端开发
---
## 安全风险修复(08:00-08:30
### 安全审计结果
对项目进行安全风险评估,发现 22 项安全风险(4严重/6高/7中/5低)。
### 已完成的修复(Phase 1
1. **CR-1**: 验证 `dependencies.py` 完整性 → 文件完整,无需修复
2. **CR-2**: 统一 Token 格式并确保向后兼容 → 修改 `token_service.py`
3. **CR-3**: Portal API 改用新认证中间件 → 修改 `portal.py``admin_roles.py`
4. **CR-4**: 修复坐席登录 Redis 连接管理 → 修改 `agents.py`
5. **H-8**: 添加映射规则输入验证 → 修改 `schemas/role.py`
### 创建的文档
- `docs/风险跟踪表.md` — 风险跟踪管理文档,包含 22 项风险的详细信息和处理计划
### 风险关联开发任务
已建立风险与开发任务的关联关系,后续开发涉及风险项目时,与风险项目一并处理并更新状态。
### 待处理风险
- **高风险**: H-7(角色分配权限验证)、H-9(Token绑定IP)、H-10(管理端IP白名单)、H-11WS Token头传递)
- **中风险**: M-6~M-12(Token迁移、缓存、速率限制、异常处理、日志脱敏、密码强度等)
- **低风险**: L-5~L-9CSP/HSTS、CORS、API认证、Nginx配置、前端配置)
---
## Phase 2Portal 前端应用(08:44-09:00
### 已完成的工作
1. **创建 frontend-portal Vue 应用**
- 基于 Element Plus(与坐席端/管理端一致)
- 基础路径:`/itportal/`
- 开发端口:5176
- 状态管理:Pinia
- 路由:vue-router 4
2. **目录结构**
```
frontend-portal/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
├── .env / .env.development / .env.production
└── src/
├── main.ts
├── App.vue
├── api/
│ ├── index.ts (axios 实例)
│ └── portal.ts (Portal API)
├── router/
│ └── index.ts
├── stores/
│ └── portal.ts (Pinia Store)
└── views/
├── PortalSelect.vue (角色选择页)
└── PortalLoading.vue (加载中页)
```
3. **核心功能**
- 角色选择页面(卡片选择 UI)
- 用户信息展示
- Token 管理(localStorage
- 角色切换(跳转到对应端)
- 响应式布局(支持移动端)
### 下一步
- 安装依赖并测试前端应用
- 集成到 Docker 构建
- 部署到服务器
---
## 重要提醒(10:15
### 测试环境限制
- **本地开发环境无法完成企微 OAuth2 认证**
- 所有登录相关验证必须在生产服务器 `10.90.5.110` 上进行
- 前端都通过企微认证,不支持独立登录页面
---
## 部署清单(10:51
### 本次更新成果(可部署)
- **后端**:角色系统(3张表+迁移脚本)、统一Token服务、角色管理API、安全修复
- **前端**Portal 统一入口应用(`frontend-portal/`
- **部署脚本**:已包含 Portal 部署逻辑
### 待部署验证
- Portal 角色选择页
- OAuth2 认证流程
- Token 传递和验证
- 角色切换功能
- 数据库迁移
---
## 安全风险修复(15:20
### 本次修复的 6 项风险
1. **H-7**: 角色分配权限验证(禁止给自己分配)→ `admin_roles.py`
2. **H-10**: 管理端 Nginx IP 白名单配置 → `nginx.conf`
3. **M-11**: PostgreSQL 更换强密码 → `.env.example`
4. **M-12**: Redis 设置密码 → `docker-compose.yml` + `.env.example`
5. **L-5**: Nginx 添加 CSP/HSTS 安全头 → `nginx.conf`
6. **L-6**: 收紧 CORS 配置 → `main.py`
### 风险处理进度
- 严重风险:4/4 已处理(100%)
- 高风险:4/6 已处理(67%
- 中风险:2/7 已处理(29%
- 低风险:2/5 已处理(40%
- **总处理率:55%**
+413
View File
@@ -0,0 +1,413 @@
# 2026-06-13 工作记录
## H5端邀请功能后续开发
### 后端改动
1. **h5.py** — 新增3个H5专用参与者端点(带员工认证):
- `POST /h5/conversations/{id}/join` — 被邀请人加入会话(`_get_current_employee` 认证)
- `POST /h5/conversations/{id}/leave-participant` — 参与者退出会话(`_get_current_employee` 认证)
- `GET /h5/conversations/{id}/participants` — 获取参与者列表(`_get_current_employee` 认证)
- 安全校验:employee_id 从 Token 自动获取,无需前端传递,防止冒充
### H5前端改动
2. **api/conversation.ts** — API路径统一为 `/h5/` 前缀:
- `joinConversation(conversationId)` — 移除 employeeId 参数,路径改为 `/h5/conversations/{id}/join`
- `leaveAsParticipant(conversationId)` — 移除 employeeId 参数,路径改为 `/h5/conversations/{id}/leave-participant`
- `getParticipants(conversationId)` — 路径改为 `/h5/conversations/{id}/participants`(独立端点)
- `ConversationInfo` 类型新增 `employee_name` 字段
3. **stores/conversation.ts**`leaveAsParticipant()` 不再传递 employeeId
4. **views/ChatView.vue**`joinConversationApi(inviteId)` 不再传递 eid
5. **components/chat/ParticipantList.vue** — 修复发起人姓名显示:
- 当发起人不是当前用户时,显示 `conv.employee_name`(真实姓名)而非固定的"员工"
### 编译验证
- 后端 py_compile ✅(h5.py
- H5前端 vue-tsc --noEmit ✅
- H5前端 vite build ✅
## 管理后台 — 角色管理界面开发
### 说明
后端 RBAC 角色系统(模型/API/服务/Schema/迁移)已全部完成,但前端管理后台零实现。
本次补齐前端角色管理 UI 层。
### 改动文件
1. **frontend-admin/src/types/index.ts** — 新增角色管理类型定义:
- `Role`(角色信息,含 permissions JSON 数组、user_count
- `UserRole`(用户角色关联,含 source/assigned_by/expires_at
- `UserRoleSource`(来源类型:auto/tag/ehr/manual
- `RoleMappingRule`(映射规则,含 source_type/source_value/priority
- `MappingSourceSource`(映射来源:wecom_tag/ehr_position
- `RoleAssignRequest` / `RoleRevokeRequest` / `RoleMappingRuleRequest`
- `ROLE_SOURCE_LABELS` / `MAPPING_SOURCE_LABELS` 常量
2. **frontend-admin/src/api/admin.ts** — 新增 6 个 API 调用函数:
- `getRoles()` — 获取所有角色列表
- `assignRole()` — 手动分配角色
- `revokeRole()` — 撤销角色
- `getRoleMappingRules()` — 获取映射规则
- `createRoleMappingRule()` — 创建映射规则
- `deleteRoleMappingRule()` — 删除映射规则
3. **frontend-admin/src/views/Roles.vue** — 新建角色管理页面:
- 角色卡片网格(3 个预置角色:用户/坐席/管理员,含用户数+权限数+权限标签)
- 用户角色分配表格(employee_id/角色/来源/分配者/时间/操作)
- 自动映射规则表格(目标角色/来源类型/匹配值/优先级/状态/操作)
- 4 个对话框:分配角色、撤销确认、新建映射规则
- Demo fallback 数据(API 不可用时的降级展示)
4. **frontend-admin/src/router/index.ts** — 新增路由:
- `/roles``Roles.vue`meta.title = "角色管理"
5. **frontend-admin/src/components/Sidebar.vue** — 新增菜单项:
- "运营管理" 分组下添加"角色管理"(Key 图标),位于"坐席管理"之后
### 编译验证
- 前端 vite build ✅(Roles-4zcp3cuz.js 13.33 kBgzip: 4.48 kB
-@vueuse/core Rollup 注解警告和 chunk 大小警告(非本次引入)
## 正式服务器部署
### 迁移修复
- **007_role_system.py** — 修复 PostgreSQL 兼容性:`datetime('now')``NOW()`
- SQLite 的 `datetime('now')` 在 PostgreSQL 中不存在,导致后端启动失败
### 部署记录
- 部署包已生成:`deploy-server/it-smart-desk-server-deploy.zip` (1.48 MB)
- 包含:3个前端 dist + 后端代码 + docker-compose.yml + .env + nginx.conf
- ⚠️ **服务器文件上传限制**10.90.5.110 无法使用 scp,只能通过堡垒机手动上传
- 部署流程:下载部署包 → 通过堡垒机上传到 /tmp/ → 解压 → docker compose build --no-cache backend → up -d
---
## 未完成任务收尾(下午)
### 任务进度确认
- 代码审查确认 #148/#155(H5端邀请功能)**已完整实现**,包括:
- ParticipantList.vue 完整组件(展示+退出+确认弹窗)
- conversation.ts storeinviteParticipant/leaveAsParticipant/joinConversation
- API层(joinConversation/leaveAsParticipant/getParticipants
- ChatView.vue 邀请链接加入流程
- WebSocket 实时推送(participant_invited/joined/removed/left 事件)
- 标记 #148#155 为 completed
### #151 H5登录Bug修复(4项)
1. **isAuthenticated 增加 JWT 过期检查**:新增 `isTokenExpired()` 函数,解析 JWT payload 的 exp 字段,60秒安全余量
2. **消除循环依赖**:新建 `utils/authCallback.ts` 独立回调注册中心,打破 api/index.ts ↔ stores/employee.ts 循环依赖
3. **并发401去重**`_authExpiredPromise` 去重锁,首个401获取锁执行处理,后续复用同一Promise
4. **Portal Token URL安全加固**:使用 URLSearchParams 精确删除 token/code/state 参数,history.replaceState 立即清除
### #156 术语替换 + UI风格更新
**术语替换**
- "举手"→"招手"agent 6文件+h5 4文件,约25处)
- "铃铛"→"传菜铃"H5端2文件6处)
- "申请"→无需替换(均为业务数据内容)
**CSS变量体系更新为企微风格**
- `--accent`: #3b82f6#07C160(企微绿)
- `--bg-primary`: #f5f7fa#f7f7f7
- `--bg-tertiary`: #f0f2f5#ededed
- `--text-primary`: #1e293b#191919
- `--text-secondary`: #64748b#666666
- `--text-tertiary`: #94a3b8#999999
- `--border`: #e2e8f0#e5e5e5
- `--radius`: 6px → 8px, `--radius-lg`: 10px → 12px
- H5 `--color-shake-start/end`: 橙色渐变 → 绿色渐变
- 深色主题变量保持不变
### #149 端到端验证
- 阻塞已解除(#148/#151已完成
- 待用户在实际环境中执行全链路验证
### 部署方案讨论
- 确认 NAS 测试环境在企微 OAuth2 认证下价值大幅降低
- 确定双企微应用方案(正式应用+测试应用),因公司子域名申请困难
- 正式上线前:正式=10.90.5.10, 测试=NAS
- 正式上线后:正式=高可用架构, 测试=10.90.5.10
---
## 部署包打包 + 调试验证指南(11:00)
### 部署包清单
| 文件 | 大小 | 内容 |
|------|------|------|
| deploy-h5.tar | 0.6 MB | frontend-h5/dist/(含JWT过期检查+企微绿风格) |
| deploy-agent.tar | 2.0 MB | frontend-agent/dist/(含术语替换+企微绿风格) |
| deploy-admin.tar | 1.7 MB | frontend-admin/dist/ |
| deploy-portal.tar | 1.53 MB | frontend-portal/dist/ |
| deploy-backend.tar | 11.02 MB | backend/ |
### 调试验证指南
- 创建 `docs/调试验证指南_2026-06-13.md`
- 包含端到端验证清单(11个验证项)
- 包含测试企微应用创建步骤(6个步骤)
- 包含环境切换方案和常见问题排查
---
## 管理后台 P2 功能开发(晚间)
### 任务1:仪表盘真实数据
- **admin_service.py** — `get_dashboard_overview()` 新增两项真实计算:
- `avg_response_time`:从 messages 表计算首条员工消息到首条坐席/AI回复的时间差,最多统计50个会话
- `ai_hit_rate`:今日有 AI 实质性回复的会话占比(ai_substantive_reply_count > 0
- 异常处理:计算失败时降级为 "—" 显示
### 任务2:P2 页面(会话审计/坐席绩效/系统日志)
**后端新增 3 组 API**
- `GET /admin/audit/conversations` — 会话审计列表(分页+状态/坐席/关键词/日期范围筛选)
- `GET /admin/audit/conversations/{id}` — 会话审计详情(含消息列表,最多200条)
- `GET /admin/agent-performance` — 坐席绩效统计(总会话数/已结单/结单率/今日会话)
- `GET /admin/system-logs` — 系统日志(配置变更历史,含操作人姓名)
**前端新增 3 个页面:**
- `SessionAudit.vue` — 会话审计页(表格+筛选+详情抽屉,消息按类型着色)
- `AgentPerformance.vue` — 坐席绩效页(表格+汇总统计,支持日期范围筛选)
- `SystemLogs.vue` — 系统日志页(表格+分页,变更前后值着色对比)
**路由+侧边栏更新:**
- 路由新增 `/session-audit``/agent-performance``/system-logs`
- 侧边栏"监控与数据"分组新增 3 个菜单项
### 任务3:功能开关增强
- `CONFIG_GROUP_MAP` 新增 5 个分组前缀:
- `queue_` → 排队策略
- `satisfaction_` → 满意度评价
- `invite_` → 邀请功能
- `notification_` → 通知推送
- `security_` → 安全策略
### 编译验证
- 后端 py_compile ✅
- 前端 vite build ✅(SessionAudit-D3UWZck-.js 6.40 kB
---
## 统一部署包打包(10:58
### 构建结果
- 4 个前端全部重建成功(H5/Agent/Admin/Portal),耗时 18 秒
- Admin 前端包含新增的 Roles.vue 角色管理页面
### 部署包清单
| 文件 | 大小 | 内容 |
|------|------|------|
| deploy-h5.tar | 0.6 MB | frontend-h5/dist/ |
| deploy-agent.tar | 2.0 MB | frontend-agent/dist/ |
| deploy-admin.tar | 1.7 MB | frontend-admin/dist/(含角色管理页) |
| deploy-portal.tar | 1.5 MB | frontend-portal/dist/ |
| deploy-backend.tar | 11.0 MB | backend/(含角色系统全部代码+迁移) |
### 服务器部署步骤
```bash
# 1. 清理失败的数据库状态
docker compose exec postgres psql -U postgres -d it_smart_desk -c "
DROP TABLE IF EXISTS role_mapping_rules CASCADE;
DROP TABLE IF EXISTS user_roles CASCADE;
DROP TABLE IF EXISTS roles CASCADE;
"
# 2. 通过堡垒机上传 5 个 tar 到 /tmp/
# 3. 在服务器执行
cd /opt/wecom-it-desk
cp /tmp/deploy-*.tar ./
docker compose down
# 解压前端
tar -xf deploy-h5.tar -C frontend-h5/
tar -xf deploy-agent.tar -C frontend-agent/
tar -xf deploy-admin.tar -C frontend-admin/
tar -xf deploy-portal.tar -C frontend-portal/
# 解压后端(保留 .env
cp backend/.env /tmp/backend-env-backup
tar -xf deploy-backend.tar
cp /tmp/backend-env-backup backend/.env
# 重建并启动
docker compose build --no-cache backend
docker compose up -d
```
---
## Dify/RAGFlow/千问集成调研(12:39
### 现有服务连通性确认(从 10.90.5.110 测试)
| 服务 | 地址 | 端口 | 状态 |
|------|------|------|------|
| RAGFlow 前端 | 10.80.0.85 | 8080 | ✅ 200 OK |
| RAGFlow API | 10.80.0.85 | 9380 | ✅ 200 OKWerkzeug |
| 千问模型 | 10.80.0.49 | 5000 | ✅ 已连接 |
| Dify | yw-dify.dc.servyou-it.com (10.80.0.240) | 80 | ✅ 307 正常 |
**结论:所有服务均已连通,无需开通新路由。**
### 集成现状
| 组件 | 后端代码 | 需要做什么 |
|------|----------|-----------|
| Dify | ✅ AIService + WingmanService | 无需改动 |
| RAGFlow | ❌ 无客户端代码 | 需开发 RagflowClient |
| 千问 | ℹ️ 通过Dify间接调用 | 无需直连 |
### 交接文档关键信息
- 消息链路:企微 → B端智能体 → dify2openai → Dify Workflow → 千问
- RAGFlow 知识运营:宋献IT组主导
- 模型:Qwen3-30B-A3B-Instruct + bge-m3(向量)
- 对接联系人:dify2openai→JG/CFDify应急→CF/WT
---
## RAGFlow 客户端开发(12:49
### 新增文件
- `backend/app/integrations/ragflow/__init__.py` — 模块导出
- `backend/app/integrations/ragflow/client.py` — RagflowClient 客户端
- `test_connection()` — 测试连接
- `retrieval()` — 知识检索(核心接口,POST /api/v1/retrieval
- `list_datasets()` — 列出知识库
- `create_dataset()` — 创建知识库
- `delete_dataset()` — 删除知识库
- `list_documents()` — 列出文档
- `upload_document()` — 上传文档
- `delete_documents()` — 删除文档
- `backend/app/integrations/ragflow/models.py` — 数据模型
- RetrievalChunk / DocAggregate / RetrievalResult / DatasetInfo / DocumentInfo
- `backend/app/integrations/ragflow/exceptions.py` — 异常定义
- RagflowError / RagflowConfigError / RagflowAuthError / RagflowApiError / RagflowConnectionError
- `backend/app/integrations/ragflow/config.py` — 配置加载器
- 从 system_configs 表读取 integration_ragflow_api_url + integration_ragflow_api_key
- 默认 API 地址:http://10.80.0.85:9380
### admin.py 新增端点
- `POST /admin/integrations/ragflow/test` — 测试连接
- `GET /admin/integrations/ragflow/datasets` — 列出知识库
- `POST /admin/integrations/ragflow/retrieval` — 知识检索测试
### 编译验证
- 后端 py_compile ✅(所有 ragflow 模块 + admin.py
### 前端更新
- `frontend-admin/src/api/admin.ts` — 新增 3 个 RAGFlow API 函数:
- `testRagflowConnection()` — 测试连接
- `getRagflowDatasets()` — 列出知识库
- `ragflowRetrieval()` — 知识检索测试
- `frontend-admin/src/views/Integrations.vue` — 更新 handleTest 函数:
- 支持 RAGFlow 测试连接(调用 testRagflowConnection
- 测试成功后更新本地状态为 connected
- 前端 vite build ✅(Integrations-CFvIx0q8.js 14.51 kB
---
## 修复:消息发送失败 + 截图不可用(15:00)
### 根因
后端 `POST /h5/conversations/current/messages` 抛出异常:
```
TypeError: AIHandler.__init__() missing 1 required positional argument: 'ai_service'
```
**深层原因**uvicorn `--reload` 模式下 WatchFiles reloader 缓存了旧的 `dependencies.py` 字节码(之前 `dep_ai_handler()` 没有 `ai_service=AIService()` 参数的版本)。即使清空 `__pycache__` 重启,reloader 仍加载旧缓存。
**修复**:去掉 `--reload` 标志启动后端即可。`start_backend.py` 已改为 `reload=False`
### 影响
- 消息发送:后端 500 错误 → 前端超时/失败
- 截图功能:截图本身正常(html2canvas + ScreenshotEditor),但上传后发送消息同样失败
- Mock 登录:正常(不经过 AIHandler)
### 验证
- Mock login → `code: 0`
- Send message → `code: 0`, 返回 user_message + ai_reply ✅
- 后端 108 个路由正常注册 ✅
### 教训
- uvicorn `--reload` 的 WatchFiles reloader 可能缓存旧字节码,清 `__pycache__` 不一定有效
- 本地开发如果不需要热重载,用 `reload=False` 更可靠
---
## AIHandler 初始化问题修复 + 打包部署脚本(23:07)
### 问题描述
后端 `POST /h5/conversations/current/messages` 报错:
```
TypeError: AIHandler.__init__() missing 1 required positional argument: 'ai_service'
```
### 根因
`dependencies.py``AIHandler()` 调用缺少必需的 `ai_service` 参数。代码重构后 `AIHandler.__init__` 需要传入 `AIService` 实例。
### 修复内容
- `backend/app/dependencies.py` 两处修复:
- `get_shared_ai_handler()`: `return AIHandler(ai_service=AIService())`
- `dep_ai_handler()`: `return AIHandler(ai_service=AIService())`
### 数据库修复(已在服务器执行)
```sql
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS impact_scope VARCHAR(50);
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS is_blocking BOOLEAN DEFAULT false;
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS emotion_state VARCHAR(50);
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS dify_conversation_id VARCHAR(255);
```
### 打包部署脚本
新增两个自动化脚本:
1. `deploy-server/build-and-deploy.ps1` — PowerShell 脚本,功能:
- 打包前端构建产物(frontend-h5/agent/admin/portal dist
- 复制 nginx 配置 + docker-compose.yml + .env
- 构建后端 Docker 镜像(包含修复后的代码)
- 导出为 `deploy-backend.tar`
- 支持 `-Mode deploy` 参数自动上传并部署到服务器
2. `deploy-server/打包部署.bat` — 一键执行批处理
- 不带参数:仅本地打包
-`deploy` 参数:打包 + 部署到服务器
### 下一步
需要重新构建后端镜像并部署到服务器:
1. 执行 `打包部署.bat deploy` 或手动运行 `build-and-deploy.ps1 -Mode deploy`
2. 脚本会自动:构建镜像 → 导出tar → 上传服务器 → 部署
---
## 剩余安全风险修复(23:03
### 任务说明
处理 4 个可在代码层面快速修复的风险项。
### 修复内容
#### 1. H-6:角色映射标签验证(高风险)
- `role_mapping_service.py` 新增 `_validate_tag_name()` 方法
- 验证规则:长度限制 50 字符,过滤禁止的特殊字符 (`<>'"&;\\|%$#@``)
- 获取企微标签时过滤不安全的标签名称
#### 2. M-9:异常信息泄露(中等风险)
- `main.py` 两处异常处理器修改
- 响应改为通用消息:"服务器内部错误,请稍后重试或联系管理员"
- 详细异常信息仅记录到日志
#### 3. M-10:日志脱敏(中等风险)
- 新增 `_mask_sensitive_data()` 脱敏函数(保留前3位)
- 已处理:`role_mapping_service.py`3处)、`admin_roles.py`4处)
#### 4. L-7:坐席列表 API 认证(低风险)
- `agents.py` 导入 `require_role` 依赖
- `/agents` 端点添加 `@require_role("agent", "admin")` 装饰器
### 风险处理进度
| 级别 | 处理率 |
|------|--------|
| 严重 | 100% (4/4) |
| 高风险 | 83% (5/6) |
| 中风险 | 57% (4/7) |
| 低风险 | 60% (3/5) |
| **总计** | **73% (16/22)** |
+64
View File
@@ -0,0 +1,64 @@
# workbuddy 评审反馈 — 2026-06-14 消息相关推送
**推送内容**: 消息撤回/删除/状态/已读/图片上传/文件上传(版本说明 v1.1.0)
**评审日期**: 2026-06-14
**评审人**: Claude
**主报告**: `D:\资料\03-项目开发\wecom_it_smart_desk\docs\评审报告\workbuddy-2026-06-14-消息优化.md`
---
## ⭐ 给 workbuddy 的关键反馈
1. **本次推送 6/13 = 46% 是 P0 鉴权漏洞** —— 必须加 "端点必须 Depends 鉴权" 自检
2. **版本说明文档有 4 处错误**,含 `-p root` 正是用户生产事故的根因
3. **5 个端点完全没有鉴权依赖** —— 新增端点请用以下模式之一:
- 坐席端: `agent: Agent = Depends(get_current_agent)` (来自 `app.api.agents`)
- H5 员工端: `employee_id: str = Depends(_get_current_employee)` (来自 `app.api.h5`)
- 上传通用: 需新建 `get_current_user_id` 兼容两端
## 🔴 P0 已修(本地代码,本评审完成)
| # | 端点 | 修复要点 |
|---|---|---|
| P0-1 | GET /h5/conversations/{id}/participants | is_creator/is_participant 校验 |
| P0-2 | POST /messages/{id}/recall | agent 鉴权 + sender_id 校验 |
| P0-3 | DELETE /messages/{id} | 同上 |
| P0-4 | POST /conversations/{id}/mark-read | agent 鉴权 + assigned/collaborator + SQL `is_(False)` |
| P0-5 | POST /messages/image | agent 鉴权 |
| P0-6 | POST /messages/file | 同上 |
## 🟡 P1 请 workbuddy 跟进
| # | 项 | 行动 |
|---|---|---|
| P1-1 | upload 路径在容器本地 | 改 volume mount(参考 nginx 静态文件挂载模式) |
| P1-2 | SQL 迁移未走 Alembic | **生成对应迁移脚本**:`alembic revision --autogenerate -m "add message status and recallable_until"` |
| P1-3 | docker-compose backend healthcheck 用 curl | 改用 Python 一行:`python -c "import socket; s=socket.socket(); s.connect(('localhost',8000))"` |
| P1-4 | ws_manager 没实现"消息状态广播" | 实现方法(如 `broadcast_message_status(conv_id, msg_id, status)`) |
## 🟢 P2 请 workbuddy 跟进
| # | 项 | 行动 |
|---|---|---|
| P2-2 | upload 写文件非原子 | 先写 `*.tmp` 再 rename |
| P2-3 | upload 返回原始文件名 | URL encode 或 XSS 过滤 |
## 📄 文档修订清单(`docs/IT智能服务台-版本更新说明-20250614.md`)
1. **部署步骤 5** 删除 `-p root` 标志 —— 这是用户 6-14 生产事故的根因
2. **部署步骤 6** SQL 引号未转义 —— 改用 Alembic 迁移,不要手动 ALTER
3. **2.1 ws_manager** 文档与代码不符(实际未实现状态广播) → 改 "规划中" 或 "本次未实现"
4. **2.1 docker-compose** "healthcheck 已配置" 不准确 → 加注 backend curl 坑
## 🔁 流程建议
- 推送前自检清单:
- [ ] 新增/修改端点是否有 `Depends(...)` 鉴权?
- [ ] 数据库 schema 变化是否有 Alembic 迁移?
- [ ] Docker 配置变化是否本地起得了容器?
- [ ] 版本说明与代码 diff 是否完全一致?
- 强烈建议:workbuddy 推送前跑 `pre-commit-review.py`(可由 Claude 生成),**P0 数量超 0 拒绝推送**
---
**下次推送窗口**: 建议等 P1-1~4 + P2-2/3 全部修完再合入,**不要在评审发现的问题未修前再叠加新功能**。
+22
View File
@@ -0,0 +1,22 @@
# 2026-06-14 工作记录
## OTP双因素认证开发完成
### 后端(已有)
- `POST /agents/otp-bind` - 绑定OTP
- `POST /agents/otp-verify` - 验证启用
- `POST /agents/otp-unbind` - 解绑OTP
- `POST /agents/otp-verify` - 登录时二次验证(admin角色)
- `POST /admin/agents/{id}/otp-unbind` - 管理员强制解绑
### 坐席端前端
- `frontend-agent/src/api/agent.ts` - 新增 bindOtp/verifyOtp/unbindOtp API
- `frontend-agent/src/components/layout/TopBar.vue` - 下拉菜单添加"OTP二次验证"选项 + 对话框(绑定/验证/解绑)
### 管理后台前端
- `frontend-admin/src/components/AgentTable.vue` - 新增OTP列(已启用/未验证/未绑定)
- `frontend-admin/src/views/Agents.vue` - 编辑对话框添加OTP状态显示+强制解绑按钮
- `frontend-admin/src/api/admin.ts` - 新增 unbindOtp API
### 数据库修复
- messages/conversations/agents等表的id字段从UUID改为VARCHAR(36)
+165
View File
@@ -0,0 +1,165 @@
# 2026-06-23 工作日志
## 修复截图发送超时Bug
### 问题分析
截图发送流程:html2canvas截取 → 裁剪选区 → 上传图片(60s超时) → 发送消息(10s超时)
- 前端 apiClient 默认超时10秒,对图片/文件消息发送过短
- 坐席端发消息时,即使是image类型也创建Redis连接(不必要)
- H5端消息发送会触发AI/Dify处理,可能超过10秒
### 修改内容
**前端(4个文件):**
1. `frontend-agent/src/api/message.ts` — sendMessage 超时 10s→30s
2. `frontend-h5/src/api/conversation.ts` — sendMessage 超时 10s→30s
3. `frontend-agent/src/api/index.ts` — apiClient 默认超时 10s→20s
4. `frontend-h5/src/api/index.ts` — apiClient 默认超时 10s→20s
**后端(1个文件):**
5. `backend/app/api/messages.py` — 非text消息跳过Redis连接(image/file等不调用企微API推送)
### 编译验证
- frontend-agent: vite build ✅ (4.63s)
- frontend-h5: vite build ✅ (1.75s)
- backend: py_compile ✅
---
## 修复员工端消息不显示Bug + 后端WS广播
### 问题分析
用户报告:员工端消息发送后没有出现在会话列表里。
**根因发现**
1. **字段名不匹配**:后端 MessageResponse 返回 `id`/`sender_type`,但 H5 前端 Message 接口期望 `message_id`/`message_type`
2. **Vue 渲染失败**`MessageBubble` 使用 `:key="msg.message_id"`,但后端返回的是 `id`,导致所有 key 为 undefined
3. **消息类型丢失**`message_type` 为 undefinedCSS class 错误(如 `message-bubble--undefined`
4. **WS handleNewMessage 错误**:使用了 `data.msg_type`content type: text/image/file)而非 `data.sender_type`sender type: employee/agent/ai
### 修改内容
**H5前端(2个文件):**
1. `frontend-h5/src/api/conversation.ts` — 新增 `mapMessage()`/`mapMessages()` 映射函数:
- `id``message_id`
- `sender_type``message_type`
- `sendMessage()``pollMessages()` 返回数据经过映射
2. `frontend-h5/src/stores/conversation.ts` — 修复 `handleNewMessage()`
- `message_type``data.msg_type`text/image)改为 `data.sender_type`employee/agent/ai
- 同时正确映射 `msg_type`content type
**后端(1个文件):**
3. `backend/app/api/h5.py` — 新增 WebSocket 广播:
- 导入 `ws_manager`
- 员工发消息后向坐席端推送 `new_message` 事件(用户消息 + AI回复)
- 同时推送 `conversation_updated` 事件(状态变更)
- 异常捕获:WS广播失败不阻塞消息存储
### 核心原理
后端 `MessageResponse` schema`app/schemas/message.py`)定义的字段名是 `id`/`sender_type`,这是与坐席端(Agent)对齐的格式。H5 前端有自己独立的 `Message` 接口(`message_id`/`message_type`),需要在 API 层做字段映射。
### 编译验证
- frontend-h5: vite build ✅ (1.70s)
- backend: py_compile ✅
### 服务重启
- 使用 `uvicorn app.main:app --reload` 重启后端
- 工作目录:`D:\资料\03-项目开发\wecom_it_smart_desk\backend`
### 启动问题修复
重启过程中遇到多个问题并逐一修复:
1. **slowapi 模块缺失** → 安装 `slowapi==0.1.9`
2. **slowapi 0.1.9 不支持 `env_file` 参数** → 移除 `env_file=None`3个文件)
- `backend/app/api/agents.py`
- `backend/app/api/h5.py`
- `backend/app/main.py`
3. **缺少依赖注入函数** → 在 `dependencies.py` 中新增:
- `get_shared_redis()` / `get_shared_wecom_service()` / `get_shared_ai_handler()`
- `dep_redis()` / `dep_wecom_service()` / `dep_ai_handler()` / `dep_wingman_service()`
- `init_shared_services()` / `cleanup_shared_services()`
4. **RateLimitExceeded 异常处理器中 `Request` 未定义** → 移除类型注解
### 服务状态
- ✅ FastAPI 已启动,运行在 `http://0.0.0.0:8000`
- ✅ 98 个路由已注册
- ✅ SQLite 数据库初始化完成
- ✅ 默认数据初始化完成
---
## Phase 2 路由选择页(Portal)构建与集成
### 背景
`frontend-portal/``backend/app/api/portal.py` 的代码已经写好,需要构建和集成。
### 已完成工作
1. **Portal 前端构建**`npm install` + `vite build` ✅ (4.65s)
2. **PortalSelect.vue 增强**:添加 OAuth2 `?code=` 参数处理(调用 `/h5/oauth/callback` 获取 token
3. **坐席端适配**(已完成):路由守卫读取 `?token=` 参数,保存到 `agent_token` + `portal_token`
4. **H5端适配**(已完成):路由守卫读取 `?token=` 参数,保存到 `h5_token`
5. **全量编译验证**
- frontend-portal: vite build ✅ (4.65s)
- frontend-h5: vite build ✅ (2.00s)
- frontend-agent: vite build ✅ (5.56s)
- backend portal.py: py_compile ✅
- backend h5.py: py_compile ✅
### 完整认证流程
1. 用户通过企微工作台点击 IT智能服务台 → 跳转到 `/itportal/`
2. Portal 检测到 `?code=xxx`(OAuth2 回调)→ 调用后端获取 token → 保存到 localStorage
3. Portal 调用 `/api/portal/roles` 获取用户角色列表
4. 如果仅 user 角色 → 自动跳转 `/itdesk/`;多角色 → 显示卡片选择页
5. 用户点击"进入" → Portal 将 token 通过 `?token=xxx` 传递到目标前端
6. 目标前端路由守卫读取 token → 保存到各自的 localStorage key → 正常工作
### Portal 服务配置
- Base path: `/itportal/`
- 开发端口: 5176
- 构建产物: `frontend-portal/dist/`
- 端口映射: 5173(坐席), 5174(H5), 5175(管理), 5176(Portal)
### Phase 2 部署配置完成
**Nginx 配置更新:**
- `nginx/nginx.conf` — 添加 `/itportal/` 路由(本地开发版)
- `deploy-server/nginx.conf` — 添加 `/itportal/` 路由 + 默认路径重定向到 `/itportal/`
**部署脚本更新:**
- `deploy-server/deploy.sh` — 添加 portal 前端部署步骤 + 数据库迁移步骤
**角色管理脚本:**
- `backend/scripts/init_roles.py` — 初始化三个默认角色(user/agent/admin
- `backend/scripts/assign_role.py` — 用户角色分配/移除/查看工具
**本地开发脚本:**
- `scripts/dev-portal.sh` — Linux/Mac 快速启动脚本
- `scripts/dev-portal.ps1` — Windows PowerShell 快速启动脚本
**数据库状态:**
- roles 表已初始化(3条:user/agent/admin
- user_roles 表已创建
- 角色分配脚本已测试通过
---
## 部署包打包完成
### 构建结果
- H5 前端: vite build ✅ (1.85s)
- Agent 前端: vite build ✅ (5.12s)
- Admin 前端: vite build ✅ (5.81s)
- Portal 前端: vite build ✅ (4.32s)
### 部署包
- 路径: `deploy-packages/it-smart-desk-deploy-20260613_102148.tar`
- 内容: 4个前端 dist + deploy.sh + nginx.conf + backend-scripts/
- 打包脚本: `deploy-packages/build-and-package.ps1`
### 部署步骤
1. 通过堡垒机上传 tar 包到服务器 `/tmp/`
2. 在服务器执行: `cd /tmp && tar -xf it-smart-desk-deploy-*.tar`
3. 执行部署脚本: `./deploy.sh`
4. 数据库迁移: `cd /opt/wecom-it-desk/backend && alembic upgrade head && python scripts/init_roles.py`
5. 角色分配: `python scripts/assign_role.py <employee_id> agent`
+30
View File
@@ -0,0 +1,30 @@
# 2026-07-15 工作日志
## 管理后台代码实现完成(阶段1B)
### 后端(backend-engineer 完成)
- 新增文件4个:
- `backend/app/models/config_change_log.py` — 配置变更日志模型
- `backend/app/schemas/admin.py` — 15个 Pydantic Schema
- `backend/app/services/admin_service.py` — 8个核心业务函数
- `backend/app/api/admin.py` — 16个路由端点 + require_admin 权限依赖
- `backend/alembic/versions/006_admin_extension.py` — 数据库迁移脚本
- 修改文件7个:Agent模型新增role/skill_tags字段,QuickReplyTemplate新增status/version/submitted_by字段,路由注册等
- 权限校验:require_admin 依赖检查 agent.role == "admin"
- 配置管理:按前缀自动分组,支持变更日志审计
### 前端(frontend-engineer 完成)
- `frontend-admin/` 项目搭建完成,已构建(dist/目录存在)
- 技术栈:Vue 3 + TypeScript + Element Plus + Tailwind CSS + Pinia
- 页面清单:Dashboard/Configs/Agents/Integrations/QuickReplies/AssignmentMode/Monitor/Flowcharts + 3个占位页
- 登录:复用坐席端 APIPOST /agents/login),额外校验 role === 'admin'
- API 拦截器:admin_token 独立存储,业务码1002自动跳转登录
- base 路径:/itadmin/
### 代码审查结论
- 后端和前端代码质量高,注释详细,架构清晰
- 无阻塞性问题
### 待办
- Task #4 管理后台测试验证(pending
- H5端登录Bug仍OPEN
+209
View File
@@ -0,0 +1,209 @@
# IT智能服务台 - 项目记忆
## 锁定的设计决策
- **AI交互原则(2026-06-14)**:小段多回合交互,逐步确认
- 第1步:确认问题("您是问XXX吗?"
- 第2步:确认谁来解决("这个问题由XXX处理可以吗?")
- 第3步:确认解决方案("我们通过XXX方式可以吗?")
- 第4步:处理过程逐步确认(进度透明,可逆)
- ❌ 禁止一次性大段回复
- **文档管理**:新建文档统一保存在 `docs/` 目录下,按类型分子目录
- **资源申请流程(2026-06-11)**:所有资源申请→`docs/资源申请清单.md`,不单独发企微/邮件/工单
- **原型已锁定**:坐席工作台 v5.3 + H5用户端 v1.1,调整前须与用户确认
- **代码更新规则**:影响显示效果的前端组件更新前须通过原型图确认
- **UI偏好(2026-06-13更新)**:坐席端+H5用户端统一企微浅色扁平风格;accent=#07C160(企微绿);深色主题保留原有配色不变
- **术语统一(2026-06-13更新)**"人工"=用户呼叫坐席(传菜铃图标);"摇人"=坐席呼叫坐席(招手👋);❌"举手"已改为"招手";❌"铃铛"已改为"传菜铃"
- **双企微应用方案(2026-06-13确定)**:正式应用"IT智能服务台"(全公司)+测试应用"IT智能服务台-测试"(IT部门);正式上线前:正式=itsupport.servyou.com.cn(10.90.5.10), 测试=itdesk.amanzac.com(NAS);正式上线后:正式→高可用架构, 测试→10.90.5.10;原因:公司子域名申请困难
- **H5主设备**:电脑(企微桌面端~70%),手机~30%
- **H5排查步骤**:固定消息框顶部,始终可见可收起,桌面+手机统一
- **输入框**:默认3行,自动扩展
- **桌面端栏宽**:可拖拽手柄调整,右侧flex:1
- **系统名称**:IT智能服务台 — AI驱动 · 多系统对接 · 一站式处理
- **H5企微环境限制(2026-06-12)**:前端路由守卫检测UA含`wxwork`标识,非企微环境跳转WeworkOnly拦截页;后端OAuth2接口同步校验UA;localhost开发环境跳过检测
- **统一入口架构(2026-06-12设计)**:所有用户必须通过企微工作台→IT智能服务台应用进入;路由选择页`/itportal/`(卡片UI);角色体系user/agent/admin;管理端仅限内网/VPN访问;技术设计文档:`docs/统一入口技术设计文档.md`
- **OTP双因素认证(2026-06-14**
- 绑定方式:首次登录自动引导(用户点击"OTP二次验证"菜单 → 生成二维码+密钥 → 验证启用)
- 验证场景:访问管理后台时(admin角色且已绑定OTP)
- 后端API/agents/otp-bind、/agents/otp-verify、/agents/otp-unbind、/admin/agents/{id}/otp-unbind
- 坐席端:TopBar下拉菜单添加"OTP二次验证"选项
- 管理后台:坐席表格OTP列 + 编辑对话框强制解绑
## 产品设计文档 (2026-06-14)
- 新增 `docs/IT智能服务台-产品设计文档.md`
- 包含:竞品分析、MVP架构、风险暴露、期待管理
- 定位:融合服务台+资产+终端安全的企业级ITSM
## 技术架构
- **坐席端**Vue 3 + TS + Vite + Element Plus + Pinia
- **H5用户端**Vue 3 + Vant 4 + TS
- **管理后台**Vue 3 + TS + Element Plus + Tailwind + Pinia (`frontend-admin/`)
- **后端**FastAPI + SQLAlchemy + PostgreSQL + Redis
- **本地开发**Python 3.12 venv + SQLite + Docker Redis + Vite proxy
- **注意**:本地开发环境 `.env` 中 DATABASE_URL 指向 **SQLite**(非 PostgreSQL),凭据存储在 `backend/it_smart_desk.db`
- **⚠️ 字段映射(CRITICAL 2026-06-23修复)**
- 后端 `MessageResponse` 返回 `id`/`sender_type`(与坐席端对齐)
- H5 前端 `Message` 接口期望 `message_id`/`message_type`
- **映射层在** `frontend-h5/src/api/conversation.ts``mapMessage()` 函数
- 坐席端直接使用 `id`/`sender_type`(无需映射)
- 新增消息时必须通过 `mapMessage()` 转换,否则 Vue 渲染失败
- **H5发消息后WS广播(2026-06-23新增)**
- 后端 `h5_send_message` 现在通过 `ws_manager.broadcast()` 向坐席端推送 new_message + conversation_updated 事件
- 之前坐席端只能通过3秒轮询发现新消息,现在WS推送更实时
- **⚠️ 字段映射(CRITICAL 2026-06-23修复)**
- 后端 `MessageResponse` 返回 `id`/`sender_type`(与坐席端对齐)
- H5 前端 `Message` 接口期望 `message_id`/`message_type`
- **映射层在** `frontend-h5/src/api/conversation.ts``mapMessage()` 函数
- 坐席端直接使用 `id`/`sender_type`(无需映射)
- 新增消息时必须通过 `mapMessage()` 转换,否则 Vue 渲染失败
- **H5发消息后WS广播(2026-06-23新增)**
- 后端 `h5_send_message` 现在通过 `ws_manager.broadcast()` 向坐席端推送 new_message + conversation_updated 事件
- 之前坐席端只能通过3秒轮询发现新消息,现在WS推送更实时
- **API超时配置(2026-06-23**
- apiClient默认:20s(原10s
- 消息发送API:30s(原10s,图片/文件需更多处理时间)
- 文件上传API60s(不变)
- 后端坐席发消息:非text消息不创建Redis连接(无企微API调用)
- **字段映射(CRITICAL 2026-06-23修复)**
- 后端 MessageResponse 用 `id`/`sender_type`H5前端 Message 接口用 `message_id`/`message_type`
- 映射层在 `frontend-h5/src/api/conversation.ts``mapMessage()` 函数
- sendMessage 和 pollMessages 都经过映射
- WS handleNewMessage 直接用 sender_type → message_type(无需映射,WS推送已用正确字段名)
- **H5发消息后WS广播(2026-06-23新增)**
- 后端 `h5_send_message` 现在通过 `ws_manager.broadcast()` 向坐席端推送 new_message + conversation_updated 事件
- 之前坐席端只能通过3秒轮询发现新消息,现在WS推送更实时
## 统一入口 Portal2026-06-23 Phase 2 完成)
- **前端**`frontend-portal/`base path `/itportal/`,端口 5176
- **后端**`backend/app/api/portal.py`/portal/roles, /portal/switch-role, /portal/entry/{role}
- **认证流程**:企微工作台 → OAuth2 → Portal(角色选择)→ 跳转目标端(?token=xxx 传递)
- **⚠️ 测试环境(CRITICAL)**:本地开发环境无法完成企微 OAuth2 认证,所有登录相关验证必须在生产服务器 `10.90.5.110` 上进行
- **前端认证方式**:所有前端都通过企微认证,不支持独立登录页面
- **Token 传递**Portal 通过 URL 参数 `?token=xxx` 传递到目标前端,路由守卫读取并保存到各自 localStorage key
- **端口映射**5173(坐席), 5174(H5), 5175(管理), 5176(Portal)
- **角色系统**user(默认) / agent / adminDB 表 roles + user_roles + role_mapping_rules
- **构建验证**:三个前端 + 后端 portal.py 全部通过 ✅
- **部署配置**Nginx /itportal/ 路由已添加(本地版 + 生产版)
- **角色管理脚本**`backend/scripts/init_roles.py` + `assign_role.py`Windows GBK 兼容,无 emoji
- **本地启动脚本**`scripts/dev-portal.sh` / `dev-portal.ps1`(一键启动4个服务)
## 部署
- **NAS测试**itdesk.amanzac.com (Cloudflare Tunnel)5容器,`/volume1/docker/wecom-it-desk`
- **正式服务器**`itsupport.servyou.com.cn`10.90.5.110),4容器(无cloudflared)`/opt/wecom-it-desk`
- **服务器文件上传默认路径**`/tmp/`(堡垒机上传到此目录后 mv 到目标位置)
- **堡垒机**`sxn@10.212.189.210:2222`OTP),默认目录 `/tmp/`
- **⚠️ 公司服务器文件上传方式限制**:只能通过堡垒机手动上传(SFTP/Web界面),不支持从本地直接 scp 推送到服务器;部署时需先下载部署包到本地,再通过堡垒机上传到 `/tmp/`
- **⚠️ 公司服务器文件上传方式限制**:只能通过堡垒机手动上传(SFTP/Web界面),不支持从本地直接 scp 推送到服务器;部署时需先下载部署包到本地,再通过堡垒机上传到 `/tmp/`
- **Docker镜像加速器**:内网无法拉 Docker Hub,需配置 daemon.json(腾讯云/USTC),或离线导入 tar 包
- **PyPI镜像**:服务器可访问 pypi.tuna.tsinghua.edu.cn,后端构建正常
- **HTTPS**:已配置 SSL`*.servyou.com.cn` 通配符证书,GeoTrust/DigiCert),nginx 监听 443HTTP 自动 301 跳转
- **WAF**:域名 itsupport.servyou.com.cn 经 WAF(10.80.0.136) 转发到 10.90.5.110,需 WAF 管理员配置
- 堡垒机:sxn@10.212.189.210:2222 (OTP)Dockerfile用清华PyPI镜像
- 前端base路径:H5 `/itdesk/`Agent `/itagent/`Admin `/itadmin/`API `/api`
- 前端开发端口:5173(坐席)5174(H5)5175(管理后台)
- Mock登录:`POST /api/h5/mock-login`;生产清空 `VITE_WECOM_CORP_ID`
- **Redis协议兼容**Windows Redis 3.x 不支持 RESP3,必须用 `protocol=2` 创建客户端(通过 `settings.create_redis_client()`
- **Redis客户端创建统一入口**:`settings.create_redis_client()` 代替直接 `aioredis.from_url()`
- **⚠️ uvicorn --reload 缓存陷阱(2026-06-13**WatchFiles reloader 可能缓存旧字节码,清 `__pycache__` 无效;本地开发建议 `reload=False` 或重启前杀掉所有 Python 进程
## 五阶段演进
1. 转人工改H5+坐席MVP+邀请(1A) | 管理后台(1B) | 端到端验证(1C)
2. H5全流程+WS+排队+满意度+OAuth2
3. AI Wingman+排查流程图+标注
4. 迭代闭环+数据看板+知识库
5. 自动/辅助审核、开单、结单
## 管理后台已实现(1B+1C+P2
- 路由前缀 `/api/admin/`;权限 require_adminP0:仪表盘/功能开关/坐席管理
- P1:分配模式/快速回复审核/集成配置/会话监控
- **P2 已实现(2026-06-13**:会话审计/坐席绩效/系统日志
- **集成三种配置模式**url_key(Dify/RAGFlow) / access_key(火绒) / account_password(联软)
- **集成管理**6个系统定义(dify/ragflow可配置,huorong access_keylianruan account_password,其余占位)
- **终端安全页**TerminalSecurity.vue 展示火绒终端数据(含demo数据fallback
- **角色管理页(2026-06-13完成)**Roles.vue — 三角色卡片+用户分配表+映射规则表;路由 `/roles`;侧边栏"运营管理"分组
- 后端 RBAC 完整:Role/UserRole/RoleMappingRule 模型 + admin_roles API(6端点) + role_mapping_service + Portal API
- 前端:types定义 + admin.ts 6个API函数 + Roles.vue 页面 + 路由 + 侧边栏
- 编译验证:vite build ✅
- **功能开关增强**CONFIG_GROUP_MAP 新增 queue_/satisfaction_/invite_/notification_/security_ 5个分组
## 外部系统集成
- **北森eHR**OAuth2.0,需找HR数字化团队对接
- **企微设备管理**:❌付费功能公司未购买(errcode 48002)
- **火绒企业版**HMAC-SHA1 AccessKey认证,17个API端点 ✅后端+前端已完成
- 后端HuorongClient(4级异常+数据模型) + API端点 + 前端终端安全页
- **errno/errcode兼容**:认证失败返回 `errno`(非 `errcode`),需 model_validator 归一化
- **凭据配置**:通过集成管理页 access_key 模式保存到 SQLite,路径 `/api/clnts/_list`
- **当前状态**:✅认证成功!根据官方API文档重写了HRESS签名机制,可正常获取终端数据
- **签名算法(官方文档确认)**
- Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
- Signature = urlencode(base64(hmac-sha1(AccessKeySecret, AccessKeyId + "\n" + Expires + "\n" + POST + "\n" + Content-MD5 + "\n" + CanonicalizedResource)))
- Content-MD5 = base64(md5_digest(body_bytes))RFC2616
- CanonicalizedResource = API路径去掉前导/(如 "api/clnts/_list"
- **API参数**:统一POST JSON;分页用 limit/offset(非 page/per_page
- **响应格式**:始终使用 errno(0=成功/1=认证失败/2=参数错误/3=内部错误/4=未授权)
- **UI标签差异**:火绒控制中心显示"Secret ID/Secret Key"=文档的"AccessKey ID/AccessKey Secret"
- **API文档**:不公开,通过技术支持QQ(320171962)单独分发;用户已保存MHTML到`D:\资料\00-工作文件\02-系统运维\火绒安全\`
- **_leak接口字段差异**(高危漏洞终端):
- `cid`(非client_id), `hostname`(非computer_name), `ip_addr`(非local_ip)
- `stat`(1=离线/2=在线/3=异常, 非is_online布尔值)
- `osver`(非os_version), `prodver`(非version)
- 外层返回 `all_client`(终端总数) + `risk_client`(高危终端数),无total
- **_virus_events接口字段**(病毒事件统计):
- `count`(病毒日志数), `result{success/fail/ignored/trusted}`(处理结果统计)
- 必须指定`type`: 0=按client_id/1=按group_id/2=全部
- 支持`begin_time`/`end_time`时间范围过滤(Unix时间戳)
- 返回`total`(查询总数)
- **联软LV7000**:三层认证(IP白名单+账号密码+Token),68个API端口 ✅后端+前端已完成
- ⭐核心价值:`strusername`字段=员工→终端精确映射(优于火绒IP匹配)
- 后端:LianruanClient(4级异常+数据模型) + API端点(3个) + config.py
- 前端:Integrations.vue三模式对话框(account_password) + IntegrationCard.vue + api/admin.ts
- 编译验证:前端 vite build ✅ / 后端 py_compile ✅
- **IT安全运维管理系统**:主机 `192.168.1.53`,备机 `192.168.1.54`
- **Dify**:✅已集成(AIService + WingmanService),调用 dify2openai 桥接
- 生产:`http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions`
- API Key格式:`base_url|app_id|app_name`
- 两个AgentAgent1(员工端自动回复) + Agent2(坐席端Wingman辅助)
- **RAGFlow**:生产 `http://10.80.0.85:8080/`(前端) / `http://10.80.0.85:9380/`(API)
- 测试:`http://10.90.5.8:8082/`
- API Key`sk-654e************f7b91ea2b`(已获取)
- 向量模型:bge-m3;知识运营:宋献IT组主导
- 大模型后端:千问 Qwen3-30B-A3B-Instruct @ `http://10.80.0.49:5000`
- ✅ 客户端已开发:`backend/app/integrations/ragflow/client.py`
- 核心接口:`POST /api/v1/retrieval`(知识检索)
- 管理接口:列出/创建/删除知识库、上传/列出/删除文档
- Admin API`/admin/integrations/ragflow/test|datasets|retrieval`
- **千问模型**`http://10.80.0.49:5000/api/llm/servyou/v1/chat/completions`
- 模型:Qwen3-30B-A3B-Instruct;通过Dify Workflow间接调用,无需直连
- **对接联系人**dify2openai→JG(标准)/CF(搭建)Dify应急→CF/WTB端智能体→JG
- **aTrust**HMAC-SHA256签名,104个API端点,需找信息安全团队获取API密钥
- **映射策略**:联软(主P0) > aTrust(VPN辅) > eHR(静态数据);火绒=安全源不参与映射
## 邀请功能(1A
- 方案三:WebSocket+应用消息双通道扩展
- 数据模型:conversations表新增participants JSON字段
- H5端+坐席端+后端均已完成(vite build ✅)
- 后端20个邀请测试全部通过 ✅(2026-06-12修复测试基础设施)
- 测试修复:路径前缀(`/api/``/`) + WecomService mock + ParticipantInfo schema补全(joined/joined_at/avatar) + 断言改业务错误码
- **H5专用参与者API2026-06-13**:统一 `/h5/` 前缀 + `_get_current_employee` 认证
- `POST /h5/conversations/{id}/join` — 加入会话(employee_id 从 Token 获取)
- `POST /h5/conversations/{id}/leave-participant` — 退出会话
- `GET /h5/conversations/{id}/participants` — 获取参与者列表
-`/conversations/{id}/join``/leave-participant` 无认证,保留给坐席端使用
## H5端消息推送
- 双通道:企微`/message/send`(必达) + H5 WebSocket(即时);断连降级→轮询
- **H5 WS端点(2026-06-12已实现)**`/ws/h5/{employee_id}?token=xxx`
- 认证:Redis `employee:token:{token}` → employee_id 一致性校验
- 事件推送:participant_invited/joined/removed/left、new_message
- 坐席端仍使用 `/ws/{agent_id}?token=xxx`
- **ConnectionManager 扩展**:坐席连接(`active_connections`) + 员工连接(`employee_connections`) 分开管理
- **session_service._broadcast_participant_change()**:广播给坐席 + 推送给相关H5员工
- **H5前端 WS composable**`useH5WebSocket.ts`,与坐席端 `useWebSocket.ts` 对齐
- **降级策略**:WS断连→3秒轮询;WS重连→停止轮询
- P0待办:Nginx超时优化
## 痛点清单
1. 员工入口体验差 → 阶段二
2. 坐席能力不稳定 → 阶段三
3. 知识无法积累传承 → 阶段四
4. 管理缺乏数据支撑 → 阶段四
+192
View File
@@ -0,0 +1,192 @@
# 企微 IT 智能服务台 (IT Smart Desk)
> **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s
> **维护者**: 税友集团 IT支持组(宋献)
> **最后更新**: 2026-06-03
---
## 📖 阅读指南(按对象)
| 阅读对象 | 推荐文档 | 内容 |
|---------|---------|------|
| **新人/产品经理** | 本文档 + docs/ARCHITECTURE.md 前半部分 | 项目背景、功能清单、如何使用 |
| **开发人员** | docs/ARCHITECTURE.md + backend/app/ 代码 | 架构设计、API 接口、数据模型、开发规范 |
| **运维/部署人员** | 本文档「部署」章节 + scripts/deploy.sh | Docker 编排、Nginx 配置、环境变量 |
| **测试人员** | docs/ARCHITECTURE.md 「任务清单」 | 功能模块划分、待完善项 |
---
## 🎯 项目背景
税友集团内部 IT 支持渠道分散(企微群、电话、走访),缺乏统一 SLA 追踪。本项目构建一个 **AI + 人工坐席协作** 的智能服务台:
- **员工端**H5):企微 OAuth2 免登,AI 自动回复 + 人工兜底,支持「敲桌子」趣味呼叫
- **坐席端**(Web):三栏工作台,会话分配/抢单/协作/转接,实时 WebSocket 推送
- **AI 层**:接入 RAGFLOW/Dify 知识库,自动回复常见 IT 问题
**核心指标**:AI 自助解决率 55%(实际 1-5月已达 70.2%
---
## ✅ 当前实现进度
### 后端(FastAPI + PostgreSQL + Redis
- [x] 企微回调加解密(AES-CBC-256
- [x] 消息路由(VIP 识别、紧急度评分 1-5、标记检测)
- [x] WebSocket 实时推送(心跳、重连、定向广播)
- [x] 会话全生命周期(创建→分配→处理→结单→转接)
- [x] 坐席管理(登录、状态切换、在线列表)
- [x] H5 端 OAuth2 认证、审批链接、软件下载
- [x] 应急模式(系统故障时手动开启)
- [x] Alembic 数据库迁移(初始表结构)
- [ ] **AI 回复集成**(需接入 Dify,目前新会话直接进入队列)
- [ ] 自动化测试(pytest
### 坐席前端(Vue 3 + Element Plus
- [x] 登录页(用户ID + 姓名,无密码)
- [x] 三栏工作台(会话列表 + 对话区 + AI 助手面板)
- [x] 6 分区会话列表(待接单/我的/协作/其他坐席/AI处理/已结单)
- [x] 协作功能(摇人邀请、接受/拒绝)
- [x] WebSocket + 轮询双模式(自动降级)
- [ ] 操作步骤、风险提示、用户信息面板(需确认后端数据)
### 员工前端(Vue 3 + Vant
- [x] OAuth2 静默授权登录
- [x] 聊天界面(员工/坐席/AI/系统 4 种消息气泡)
- [x] 「敲桌子」呼叫坐席(7 种 SVG 动画)
- [x] AI 助手面板、审批流程链接、软件下载
- [ ] AI 回复展示(依赖后端 AI 集成)
### 部署
- [x] Docker Compose 4 容器编排(nginx + backend + postgres + redis
- [x] Nginx 反向代理(与数据平台共享域名,独立主机部署)
- [x] 部署脚本(build.sh + deploy.sh
- [ ] HTTPS 启用(nginx.conf 已预留模板)
- [ ] **预生产环境验证**(独立主机,路径路由到数据平台远程主机)
---
## 🚀 快速启动
### 前置条件
- Docker Desktop / Docker Compose
- 企微企业应用(需配置 Token、EncodingAESKey、CorpID
- 公钥上传至服务器(部署用)
### 本地开发
```bash
# 1. 复制环境变量
cp .env.example .env
# 编辑 .env 填入企微凭据和数据库密码
# 2. 启动数据库(仅 PostgreSQL
docker compose up -d postgres redis
# 3. 后端开发模式
cd backend
python -m venv venv && venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
# 4. 前端开发模式(另开终端)
cd frontend-agent
npm install && npm run dev
```
### 预生产部署
> **注意**:预生产环境中,智能咨询系统与数据平台在不同主机上。部署前需将 `nginx/nginx.conf` 中 `DATAQUERY_HOST` 替换为数据平台实际 IP。
```bash
# 1. 修改 nginx 反代地址
# 编辑 nginx/nginx.conf,将 DATAQUERY_HOST 改为数据平台主机 IP
# 2. 使用部署脚本
bash scripts/deploy.sh deploy
# 3. 或手动
docker compose up -d --build
```
> 正式环境将迁移到 K8s 集群,届时部署方式另行调整。
---
## 📂 项目结构
```
wecom_it_smart_desk/
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/ # 8 个路由模块
│ │ ├── models/ # 9 个数据模型
│ │ ├── services/ # 核心服务(消息路由、会话管理、企微API)
│ │ ├── schemas/ # Pydantic 请求/响应 Schema
│ │ └── utils/ # 加解密、Token管理、WebSocket
│ └── alembic/ # 数据库迁移
├── frontend-agent/ # 坐席工作台(Vue 3 + Element Plus
├── frontend-h5/ # 员工端(Vue 3 + Vant
├── nginx/ # Nginx 反向代理配置
├── scripts/ # 构建和部署脚本
├── docs/ # 项目文档(架构/PRD/测试报告等)
└── docker-compose.yml # 容器编排
```
---
## 🔧 核心配置项(.env
| 变量 | 说明 | 示例 |
|------|------|------|
| `WECOM_CORPID` | 企微企业ID | `ww...` |
| `WECOM_AGENT_TOKEN` | 企微应用 Token | `your_token` |
| `WECOM_AGENT_ENCODING_AES_KEY` | 企微消息加解密密钥 | `32位Base64` |
| `DATABASE_URL` | 数据库连接串 | `postgresql+asyncpg://...` |
| `REDIS_URL` | Redis 连接串 | `redis://...` |
| `BACKEND_PORT` | 后端端口(容器内需 8000) | `8000` |
---
## 📊 API 端点概览
| 模块 | 端点前缀 | 核心功能 |
|------|---------|---------|
| 坐席管理 | `/api/v1/agents` | 登录、状态切换、在线列表 |
| 会话管理 | `/api/v1/conversations` | 列表、详情、分配、结单、转接 |
| 消息管理 | `/api/v1/messages` | 消息列表、发送、轮询 |
| 企微回调 | `/api/v1/wecom` | GET 验证、POST 接收消息 |
| H5 员工端 | `/api/v1/h5` | OAuth2、消息、举手、审批 |
| 快速回复 | `/api/v1/quick-replies` | 模板 CRUD |
| 系统 | `/api/v1/system` | 应急模式开关 |
> 详细请求/响应格式见 `docs/ARCHITECTURE.md` 或运行后访问 `/docs`Swagger UI
---
## 🐛 已知问题 / 待完善
1. **AI 回复未集成**:目前新会话直接进入 `queued` 状态,需接入 Dify 工作流
2. **无自动化测试**:后端 pytest、前端 Vitest 均未配置
3. **Alembic 迁移不完整**:仅初始迁移,后续模型变更需手动管理
4. **HTTPS 未启用**nginx.conf 有模板但未配置证书
5. **VIP 缓存未实现**`message_router.py` 中 Redis 缓存被注释
---
## 📝 相关文档
- **docs/ARCHITECTURE.md**:完整架构设计、数据模型、调用流程、任务清单
- **docs/现有系统交接文档内容.txt**:现有 IT 客服机器人系统交接信息(RAGFLOW、Dify 部署环境)
- **scripts/deploy.sh**:部署脚本详细说明(5 种运行模式)
---
## 📞 联系
- **项目负责人**:宋献 — 税友集团 IT支持组
- **企业微信**:通过内部企微联系
- **Issue 反馈**:在项目目录创建任务文档或联系开发组
---
*最后更新:2026-06-03 - 合并文档,反映当前实际完成进度*
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# 企微IT智能服务台 — 后端 Docker 镜像构建文件
# =============================================================================
# 说明:基于 Python 3.12 构建后端镜像
# 用法:docker build -t wecom-it-desk-backend .
# =============================================================================
# --------------------------------------------------------------------------
# 第一阶段:构建阶段
# --------------------------------------------------------------------------
FROM python:3.12-slim AS builder
# 设置工作目录
WORKDIR /app
# 安装系统依赖(psycopg2 编译需要 + qrcode 图片处理需要 + healthcheck 需要 curl
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev libjpeg-dev zlib1g-dev curl && \
rm -rf /var/lib/apt/lists/*
# 复制依赖声明文件并安装(利用 Docker 层缓存,依赖不变则不重新安装)
# 使用清华大学 PyPI 镜像源,解决公司内网下载 PyPI 官方源超时问题
COPY requirements.txt .
RUN pip install --no-cache-dir \
--timeout 120 \
--retries 5 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt
# --------------------------------------------------------------------------
# 第二阶段:运行阶段(更小的镜像体积)
# --------------------------------------------------------------------------
FROM python:3.12-slim
# 设置标签信息
LABEL maintainer="IT服务台开发团队"
LABEL description="企微IT智能服务台后端服务"
# 安装运行时依赖(psycopg2 运行时需要 libpq + healthcheck 需要 curl
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 curl && \
rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 从构建阶段复制已安装的 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# 复制项目代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令(Docker Compose 中会覆盖为 alembic upgrade head + uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+42
View File
@@ -0,0 +1,42 @@
# Alembic database migration configuration
# Usage: alembic upgrade head
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://wecom:wecom_secret@localhost:5432/wecom_it_desk
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+62
View File
@@ -0,0 +1,62 @@
# =============================================================================
# Alembic migration environment
# Handles both sync (alembic CLI) and async (app runtime) database URLs
# =============================================================================
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.config import settings
from app.database import Base
import app.models # noqa: F401
config = context.config
# Convert async URL to sync for alembic CLI operations
# aiosqlite -> sqlite, asyncpg -> psycopg2
db_url = settings.database_url
db_url = db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", db_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Generate SQL scripts without connecting to the database."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Connect to the database and run migrations."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,47 @@
"""add media fields to messages table
为消息表添加媒体文件相关字段,支持图片、语音、文件等非文本消息。
新增字段:
- media_id: 企微媒体文件ID3天有效)
- media_url: 本地存储的媒体文件URL
- file_name: 文件名
- file_size: 文件大小(字节)
- extra_data: 扩展元数据(JSON
Revision ID: 002_media_fields
Revises: 6d5520491644
Create Date: 2026-06-03 17:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_media_fields'
down_revision = '6d5520491644'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加媒体文件相关字段到 messages 表。"""
# 企微媒体文件ID(图片/语音/视频消息携带,3天有效)
op.add_column('messages', sa.Column('media_id', sa.String(256), nullable=True, comment='企微媒体文件ID3天有效)'))
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
op.add_column('messages', sa.Column('media_url', sa.String(512), nullable=True, comment='本地存储的媒体文件URL'))
# 文件名(文件消息携带)
op.add_column('messages', sa.Column('file_name', sa.String(256), nullable=True, comment='文件名'))
# 文件大小(字节)
op.add_column('messages', sa.Column('file_size', sa.Integer(), nullable=True, comment='文件大小(字节)'))
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
op.add_column('messages', sa.Column('extra_data', sa.JSON(), nullable=True, comment='扩展元数据(JSON'))
def downgrade() -> None:
"""移除媒体文件相关字段。"""
op.drop_column('messages', 'extra_data')
op.drop_column('messages', 'file_size')
op.drop_column('messages', 'file_name')
op.drop_column('messages', 'media_url')
op.drop_column('messages', 'media_id')
@@ -0,0 +1,40 @@
"""add suggestion_action field to messages table
为消息表添加 suggestion_action 字段,用于追踪坐席对 AI 建议的操作行为。
取值范围:accepted(采纳)/ edited(编辑后采纳)/ ignored(忽略)
新增字段:
- suggestion_action: VARCHAR(20), nullable, 坐席对AI建议的操作行为
Revision ID: 003_suggestion_action
Revises: 002_media_fields
Create Date: 2026-07-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_suggestion_action'
down_revision = '002_media_fields'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 suggestion_action 字段到 messages 表。"""
# 坐席对 AI 建议的操作行为(accepted/edited/ignored
op.add_column(
'messages',
sa.Column(
'suggestion_action',
sa.String(20),
nullable=True,
comment='AI建议操作行为: accepted/edited/ignored',
)
)
def downgrade() -> None:
"""移除 suggestion_action 字段。"""
op.drop_column('messages', 'suggestion_action')
@@ -0,0 +1,58 @@
"""add participants field to conversations table
为会话表添加 participants JSON 字段,支持邀请功能(P0-09~P0-11)。
与 collaborating_agent_ids(摇人 = 坐席间协作)独立,
participants 存储被邀请的员工/部门列表。
新增字段:
- participants: JSON, 非空, 默认空列表, 被邀请参与会话的人员列表
数据格式:
[
{
"id": "employee_user_id",
"name": "员工姓名",
"department": "部门名称",
"type": "employee", # employee 或 department
"joined": false, # 是否已加入会话
"joined_at": null, # 加入时间
"invited_by": "agent_id" # 邀请人坐席ID
}
]
Revision ID: 004_participants
Revises: 003_suggestion_action
Create Date: 2026-07-14 14:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004_participants'
down_revision = '003_suggestion_action'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 participants 字段到 conversations 表。"""
# 被邀请参与会话的人员列表(JSON 数组)
# 与 collaborating_agent_ids 区别:
# collaborating_agent_ids = 坐席→坐席协作(摇人)
# participants = 坐席→员工/部门(邀请)
op.add_column(
'conversations',
sa.Column(
'participants',
sa.JSON,
nullable=False,
server_default='[]', # 默认空数组
comment='被邀请参与会话的人员列表(邀请功能)',
)
)
def downgrade() -> None:
"""移除 participants 字段。"""
op.drop_column('conversations', 'participants')
@@ -0,0 +1,43 @@
"""add reply_to_id field to messages table
为消息表添加 reply_to_id 字段,支持消息引用回复功能(M1)。
当消息是对某条消息的回复时,此字段指向被回复的消息ID。
新增字段:
- reply_to_id: VARCHAR(36), nullable, 被回复的消息ID
前端展示逻辑:
- reply_to_id 非空时,在消息气泡上方显示被回复消息的摘要
- 点击摘要可滚动到被回复的消息位置
Revision ID: 005_reply_to_id
Revises: 004_participants
Create Date: 2026-07-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005_reply_to_id'
down_revision = '004_participants'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 reply_to_id 字段到 messages 表。"""
op.add_column(
'messages',
sa.Column(
'reply_to_id',
sa.String(36),
nullable=True,
comment='引用回复:被回复的消息ID',
)
)
def downgrade() -> None:
"""移除 reply_to_id 字段。"""
op.drop_column('messages', 'reply_to_id')
@@ -0,0 +1,116 @@
"""admin extension — 管理后台数据库扩展迁移
新增 config_change_logs 表(配置变更日志)。
扩展 agents 表:新增 role(角色)和 skill_tags(技能标签)字段。
扩展 quick_reply_templates 表:新增 status(审核状态)、version(版本号)、
submitted_by(提交人)字段。
Revision ID: 006_admin_ext
Revises: 005_reply_to_id
Create Date: 2026-07-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '006_admin_ext'
down_revision = '005_reply_to_id'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""执行管理后台数据库扩展迁移。"""
# 1. 创建 config_change_logs 表
op.create_table(
'config_change_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('config_key', sa.String(128), nullable=False, comment='配置键'),
sa.Column('old_value', sa.Text, nullable=False, server_default='', comment='变更前的值'),
sa.Column('new_value', sa.Text, nullable=False, server_default='', comment='变更后的值'),
sa.Column('changed_by', sa.String(36), nullable=False, comment='变更操作人 agent_id'),
sa.Column('changed_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), comment='变更时间'),
)
# 创建索引
op.create_index('idx_ccl_config_key', 'config_change_logs', ['config_key'])
op.create_index('idx_ccl_changed_at', 'config_change_logs', ['changed_at'])
# 2. 给 agents 表新增 role 字段
op.add_column(
'agents',
sa.Column(
'role',
sa.String(20),
nullable=False,
server_default='agent',
comment='角色:admin=组长, agent=坐席',
)
)
# 3. 给 agents 表新增 skill_tags 字段
op.add_column(
'agents',
sa.Column(
'skill_tags',
sa.JSON,
nullable=False,
server_default='[]',
comment='技能标签列表(电脑/软件/外设/网络/安全/资产/其他)',
)
)
# 4. 给 quick_reply_templates 表新增 status 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'status',
sa.String(20),
nullable=False,
server_default='approved',
comment='状态:draft/pending_review/approved/rejected',
)
)
# 5. 给 quick_reply_templates 表新增 version 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'version',
sa.Integer(),
nullable=False,
server_default='1',
comment='版本号,每次审核通过后 +1',
)
)
# 6. 给 quick_reply_templates 表新增 submitted_by 字段
op.add_column(
'quick_reply_templates',
sa.Column(
'submitted_by',
sa.String(36),
nullable=True,
comment='提交人 agent_id',
)
)
def downgrade() -> None:
"""回滚管理后台数据库扩展迁移。"""
# 删除 quick_reply_templates 新增字段
op.drop_column('quick_reply_templates', 'submitted_by')
op.drop_column('quick_reply_templates', 'version')
op.drop_column('quick_reply_templates', 'status')
# 删除 agents 新增字段
op.drop_column('agents', 'skill_tags')
op.drop_column('agents', 'role')
# 删除 config_change_logs 表索引和表
op.drop_index('idx_ccl_changed_at', table_name='config_change_logs')
op.drop_index('idx_ccl_config_key', table_name='config_change_logs')
op.drop_table('config_change_logs')
+104
View File
@@ -0,0 +1,104 @@
"""role system — 统一入口角色系统迁移
新增 roles 表(角色定义)。
新增 user_roles 表(用户角色关联)。
新增 role_mapping_rules 表(角色映射规则)。
预置三个基础角色:user、agent、admin。
Revision ID: 007_role_sys
Revises: 006_admin_ext
Create Date: 2026-06-12 23:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007_role_sys'
down_revision = '006_admin_ext'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""执行角色系统迁移。"""
# 1. 创建 roles 表
op.create_table(
'roles',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(50), unique=True, nullable=False, comment='角色标识:user/agent/admin'),
sa.Column('display_name', sa.String(100), nullable=False, comment='显示名称:用户/坐席/管理员'),
sa.Column('description', sa.Text, nullable=True, comment='角色描述'),
sa.Column('permissions', sa.JSON, nullable=False, server_default='[]', comment='权限列表'),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='0', comment='是否默认角色'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='更新时间'),
)
# 2. 创建 user_roles 表
op.create_table(
'user_roles',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('employee_id', sa.String(100), nullable=False, comment='企微 UserID'),
sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='角色 ID'),
sa.Column('source', sa.String(50), nullable=False, comment='角色来源:auto/tag/ehr/manual'),
sa.Column('assigned_by', sa.String(100), nullable=True, comment='分配者'),
sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='分配时间'),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True, comment='过期时间'),
sa.UniqueConstraint('employee_id', 'role_id', name='uq_user_role'),
)
# 创建索引
op.create_index('idx_user_roles_employee_id', 'user_roles', ['employee_id'])
op.create_index('idx_user_roles_role_id', 'user_roles', ['role_id'])
# 3. 创建 role_mapping_rules 表
op.create_table(
'role_mapping_rules',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='目标角色 ID'),
sa.Column('source_type', sa.String(50), nullable=False, comment='来源类型:wecom_tag/ehr_position'),
sa.Column('source_value', sa.String(200), nullable=False, comment='来源值:标签名/岗位关键词'),
sa.Column('priority', sa.Integer(), nullable=False, server_default='0', comment='优先级'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1', comment='是否启用'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), comment='创建时间'),
)
# 创建索引
op.create_index('idx_role_mapping_rules_role_id', 'role_mapping_rules', ['role_id'])
op.create_index('idx_role_mapping_rules_source_type', 'role_mapping_rules', ['source_type'])
# 4. 预置三个基础角色
# 注意:使用 op.execute 直接插入数据,因为 server_default 不适用于 Python 端生成的 UUID
# PostgreSQL 使用 NOW() 替代 SQLite 的 datetime('now')
op.execute("""
INSERT INTO roles (id, name, display_name, description, permissions, is_default, created_at, updated_at) VALUES
('role_user_001', 'user', '用户', '所有在职员工默认角色,可提交工单、查看进度、浏览知识库', '["ticket.create", "ticket.view", "knowledge.view"]', TRUE, NOW(), NOW()),
('role_agent_001', 'agent', '坐席', 'IT支持人员,可处理会话、使用AI辅助、管理工单', '["conversation.manage", "ticket.assign", "knowledge.edit", "ai.wingman"]', FALSE, NOW(), NOW()),
('role_admin_001', 'admin', '管理员', '系统管理员,可配置系统、管理权限、查看数据分析', '["system.config", "user.manage", "role.manage", "analytics.view"]', FALSE, NOW(), NOW())
""")
# 5. 预置默认映射规则(企微标签 → agent 角色)
op.execute("""
INSERT INTO role_mapping_rules (id, role_id, source_type, source_value, priority, is_active, created_at) VALUES
('rule_agent_tag_001', 'role_agent_001', 'wecom_tag', 'IT坐席', 10, TRUE, NOW()),
('rule_agent_ehr_001', 'role_agent_001', 'ehr_position', 'IT支持', 10, TRUE, NOW()),
('rule_agent_ehr_002', 'role_agent_001', 'ehr_position', 'IT运维', 10, TRUE, NOW()),
('rule_agent_ehr_003', 'role_agent_001', 'ehr_position', '技术支持', 10, TRUE, NOW())
""")
def downgrade() -> None:
"""回滚角色系统迁移。"""
# 删除 role_mapping_rules 表索引和表
op.drop_index('idx_role_mapping_rules_source_type', table_name='role_mapping_rules')
op.drop_index('idx_role_mapping_rules_role_id', table_name='role_mapping_rules')
op.drop_table('role_mapping_rules')
# 删除 user_roles 表索引和表
op.drop_index('idx_user_roles_role_id', table_name='user_roles')
op.drop_index('idx_user_roles_employee_id', table_name='user_roles')
op.drop_table('user_roles')
# 删除 roles 表
op.drop_table('roles')
@@ -0,0 +1,199 @@
"""initial_all_tables
Revision ID: 6d5520491644
Revises:
Create Date: 2026-06-03 17:28:43.238581
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d5520491644'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agents',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=64), nullable=False, comment='企微用户ID(唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='坐席姓名'),
sa.Column('status', sa.String(length=20), nullable=False, comment='坐席状态: online/offline/busy'),
sa.Column('current_load', sa.Integer(), nullable=False, comment='当前服务会话数'),
sa.Column('max_load', sa.Integer(), nullable=False, comment='最大同时服务数'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
op.create_table('approval_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:IT/HR/行政/财务'),
sa.Column('title', sa.String(length=128), nullable=False, comment='审批名称'),
sa.Column('url', sa.Text(), nullable=False, comment='审批链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_al_category', 'approval_links', ['category'], unique=False)
op.create_table('conversations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID(主企业或下游企业)'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID'),
sa.Column('employee_name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=256), nullable=False, comment='部门'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('level', sa.String(length=64), nullable=False, comment='等级'),
sa.Column('status', sa.String(length=20), nullable=False, comment='会话状态: ai_handling/queued/serving/resolved'),
sa.Column('is_vip', sa.Boolean(), nullable=False, comment='VIP标记'),
sa.Column('is_pinned', sa.Boolean(), nullable=False, comment='置顶标记'),
sa.Column('is_todo', sa.Boolean(), nullable=False, comment='代办标记'),
sa.Column('urgency_score', sa.Integer(), nullable=False, comment='紧急度1-5'),
sa.Column('tags', sa.JSON(), nullable=False, comment='标签集合'),
sa.Column('assigned_agent_id', sa.String(length=64), nullable=True, comment='分配的坐席ID'),
sa.Column('collaborating_agent_ids', sa.JSON(), nullable=False, comment='协作坐席ID列表'),
sa.Column('ai_substantive_reply_count', sa.Integer(), nullable=False, comment='AI实质性回复计数(满3次可呼叫坐席)'),
sa.Column('last_message_at', sa.DateTime(timezone=True), nullable=True, comment='最后消息时间'),
sa.Column('last_message_summary', sa.String(length=256), nullable=False, comment='最后消息摘要'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_conversations_assigned_agent', 'conversations', ['assigned_agent_id'], unique=False)
op.create_index('idx_conversations_corp_id', 'conversations', ['corp_id'], unique=False)
op.create_index('idx_conversations_employee_id', 'conversations', ['employee_id'], unique=False)
op.create_index('idx_conversations_last_message_at', 'conversations', ['last_message_at'], unique=False)
op.create_index('idx_conversations_status', 'conversations', ['status'], unique=False)
op.create_index('idx_conversations_urgency_score', 'conversations', ['urgency_score'], unique=False)
op.create_table('employees',
sa.Column('id', sa.String(length=36), nullable=False, comment='员工记录唯一标识'),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID(企业内唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=512), nullable=False, comment='部门ID列表(JSON数组)'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('mobile', sa.String(length=32), nullable=False, comment='手机号'),
sa.Column('email', sa.String(length=128), nullable=False, comment='邮箱'),
sa.Column('avatar', sa.String(length=512), nullable=False, comment='头像URL'),
sa.Column('status', sa.Integer(), nullable=False, comment='激活状态: 1=已激活, 2=已禁用, 4=未激活'),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True, comment='最后登录时间'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('corp_id', 'employee_id', name='uq_employee_corp')
)
op.create_index('idx_employees_corp_id', 'employees', ['corp_id'], unique=False)
op.create_index('idx_employees_employee_id', 'employees', ['employee_id'], unique=False)
op.create_table('funny_phrases',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('scene', sa.String(length=64), nullable=False, comment='触发场景: shake/keyword/waiting/connected/timeout/vip'),
sa.Column('content', sa.Text(), nullable=False, comment='话术内容'),
sa.Column('tone', sa.String(length=32), nullable=False, comment='语气标签'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('is_active', sa.Boolean(), nullable=False, comment='是否启用'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_fp_scene', 'funny_phrases', ['scene'], unique=False)
op.create_table('quick_reply_templates',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:账号/网络/软件/硬件/通用'),
sa.Column('title', sa.String(length=128), nullable=False, comment='模板标题'),
sa.Column('content', sa.Text(), nullable=False, comment='模板内容,支持变量如 {employee_name}'),
sa.Column('variables', sa.JSON(), nullable=False, comment='可用变量列表'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_qr_category', 'quick_reply_templates', ['category'], unique=False)
op.create_table('software_downloads',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:办公/开发/安全/工具'),
sa.Column('name', sa.String(length=128), nullable=False, comment='软件名称'),
sa.Column('version', sa.String(length=32), nullable=False, comment='版本号'),
sa.Column('platform', sa.String(length=32), nullable=False, comment='平台: Windows/Mac/Linux/全平台'),
sa.Column('download_url', sa.Text(), nullable=False, comment='下载链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_sd_category', 'software_downloads', ['category'], unique=False)
op.create_table('system_configs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('config_key', sa.String(length=128), nullable=False, comment='配置键'),
sa.Column('config_value', sa.Text(), nullable=False, comment='配置值(JSON字符串或纯文本)'),
sa.Column('description', sa.String(length=256), nullable=False, comment='配置说明'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('config_key')
)
op.create_table('agent_notes',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('agent_id', sa.String(length=64), nullable=False, comment='坐席ID'),
sa.Column('content', sa.Text(), nullable=False, comment='备注内容'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_an_conversation', 'agent_notes', ['conversation_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('sender_type', sa.String(length=20), nullable=False, comment='发送者类型: employee/agent/ai/system'),
sa.Column('sender_id', sa.String(length=64), nullable=False, comment='发送者ID'),
sa.Column('sender_name', sa.String(length=128), nullable=False, comment='发送者姓名'),
sa.Column('content', sa.Text(), nullable=False, comment='消息内容'),
sa.Column('msg_type', sa.String(length=20), nullable=False, comment='消息类型: text/image/file/system'),
sa.Column('ai_suggestion', sa.Boolean(), nullable=False, comment='是否为AI建议'),
sa.Column('is_read', sa.Boolean(), nullable=False, comment='是否已读'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_messages_conversation_created', 'messages', ['conversation_id', 'created_at'], unique=False)
op.create_index('idx_messages_conversation_id', 'messages', ['conversation_id'], unique=False)
op.create_index('idx_messages_created_at', 'messages', ['created_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_messages_created_at', table_name='messages')
op.drop_index('idx_messages_conversation_id', table_name='messages')
op.drop_index('idx_messages_conversation_created', table_name='messages')
op.drop_table('messages')
op.drop_index('idx_an_conversation', table_name='agent_notes')
op.drop_table('agent_notes')
op.drop_table('system_configs')
op.drop_index('idx_sd_category', table_name='software_downloads')
op.drop_table('software_downloads')
op.drop_index('idx_qr_category', table_name='quick_reply_templates')
op.drop_table('quick_reply_templates')
op.drop_index('idx_fp_scene', table_name='funny_phrases')
op.drop_table('funny_phrases')
op.drop_index('idx_employees_employee_id', table_name='employees')
op.drop_index('idx_employees_corp_id', table_name='employees')
op.drop_table('employees')
op.drop_index('idx_conversations_urgency_score', table_name='conversations')
op.drop_index('idx_conversations_status', table_name='conversations')
op.drop_index('idx_conversations_last_message_at', table_name='conversations')
op.drop_index('idx_conversations_employee_id', table_name='conversations')
op.drop_index('idx_conversations_corp_id', table_name='conversations')
op.drop_index('idx_conversations_assigned_agent', table_name='conversations')
op.drop_table('conversations')
op.drop_index('idx_al_category', table_name='approval_links')
op.drop_table('approval_links')
op.drop_table('agents')
# ### end Alembic commands ###
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — 应用包初始化
# =============================================================================
# 说明:将 app/ 目录标记为 Python 包
# =============================================================================
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — API 包初始化
# =============================================================================
# 说明:将 api/ 目录标记为 Python 包
# =============================================================================
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台角色管理 API
# =============================================================================
# 说明:管理后台的角色管理接口
# 包含:
# 1. 角色管理(CRUD
# 2. 用户角色分配/撤销
# 3. 角色映射规则管理
# 所有接口需要 admin 角色权限
# =============================================================================
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo, require_role
from app.database import get_db
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
from app.schemas.role import (
RoleAssignRequest,
RoleMappingRuleRequest,
RoleMappingRuleResponse,
RoleRevokeRequest,
RoleResponse,
UserRoleResponse,
)
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
"""脱敏处理敏感数据。
Args:
value: 原始值
visible_chars: 开头保留的字符数
Returns:
str: 脱敏后的值,如 "abc***def"
"""
if not value:
return ""
if len(value) <= visible_chars:
return "*" * len(value)
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
# 创建路由器
router = APIRouter(prefix="/admin/roles")
# --------------------------------------------------------------------------
# 管理后台权限校验依赖
# --------------------------------------------------------------------------
async def require_admin(
current_user: UserInfo = Depends(get_current_user),
) -> UserInfo:
"""管理后台权限校验:仅 admin 角色可访问。
Args:
current_user: 当前用户(通过认证依赖注入)
Returns:
UserInfo: 具有管理权限的用户信息
Raises:
AppException: 非管理员角色(错误码 1004)
"""
if "admin" not in current_user.roles:
raise AppException(1004, "无管理权限")
return current_user
# ==========================================================================
# 1. 角色管理
# ==========================================================================
# ---------- GET /api/admin/roles ----------
@router.get("")
async def get_roles(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色列表。
返回角色列表,包含每个角色的用户数量统计。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色列表
"""
# 查询所有角色
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
result = await db.execute(stmt)
roles = result.scalars().all()
# 构建响应,包含用户数量
role_list = []
for role in roles:
# 统计拥有该角色的用户数
count_stmt = select(func.count()).select_from(UserRole).where(UserRole.role_id == role.id)
count_result = await db.execute(count_stmt)
user_count = count_result.scalar() or 0
role_list.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
user_count=user_count,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
return success_response(data=[r.model_dump() for r in role_list])
# ==========================================================================
# 2. 用户角色分配/撤销
# ==========================================================================
# ---------- POST /api/admin/roles/assign ----------
@router.post("/assign")
async def assign_role(
body: RoleAssignRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""手动分配角色。
为指定用户分配角色,记录分配者和分配原因。
安全限制:禁止管理员给自己分配角色。
Args:
body: 分配角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员给自己分配角色
if body.employee_id == admin.employee_id:
raise AppException(4014, "不能给自己分配角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已拥有该角色
existing_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4009, f"用户已拥有 {body.role_name} 角色")
# 创建用户角色关联
user_role = UserRole(
employee_id=body.employee_id,
role_id=role.id,
source="manual",
assigned_by=admin.employee_id,
)
db.add(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(body.employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 分配成功")
# ---------- POST /api/admin/roles/revoke ----------
@router.post("/revoke")
async def revoke_role(
body: RoleRevokeRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""撤销角色。
撤销指定用户的角色。
安全限制:禁止管理员撤销自己的角色。
Args:
body: 撤销角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员撤销自己的角色
if body.employee_id == admin.employee_id:
raise AppException(4015, "不能撤销自己的角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 不允许撤销默认角色
if role.is_default:
raise AppException(4010, "不能撤销默认角色")
# 查询用户角色关联
user_role_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if not user_role:
raise AppException(4011, f"用户没有 {body.role_name} 角色")
# 删除用户角色关联
await db.delete(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 撤销用户 {_mask_sensitive_data(body.employee_id)} 的角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 撤销成功")
# ==========================================================================
# 3. 角色映射规则管理
# ==========================================================================
# ---------- GET /api/admin/roles/mapping-rules ----------
@router.get("/mapping-rules")
async def get_mapping_rules(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色映射规则。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含映射规则列表
"""
# 查询所有映射规则
stmt = (
select(RoleMappingRule, Role)
.join(Role, RoleMappingRule.role_id == Role.id)
.order_by(RoleMappingRule.priority.desc(), RoleMappingRule.source_type)
)
result = await db.execute(stmt)
rules = result.all()
# 构建响应
rule_list = []
for rule, role in rules:
rule_list.append(
RoleMappingRuleResponse(
id=rule.id,
role_id=rule.role_id,
role_name=role.name,
source_type=rule.source_type,
source_value=rule.source_value,
priority=rule.priority,
is_active=rule.is_active,
created_at=rule.created_at,
)
)
return success_response(data=[r.model_dump() for r in rule_list])
# ---------- POST /api/admin/roles/mapping-rules ----------
@router.post("/mapping-rules")
async def create_mapping_rule(
body: RoleMappingRuleRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""创建角色映射规则。
Args:
body: 创建映射规则请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含新创建的规则 ID
"""
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已存在相同的规则
existing_stmt = select(RoleMappingRule).where(
RoleMappingRule.role_id == role.id,
RoleMappingRule.source_type == body.source_type,
RoleMappingRule.source_value == body.source_value,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4012, "已存在相同的映射规则")
# 创建映射规则
rule = RoleMappingRule(
role_id=role.id,
source_type=body.source_type,
source_value=body.source_value,
priority=body.priority,
is_active=body.is_active,
)
db.add(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 创建映射规则:{body.source_type}={body.source_value}{body.role_name}")
return success_response(
message="映射规则创建成功",
data={"id": rule.id},
)
# ---------- DELETE /api/admin/roles/mapping-rules/{rule_id} ----------
@router.delete("/mapping-rules/{rule_id}")
async def delete_mapping_rule(
rule_id: str,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""删除角色映射规则。
Args:
rule_id: 规则 ID
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询规则
rule_stmt = select(RoleMappingRule).where(RoleMappingRule.id == rule_id)
rule_result = await db.execute(rule_stmt)
rule = rule_result.scalars().first()
if not rule:
raise AppException(4013, "映射规则不存在")
# 删除规则
await db.delete(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
return success_response(message="映射规则删除成功")
+215
View File
@@ -0,0 +1,215 @@
# =============================================================================
# 企微IT智能服务台 — 坐席备注 API
# =============================================================================
# 说明:坐席端的备注管理接口,包括:
# 1. GET /api/agent-notes/{employee_id} — 获取员工的所有备注
# 2. POST /api/agent-notes — 添加备注
# 3. PUT /api/agent-notes/{id} — 更新备注
# 4. DELETE /api/agent-notes/{id} — 删除备注
# =============================================================================
import logging
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent_note import AgentNote
from app.models.conversation import Conversation
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# GET /api/agent-notes/{employee_id} — 获取员工的所有备注
# --------------------------------------------------------------------------
@router.get("/agent-notes/{employee_id}")
async def list_agent_notes(
employee_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取员工的所有备注。
通过员工ID查找其所有会话的备注。
用于坐席端用户信息面板展示。
Args:
employee_id: 员工企微 UserID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含备注列表
"""
# 查找该员工所有会话的备注
stmt = (
select(AgentNote)
.join(Conversation, AgentNote.conversation_id == Conversation.id)
.where(Conversation.employee_id == employee_id)
.order_by(AgentNote.created_at.desc())
)
result = await db.execute(stmt)
notes = list(result.scalars().all())
items = [
{
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
for note in notes
]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# POST /api/agent-notes — 添加备注
# --------------------------------------------------------------------------
@router.post("/agent-notes")
async def create_agent_note(
body: dict,
db: AsyncSession = Depends(get_db),
):
"""添加坐席备注。
Args:
body: 备注请求体(包含 conversation_id, agent_id, content
db: 数据库会话
Returns:
Dict: 统一响应格式,包含创建的备注
"""
conversation_id = body.get("conversation_id", "")
agent_id = body.get("agent_id", "")
content = body.get("content", "")
if not conversation_id or not agent_id or not content:
raise AppException(1001, "缺少必要参数: conversation_id, agent_id, content")
# 校验会话存在
try:
conv_uuid = UUID(conversation_id)
except ValueError:
raise AppException(1001, "无效的 conversation_id 格式")
conv_stmt = select(Conversation).where(Conversation.id == conv_uuid)
conv_result = await db.execute(conv_stmt)
if not conv_result.scalars().first():
raise ERR_NOT_FOUND
# 创建备注
note = AgentNote(
conversation_id=conv_uuid,
agent_id=agent_id,
content=content,
)
db.add(note)
await db.flush()
logger.info(f"添加坐席备注: conv_id={conversation_id}, agent={agent_id}")
note_data = {
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
return success_response(data=note_data)
# --------------------------------------------------------------------------
# PUT /api/agent-notes/{id} — 更新备注
# --------------------------------------------------------------------------
@router.put("/agent-notes/{note_id}")
async def update_agent_note(
note_id: UUID,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""更新坐席备注。
Args:
note_id: 备注ID
body: 更新请求体(包含 content
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的备注
"""
# 查找备注
stmt = select(AgentNote).where(AgentNote.id == note_id)
result = await db.execute(stmt)
note = result.scalars().first()
if not note:
raise ERR_NOT_FOUND
# 更新内容
content = body.get("content")
if content is not None:
note.content = content
note.updated_at = datetime.now()
db.add(note)
await db.flush()
logger.info(f"更新坐席备注: id={note_id}")
note_data = {
"id": str(note.id),
"conversation_id": str(note.conversation_id),
"agent_id": note.agent_id,
"content": note.content,
"created_at": note.created_at.isoformat() if note.created_at else "",
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
}
return success_response(data=note_data)
# --------------------------------------------------------------------------
# DELETE /api/agent-notes/{id} — 删除备注
# --------------------------------------------------------------------------
@router.delete("/agent-notes/{note_id}")
async def delete_agent_note(
note_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""删除坐席备注。
Args:
note_id: 备注ID
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查找备注
stmt = select(AgentNote).where(AgentNote.id == note_id)
result = await db.execute(stmt)
note = result.scalars().first()
if not note:
raise ERR_NOT_FOUND
# 物理删除
await db.delete(note)
await db.flush()
logger.info(f"删除坐席备注: id={note_id}")
return success_response(data=None, message="删除成功")
+519
View File
@@ -0,0 +1,519 @@
# =============================================================================
# 企微IT智能服务台 — 坐席管理 API
# =============================================================================
# 说明:坐席端的管理接口,包括:
# 1. POST /api/agents/login — 坐席登录(用户名密码,返回JWT token)
# 2. GET /api/agents/me — 获取当前坐席信息
# 3. PUT /api/agents/me/status — 更新坐席状态(online/busy/offline
# 4. GET /api/agents — 获取坐席列表(用于转接选择)
# 坐席认证使用 JWTtoken 存 RedisTTL 8小时)
# =============================================================================
import base64
import io
import json
import logging
import secrets
from datetime import datetime
from typing import Optional
from uuid import UUID
import pyotp
import qrcode
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Query, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
# 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# JWT 简化版:使用随机 token 存 RedisTTL 8 小时
# 为什么不用标准 JWT:第一步简化实现,token 存 Redis 更容易实现登出和状态管理
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
def _get_redis() -> aioredis.Redis:
"""获取 Redis 客户端。"""
return settings.create_redis_client()
# --------------------------------------------------------------------------
# 坐席认证依赖
# --------------------------------------------------------------------------
async def get_current_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Agent:
"""从请求头中提取坐席身份(认证依赖)。
支持两种 Token 格式:
1. 统一格式:user:token:{token} → JSON 包含 employee_id 和 roles
2. 旧格式:agent:token:{token} → 直接存储 user_id
Args:
authorization: 请求头中的 Authorization 字段(格式:Bearer token
db: 数据库会话
Returns:
Agent: 当前坐席对象
Raises:
AppException: 未授权(token 缺失、无效或过期)
"""
if not authorization:
raise ERR_UNAUTHORIZED
# 提取 token(支持 "Bearer xxx" 格式)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
raise ERR_UNAUTHORIZED
# 从 Redis 查找坐席ID
redis_client = _get_redis()
try:
# 1. 尝试统一格式(新)
unified_data = await redis_client.get(f"user:token:{token}")
if unified_data:
try:
user_info = json.loads(unified_data)
agent_user_id = user_info.get("employee_id")
if agent_user_id:
# 从数据库查找坐席
stmt = select(Agent).where(Agent.user_id == agent_user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if agent:
return agent
except json.JSONDecodeError:
logger.warning(f"统一 Token 数据解析失败: {token[:10]}...")
# 2. 尝试旧格式(兼容)
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
raise ERR_UNAUTHORIZED
# 从数据库查找坐席
# agent_user_id 可能是 bytesRedis 返回)或 str
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
stmt = select(Agent).where(Agent.user_id == uid)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise ERR_UNAUTHORIZED
return agent
except AppException:
# 业务异常直接抛出(如 ERR_UNAUTHORIZED
raise
except Exception as e:
# Redis 连接失败等底层异常
logger.error(f"Redis 读取失败: {e}")
raise ERR_UNAUTHORIZED
finally:
try:
await redis_client.close()
except Exception:
pass
# --------------------------------------------------------------------------
# POST /api/agents/login — 坐席登录
# --------------------------------------------------------------------------
@router.post("/agents/login")
@limiter.limit("10/minute") # 登录接口限流:每IP每分钟最多10次,防暴力破解
async def agent_login(
request: Request,
body: AgentLogin,
db: AsyncSession = Depends(get_db),
):
"""坐席登录。
第一步使用简单的用户名密码登录。
登录成功后生成 token 存入 RedisTTL 8小时)。
流程:
1. 查找坐席记录(按 user_id),不存在则自动创建
2. 生成随机 token
3. token 存 Rediskey: agent:token:{token}, value: user_id
4. 更新坐席状态为 online
5. 返回坐席信息和 token
Args:
body: 登录请求体(包含 user_id 和 name
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席信息和 token
"""
try:
# 0. 企微通讯录身份验证(防止任意 user_id 冒充坐席)
# 调用企微API校验 user_id 是否存在于通讯录中
# 安全策略:
# - 企微验证通过 → 正常登录,用企微真实姓名覆盖前端传入值
# - 企微验证失败(用户不存在) → 拒绝登录
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
wecom_verified = False
try:
redis_client_verify = _get_redis()
try:
wecom_service = WecomService(redis_client_verify)
user_info = await wecom_service.get_user_info(body.user_id)
# 验证通过:用户存在于企微通讯录
wecom_verified = True
# 用企微返回的真实姓名覆盖前端传入的姓名(防止冒用他人身份)
real_name = user_info.get("name", "")
if real_name:
body.name = real_name
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
finally:
try:
await redis_client_verify.close()
except Exception:
pass
try:
await wecom_service.close()
except Exception:
pass
except Exception as wecom_err:
# 企微API不可达时:仅允许已注册坐席降级登录,新注册必须验证
# 原因:网络故障不应阻断已注册坐席工作,但不能让未验证用户注册新账号
logger.warning(
f"企微通讯录验证失败: user_id={body.user_id}, "
f"error={wecom_err}"
)
# 检查是否为已注册坐席(数据库已有记录才允许降级登录)
check_stmt = select(Agent).where(Agent.user_id == body.user_id)
check_result = await db.execute(check_stmt)
existing_agent = check_result.scalars().first()
if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException(
1003,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
)
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
# 首次登录,创建坐席记录
agent = Agent(
user_id=body.user_id,
name=body.name,
status="online",
current_load=0,
max_load=5,
)
db.add(agent)
await db.flush()
logger.info(f"新坐席注册: user_id={body.user_id}, name={body.name}")
else:
# 更新坐席名称(可能改名了)
agent.name = body.name
agent.status = "online"
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
if agent.role == "admin" and agent.otp_enabled == 1:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
"user_id": agent.user_id,
"name": agent.name,
})
else:
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入")
# 3. 生成随机 token(使用统一格式)
from app.services.token_service import TokenService
from app.dependencies import get_redis
# 使用共享 Redis 连接(从连接池获取,不要手动关闭)
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 查询用户角色
from app.services.role_mapping_service import RoleMappingService
role_service = RoleMappingService(db)
roles = await role_service.get_user_roles(body.user_id)
# 创建统一格式的 Token
token = await token_service.create_token(
employee_id=body.user_id,
name=body.name,
roles=roles,
login_source="agent",
)
# 5. 返回坐席信息和 token
agent_data = AgentResponse.model_validate(agent).model_dump()
agent_data["token"] = token
return success_response(data=agent_data)
except AppException:
# 业务异常直接抛出
raise
except Exception as e:
# 未预期的异常:记录日志,返回友好错误
logger.error(f"登录异常: {e}", exc_info=True)
raise AppException(1005, f"登录失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/agents/me — 获取当前坐席信息
# --------------------------------------------------------------------------
@router.get("/agents/me")
async def get_agent_me(
agent: Agent = Depends(get_current_agent),
):
"""获取当前坐席信息。
需要在请求头中携带有效的 token。
Args:
agent: 当前坐席(通过认证依赖注入)
Returns:
Dict: 统一响应格式,包含坐席信息
"""
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# PUT /api/agents/me/status — 更新坐席状态
# --------------------------------------------------------------------------
@router.put("/agents/me/status")
async def update_agent_status(
body: AgentStatusUpdate,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""更新坐席状态。
坐席可以切换为 online/busy/offline。
- online: 在线,可以接收新会话
- busy: 忙碌,不接收新会话但继续处理已有的
- offline: 离线,不接收任何会话
Args:
body: 状态更新请求体
agent: 当前坐席
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的坐席信息
"""
agent.status = body.status
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席状态更新: agent={agent.user_id}, status={body.status}")
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# GET /api/agents — 获取坐席列表(需要 agent 或 admin 角色)
# --------------------------------------------------------------------------
@router.get("/agents")
@require_role("agent", "admin")
async def list_agents(
status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"),
db: AsyncSession = Depends(get_db),
):
"""获取坐席列表。
用于转接选择时展示可用的坐席列表。
Args:
status: 按状态过滤(可选)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席列表
"""
stmt = select(Agent).order_by(Agent.name)
if status:
stmt = stmt.where(Agent.status == status)
result = await db.execute(stmt)
agents = list(result.scalars().all())
items = [AgentResponse.model_validate(a).model_dump() for a in agents]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# OTP 绑定接口
# --------------------------------------------------------------------------
@router.post("/agents/otp-bind")
async def bind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""为当前坐席生成 OTP 密钥和二维码。
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
返回二维码(base64编码)和密钥,供用户手动输入备用。
Returns:
Dict: 二维码图片(base64)和密钥
"""
try:
# 检查是否已绑定
if agent.otp_secret:
# 已绑定,返回现有密钥的二维码
totp = pyotp.TOTP(agent.otp_secret)
else:
# 生成新密钥
secret = pyotp.random_base32()
agent.otp_secret = secret
# otp_enabled 保持 0,等待首次验证后启用
db.add(agent)
await db.flush()
totp = pyotp.TOTP(secret)
# 生成 otpauth:// URI
otpauth_uri = totp.provisioning_uri(
name=f"IT支持服务:{agent.name}",
issuer_name="IT支持服务",
)
# 生成二维码图片
qr = qrcode.make(otpauth_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.otp_secret,
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP绑定异常: {e}", exc_info=True)
raise AppException(1007, f"OTP绑定失败: {str(e)}")
@router.post("/agents/otp-verify")
async def verify_agent_otp(
body: AgentLogin, # 复用 AgentLoginotp_code 为必填
db: AsyncSession = Depends(get_db),
):
"""验证并启用 OTP。
用户输入 OTP 码验证成功后,启用 OTP。
首次验证成功后 otp_enabled 设为 1。
Args:
body.otp_code: 用户输入的 OTP 码(必填)
Returns:
Dict: 验证结果
"""
try:
# 查找坐席
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent or not agent.otp_secret:
raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 OTP
agent.otp_enabled = 1
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={
"otp_enabled": True,
"message": "OTP验证成功,已启用",
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP验证异常: {e}", exc_info=True)
raise AppException(1009, f"OTP验证失败: {str(e)}")
@router.post("/agents/otp-unbind")
async def unbind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""解绑 OTP。
解绑后 otp_secret 和 otp_enabled 都清空。
需要管理员操作。
Returns:
Dict: 解绑结果
"""
try:
agent.otp_secret = None
agent.otp_enabled = 0
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP解绑: agent={agent.user_id}")
return success_response(data={"message": "OTP已解绑"})
except AppException:
raise
except Exception as e:
logger.error(f"OTP解绑异常: {e}", exc_info=True)
raise AppException(1010, f"OTP解绑失败: {str(e)}")
+688
View File
@@ -0,0 +1,688 @@
# =============================================================================
# 企微IT智能服务台 — 会话管理 API
# =============================================================================
# 说明:坐席端的会话管理接口,包括:
# 1. GET /api/conversations — 坐席获取会话列表(支持状态过滤、排序)
# 2. GET /api/conversations/{id} — 获取会话详情
# 3. POST /api/conversations/{id}/assign — 接单(坐席接入会话)
# 4. POST /api/conversations/{id}/resolve — 结单
# 5. POST /api/conversations/{id}/pin — 置顶/取消置顶
# 6. POST /api/conversations/{id}/todo — 代办/取消代办
# 7. POST /api/conversations/{id}/transfer — 转接
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.schemas.conversation import (
ConversationAssign,
ConversationInvite,
ConversationListResponse,
ConversationResponse,
ConversationStatusUpdate,
InviteParticipantRequest,
JoinConversationRequest,
)
from app.services.session_service import SessionService
from app.services.wecom_service import WecomService
from app.utils.response import AppException, success_response
# 坐席认证依赖(从 agents.py 导入)
from app.api.agents import get_current_agent
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# GET /api/conversations — 获取坐席会话列表(全局可见)
# --------------------------------------------------------------------------
@router.get("/conversations")
async def list_conversations(
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
page: int = Query(1, ge=1, description="页码(从1开始)"),
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席获取会话列表(全局可见)。
返回所有活跃会话,每个会话增加字段:
- is_mine: 是否为当前坐席的会话
- assigned_agent_name: 分配的坐席姓名(其他坐席会话显示用)
- can_grab: 是否可以接手(其他坐席已接单的会话为 True)
排序规则:紧急→举手→需介入→活跃→已结单。
Args:
status: 按状态过滤(可选)
agent_id: 按坐席ID过滤(可选)
page: 页码
page_size: 每页数量
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含会话列表和总数
"""
session_service = SessionService(db)
conversations, total = await session_service.get_conversations(
status=status,
agent_id=agent_id,
page=page,
page_size=page_size,
)
# 批量查询所有涉及坐席的信息,避免 N+1 查询
# 收集所有需要查询姓名的坐席ID(主责坐席 + 协作坐席)
agent_ids_to_query = set()
for conv in conversations:
if conv.assigned_agent_id:
agent_ids_to_query.add(conv.assigned_agent_id)
for aid in (conv.collaborating_agent_ids or []):
agent_ids_to_query.add(aid)
# 一次性查询所有相关坐席姓名
agent_name_map: dict[str, str] = {}
if agent_ids_to_query:
stmt = select(Agent).where(Agent.user_id.in_(agent_ids_to_query))
result = await db.execute(stmt)
for agent in result.scalars().all():
agent_name_map[agent.user_id] = agent.name
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
items = []
for conv in conversations:
conv_data = ConversationResponse.model_validate(conv).model_dump()
# 是否为当前坐席的会话
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
# 坐席姓名(从批量查询结果中获取)
conv_data["assigned_agent_name"] = agent_name_map.get(conv.assigned_agent_id) if conv.assigned_agent_id else None
# 是否可以接手:其他坐席已接单(assigned 且不是自己的)
conv_data["can_grab"] = (
conv.assigned_agent_id is not None
and conv.assigned_agent_id != current_agent.user_id
and conv.status == "serving"
)
# ----- 多坐席协作扩展字段 -----
# 协作坐席ID列表
collab_ids = conv.collaborating_agent_ids or []
conv_data["collaborating_agent_ids"] = collab_ids
# 协作坐席姓名映射
conv_data["collaborating_agent_names"] = {
aid: agent_name_map.get(aid, "未知") for aid in collab_ids
}
# 是否为协作坐席(在协作列表中但不是主责坐席)
conv_data["is_collaborator"] = (
current_agent.user_id in collab_ids
and conv.assigned_agent_id != current_agent.user_id
)
items.append(conv_data)
return success_response(
data={
"items": items,
"total": total,
}
)
# --------------------------------------------------------------------------
# GET /api/conversations/{id} — 获取会话详情
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取会话详情。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含会话详情
"""
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/assign — 坐席接单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/assign")
async def assign_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""坐席接单(接入会话)。
坐席点击"接单"按钮时调用,将会话状态从 queued 改为 serving。
Args:
conversation_id: 会话ID
body: 接单请求体(包含 agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例用于发送接入通知
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,接入通知将不发送")
session_service = SessionService(db)
conversation = await session_service.assign_agent(
conversation_id=conversation_id,
agent_id=body.agent_id,
)
# 关闭企微服务连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/resolve — 结单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/resolve")
async def resolve_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""结单。
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
权限控制:只有主责坐席(assigned_agent_id)才能结单。
协作坐席和其他坐席不能结单。
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
# 先查询会话,验证主责坐席身份
from sqlalchemy import select as _select
from app.models.conversation import Conversation as _Conversation
stmt = _select(_Conversation).where(_Conversation.id == conversation_id)
result = await db.execute(stmt)
conv = result.scalars().first()
if not conv:
raise AppException(3003, "会话不存在")
if conv.assigned_agent_id != current_agent.user_id:
raise AppException(3027, "只有主责坐席才能结单")
conversation = await session_service.resolve_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/pin — 置顶/取消置顶
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/pin")
async def toggle_pin(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话置顶状态。
每次调用切换当前状态:置顶→取消置顶,取消置顶→置顶。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_pin(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/todo — 代办/取消代办
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/todo")
async def toggle_todo(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话代办状态。
每次调用切换当前状态:代办→取消代办,取消代办→代办。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_todo(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/transfer — 转接
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/transfer")
async def transfer_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""转接会话到另一个坐席。
第一步简化版:只更换坐席,不做转接通知。
Args:
conversation_id: 会话ID
body: 转接请求体(包含 target agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.transfer_conversation(
conversation_id=conversation_id,
target_agent_id=body.agent_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/grab — 接手会话(抢单)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/grab")
async def grab_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""接手其他坐席的会话(抢单)。
接手后原坐席自动释放,会话 assigned_agent_id 切换为当前坐席。
验证规则:
1. 会话必须已分配给其他坐席(不能接手自己的,不能接手未分配的)
2. 当前坐席未满负荷
3. 会话状态为 serving
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含接手后的会话信息
"""
# 1. 查找目标会话
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
# 2. 校验:会话必须已分配给其他坐席
if not conversation.assigned_agent_id:
raise AppException(3011, "该会话尚未分配坐席,请使用接单功能")
if conversation.assigned_agent_id == current_agent.user_id:
raise AppException(3012, "不能接手自己的会话")
if conversation.status == "resolved":
raise AppException(3002, "会话已结单")
if conversation.status != "serving":
raise AppException(3013, f"只能接手服务中的会话,当前状态: {conversation.status}")
# 3. 校验当前坐席未满负荷
# 刷新坐席数据(current_agent 可能是缓存的旧数据)
stmt = select(Agent).where(Agent.user_id == current_agent.user_id)
result = await db.execute(stmt)
fresh_agent = result.scalars().first()
if fresh_agent and fresh_agent.current_load >= fresh_agent.max_load:
raise AppException(3005, "您已满负荷,无法接手更多会话")
# 4. 原坐席 current_load 减 1
old_agent_id = conversation.assigned_agent_id
stmt = select(Agent).where(Agent.user_id == old_agent_id)
result = await db.execute(stmt)
old_agent = result.scalars().first()
if old_agent and old_agent.current_load > 0:
old_agent.current_load -= 1
db.add(old_agent)
# 5. 更新会话 assigned_agent_id 为当前坐席
conversation.assigned_agent_id = current_agent.user_id
conversation.updated_at = datetime.now()
db.add(conversation)
# 6. 当前坐席 current_load 加 1
if fresh_agent:
fresh_agent.current_load += 1
db.add(fresh_agent)
await db.flush()
logger.info(
f"会话接手: conv_id={conversation_id}, "
f"from={old_agent_id} to={current_agent.user_id}"
)
# 7. WS 广播 conversation_updated 事件(原坐席和当前坐席都能收到)
from app.services.ws_manager import manager as ws_manager
try:
await ws_manager.broadcast({
"type": "conversation_updated",
"data": {
"conversation_id": str(conversation.id),
"status": conversation.status,
"assigned_agent_id": conversation.assigned_agent_id,
"old_agent_id": old_agent_id,
"new_agent_id": current_agent.user_id,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败: {e}")
# 8. 返回接手成功的会话信息
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = True
response_data["assigned_agent_name"] = current_agent.name
response_data["can_grab"] = False
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite")
async def invite_collaborator(
conversation_id: str,
body: ConversationInvite,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席A邀请坐席B加入会话协作。
校验规则:
1. 当前坐席必须是主责坐席或已加入的协作坐席
2. 被邀请坐席存在且在线
3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请)
4. 会话必须为 serving(已结单的不能摇人)
副作用:
- WebSocket 推送给被邀请坐席(collaborator_invited 定向通知)
- WebSocket 广播给所有坐席(collaborator_joined 刷新列表)
Args:
conversation_id: 会话ID
body: 邀请请求(含 agent_id
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.invite_collaborator(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
invitee_agent_id=body.agent_id,
)
# 构建响应
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = conversation.assigned_agent_id == current_agent.user_id
response_data["is_collaborator"] = False # 邀请人自己不是被邀请的协作坐席
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave — 退出协作
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave")
async def leave_collaboration(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席退出协作。
校验规则:
1. 当前坐席必须在协作列表中
2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单)
副作用:
- WebSocket 广播给所有坐席(collaborator_left 刷新列表)
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_collaboration(
conversation_id=conversation_id,
agent_id=current_agent.user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# =============================================================================
# 邀请功能 APIP0-09~P0-11
# =============================================================================
# 和「摇人」的区别:
# 摇人 (invite) = 坐席 → 坐席协作(collaborating_agent_ids
# 邀请 (invite-participant) = 坐席 → 任意员工/部门(participants
# =============================================================================
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite-participant")
async def invite_participant(
conversation_id: str,
body: InviteParticipantRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席邀请员工/部门加入会话(P0-09 邀请发起)。
权限:只有主责坐席可以发起邀请。
副作用:
- 向被邀请人发送企微卡片通知(含「加入会话」按钮)
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 邀请请求(含被邀请人列表 + 历史共享模式)
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例(发送卡片通知用)
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,邀请通知将不发送")
session_service = SessionService(db)
conversation = await session_service.invite_participants(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
participants=[p.model_dump() for p in body.participants],
history_mode=body.history_mode,
)
# 关闭连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/join — 被邀请人加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/join")
async def join_conversation(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""被邀请人通过链接加入会话(P0-10 加入会话)。
校验:该员工必须在 participants 列表中(被邀请过才能加入)。
副作用:
- 更新参与者的 joined 状态
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 加入请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.join_conversation(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
# --------------------------------------------------------------------------
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
async def remove_participant(
conversation_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""移除参与者(P0-11 参与者管理)。
权限:只有主责坐席可以移除参与者。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
user_id: 被移除的员工UserID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.remove_participant(
conversation_id=conversation_id,
remover_agent_id=current_agent.user_id,
target_user_id=user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave-participant")
async def leave_as_participant(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""参与者主动退出会话。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 退出请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_as_participant(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
+116
View File
@@ -0,0 +1,116 @@
# =============================================================================
# 企微IT智能服务台 — 员工 API
# =============================================================================
# 说明:提供员工相关的管理接口
# 接口列表:
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
# =============================================================================
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator
from app.utils.response import success_response
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
# 创建路由器
router = APIRouter(prefix="/employees", tags=["员工管理"])
# --------------------------------------------------------------------------
# 请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
class ItLevelUpdateResponse(BaseModel):
"""IT技能等级更新响应 Schema。"""
employee_id: str
it_level: str
it_level_source: str
message: str
# --------------------------------------------------------------------------
# Mock 员工数据存储(IT 等级映射)
# --------------------------------------------------------------------------
# 简单的内存存储,key 为 employee_idvalue 为 it_level
MOCK_EMPLOYEE_IT_LEVELS: dict = {
"emp-001": "silver",
"emp-002": "gold",
"emp-003": "bronze",
"emp-004": "platinum",
"emp-005": "diamond",
"emp-006": "silver",
"emp-007": "star",
"emp-008": "king",
}
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.put("/{employee_id}/it-level")
async def update_employee_it_level(
employee_id: str,
request: ItLevelUpdateRequest,
):
"""更新员工IT技能等级。
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
更新后等级立即生效,并记录来源以便追溯。
Args:
employee_id: 员工ID
request: 等级更新请求
Returns:
更新结果
"""
# 更新内存中的等级
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
# 构造等级名称映射
level_names = {
"bronze": "青铜",
"silver": "白银",
"gold": "黄金",
"platinum": "铂金",
"diamond": "钻石",
"star": "星耀",
"king": "王者",
}
return success_response(data=ItLevelUpdateResponse(
employee_id=employee_id,
it_level=request.it_level,
it_level_source=request.source,
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
).model_dump())
File diff suppressed because it is too large Load Diff
+556
View File
@@ -0,0 +1,556 @@
# =============================================================================
# 企微IT智能服务台 — 消息管理 API
# =============================================================================
# 说明:坐席端的消息管理接口,包括:
# 1. GET /api/conversations/{id}/messages — 获取会话消息列表(分页)
# 2. POST /api/conversations/{id}/messages — 坐席发送消息
# 3. GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# 4. POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# 5. DELETE /api/messages/{id} — 删除消息
# 6. POST /api/conversations/{id}/mark-read — 标记已读
# 7. POST /api/messages/image — 上传图片
# 8. POST /api/messages/file — 上传文件
# 消息发送需同时:存数据库 + 调用企微API发送给员工
# =============================================================================
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.schemas.message import MessageCreate, MessageResponse
from app.api.agents import get_current_agent
from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 文件大小限制:10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# 可撤回时间窗口:2分钟
RECALLABLE_WINDOW_MINUTES = 2
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages — 获取会话消息列表
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
db: AsyncSession = Depends(get_db),
):
"""获取会话消息列表(分页)。
支持向上加载历史消息(通过 before 参数指定消息ID)。
默认返回最新的 limit 条消息。
Args:
conversation_id: 会话ID
limit: 每页消息数量
before: 加载此消息ID之前的消息(向上翻页)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含消息列表和是否还有更多消息
"""
# 校验会话存在(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
# 构建查询
stmt = select(Message).where(
Message.conversation_id == conv_id_str
).order_by(Message.created_at.desc())
# 如果指定了 before,只加载该消息之前的消息
if before:
try:
before_uuid = str(UUID(before))
# 先获取 before 消息的创建时间
before_stmt = select(Message.created_at).where(Message.id == before_uuid)
before_result = await db.execute(before_stmt)
before_time = before_result.scalar_one_or_none()
if before_time:
stmt = stmt.where(Message.created_at < before_time)
except ValueError:
pass # before 参数格式错误,忽略
# 限制数量
stmt = stmt.limit(limit + 1) # 多查一条判断是否还有更多
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 判断是否还有更多消息
has_more = len(messages) > limit
if has_more:
messages = messages[:limit] # 去掉多查的那一条
# 按时间正序排列(最早的在前)
messages.reverse()
# 标记消息为已读(坐席查看时自动标记)
for msg in messages:
if not msg.is_read and msg.sender_type == "employee":
msg.is_read = True
await db.flush()
# 转换为响应格式
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
return success_response(
data={
"items": items,
"has_more": has_more,
}
)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/messages — 坐席发送消息
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/messages")
async def send_message(
conversation_id: str,
body: MessageCreate,
db: AsyncSession = Depends(get_db),
):
"""坐席发送消息。
流程:
1. 校验会话存在且未结单
2. 将消息存入 messages 表
3. 调用企微 API 发送消息给员工
4. 更新会话的最后消息信息
Args:
conversation_id: 会话ID
body: 消息请求体(包含 content 和 msg_type
db: 数据库会话
Returns:
Dict: 统一响应格式,包含发送的消息对象
"""
# 1. 校验会话(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
if conversation.status == "resolved":
raise ERR_CONVERSATION_RESOLVED
# 2. 创建消息记录
# 从会话的 assigned_agent_id 获取坐席信息
agent_id = conversation.assigned_agent_id or "unknown"
# 计算可撤回截止时间
recallable_until = datetime.now() + timedelta(minutes=RECALLABLE_WINDOW_MINUTES)
message = Message(
conversation_id=conv_id_str,
sender_type="agent",
sender_id=agent_id,
sender_name="", # 坐席姓名,后续从坐席信息补充
content=body.content,
msg_type=body.msg_type,
# M1 新增:文件上传相关字段
media_url=body.media_url,
file_name=body.file_name,
file_size=body.file_size,
# M1 新增:引用回复
reply_to_id=body.reply_to_id,
status="sending", # 初始状态为发送中
recallable_until=recallable_until,
is_read=True, # 坐席自己发的消息默认已读
)
db.add(message)
# 3. 更新会话最后消息信息
conversation.last_message_at = datetime.now()
conversation.last_message_summary = body.content[:256]
conversation.updated_at = datetime.now()
db.add(conversation)
await db.flush() # 刷新以获取消息 ID
# 4. 调用企微 API 发送消息给员工
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text":
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
await wecom_service.send_text_message(
conversation.employee_id, body.content
)
await wecom_service.close()
await redis_client.close()
except Exception as e:
# 企微 API 调用失败不阻塞消息存储
logger.warning(f"企微消息发送失败(消息已存储): {e}")
# 5. 更新消息状态为已发送
message.status = "sent"
await db.flush()
# 转换为响应格式
response_data = MessageResponse.model_validate(message).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages/poll")
async def poll_messages(
conversation_id: str,
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
db: AsyncSession = Depends(get_db),
):
"""坐席轮询新消息。
前端每 3-5 秒调用一次,获取上次轮询后的新消息。
Args:
conversation_id: 会话ID
after_message_id: 上次轮询的最后一消息ID(返回此之后的消息)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含新消息列表
"""
# 构建查询(UUID 转为字符串,兼容 SQLite String(36) 列)
conv_id_str = str(conversation_id)
stmt = select(Message).where(
Message.conversation_id == conv_id_str
).order_by(Message.created_at.asc())
# 如果指定了 after_message_id,只返回该ID之后的消息
if after_message_id:
try:
# 获取 after_message 的创建时间
# 注意:确保用字符串比较,避免SQLAlchemy把参数转成UUID导致类型不匹配
after_stmt = select(Message.created_at).where(
Message.id == str(after_message_id)
)
after_result = await db.execute(after_stmt)
after_time = after_result.scalar_one_or_none()
if after_time:
stmt = stmt.where(Message.created_at > after_time)
except Exception:
pass # 参数格式错误或查询失败,忽略
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 标记员工消息为已读
for msg in messages:
if not msg.is_read and msg.sender_type == "employee":
msg.is_read = True
await db.flush()
# 转换为响应格式
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
return success_response(
data={
"items": items,
"has_more": False, # 轮询接口不需要分页
}
)
# --------------------------------------------------------------------------
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# --------------------------------------------------------------------------
@router.post("/messages/{message_id}/recall")
async def recall_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""撤回消息(2分钟内)。
仅可撤回2分钟内坐席自己发送的消息。
P0-2 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可调用此端点修改任意消息。
现在依赖 get_current_agent 校验登录态,再校验 message.sender_id
是否等于当前坐席的 user_id,防止越权撤回他人消息。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# 校验是否是坐席发送的消息
if message.sender_type != "agent":
raise AppException(code=403, message="只能撤回坐席发送的消息")
# P0-2 修复:校验是否是当前坐席自己发的
if message.sender_id != agent.user_id:
raise AppException(code=403, message="只能撤回自己的消息")
# 校验是否在可撤回时间窗口内
if message.recallable_until and datetime.now() > message.recallable_until:
raise AppException(code=403, message="消息已超过2分钟,无法撤回")
# 将消息内容置为空,表示已撤回
message.content = "[消息已撤回]"
message.status = "recalled"
await db.flush()
return success_response(message="消息撤回成功")
# --------------------------------------------------------------------------
# DELETE /api/messages/{id} — 删除消息
# --------------------------------------------------------------------------
@router.delete("/messages/{message_id}")
async def delete_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""删除坐席自己发送的消息。
P0-3 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可删除任意消息。
现在依赖 get_current_agent 校验登录态,再校验消息是否属于当前坐席,
防止越权删除他人/会话历史。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# P0-3 修复:仅允许坐席删除自己发送的消息
if message.sender_type != "agent" or message.sender_id != agent.user_id:
raise AppException(code=403, message="只能删除自己发送的消息")
# 删除消息
await db.delete(message)
await db.flush()
return success_response(message="消息删除成功")
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/mark-read — 标记已读
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/mark-read")
async def mark_read(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""标记会话中所有员工未读消息为已读。
P0-4 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可标记任意会话为已读,
会破坏"未读消息数"业务统计。
现在依赖 get_current_agent 校验登录态,再校验当前坐席是会话的
主责或协作坐席才允许标记,防止越权篡改未读状态。
P2-3 修复:原 `.where(Message.is_read == False)` 是 Python 表达式比较
永远为 False(不抛错但实际未过滤),SQLAlchemy 也会当成赋值表达式
处理;改为 `is_(False)` 走 SQL 否定。
Args:
conversation_id: 会话ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
conv_id_str = str(conversation_id)
# P0-4 修复:先校验当前坐席有权访问此会话
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if not conversation:
raise ERR_CONVERSATION_NOT_FOUND
is_assigned = conversation.assigned_agent_id == agent.user_id
is_collaborator = agent.user_id in (conversation.collaborating_agent_ids or [])
if not (is_assigned or is_collaborator):
raise AppException(code=403, message="您不是该会话的坐席,无权操作")
# P2-3 修复:使用 is_(False) 而非 == False
# 更新该会话的所有员工未读消息为已读
stmt = (
update(Message)
.where(Message.conversation_id == conv_id_str)
.where(Message.sender_type == "employee")
.where(Message.is_read.is_(False))
.values(is_read=True, status="read")
)
await db.execute(stmt)
await db.flush()
return success_response(message="标记已读成功")
# --------------------------------------------------------------------------
# POST /api/messages/image — 上传图片
# --------------------------------------------------------------------------
@router.post("/messages/image")
async def upload_image(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传图片文件。
文件大小限制:10MB
Args:
file: 图片文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校验文件大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 校验文件类型
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
content_type = file.content_type
if content_type not in allowed_types:
raise AppException(code=400, message="不支持的图片格式")
# 生成保存路径
import uuid as uuid_module
file_ext = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "images")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/images/{file_name}"
return success_response(
data={
"url": file_url,
"filename": file_name,
"file_size": file_size,
"content_type": content_type,
}
)
# --------------------------------------------------------------------------
# POST /api/messages/file — 上传文件
# --------------------------------------------------------------------------
@router.post("/messages/file")
async def upload_message_file(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传普通文件。
文件大小限制:10MB
Args:
file: 文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 生成保存路径
import uuid as uuid_module
original_name = file.filename or "file"
file_ext = os.path.splitext(original_name)[1]
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "files")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/files/{file_name}"
return success_response(
data={
"url": file_url,
"filename": original_name,
"file_size": file_size,
"content_type": file.content_type,
}
)
+249
View File
@@ -0,0 +1,249 @@
# =============================================================================
# 企微IT智能服务台 — Portal 统一入口 API
# =============================================================================
# 说明:统一入口(Portal)相关接口
# 包含:
# 1. 获取当前用户角色信息
# 2. 切换当前角色
# 3. 获取角色对应的入口 URL
# 所有接口需要有效的 Bearer Token
# =============================================================================
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo
from app.config import settings
from app.database import get_db
from app.models.role import Role
from app.models.user_role import UserRole
from app.schemas.role import (
PortalUserInfo,
RoleResponse,
SwitchRoleRequest,
SwitchRoleResponse,
)
from app.services.token_service import TokenService
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# HTTP Bearer 认证方案
security = HTTPBearer()
# 创建路由器
router = APIRouter(prefix="/portal")
# --------------------------------------------------------------------------
# 获取当前用户角色信息
# --------------------------------------------------------------------------
@router.get("/roles")
async def get_user_roles(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的角色信息。
返回用户的基本信息和角色列表,用于路由选择页展示。
Args:
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含用户信息和角色列表
"""
# 查询用户拥有的角色
stmt = (
select(Role, UserRole)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(
# 过滤已过期的角色
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > func.now())
)
)
result = await db.execute(stmt)
role_rows = result.all()
# 构建角色列表
roles = []
for role, user_role in role_rows:
roles.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
# 如果用户没有任何角色,添加默认的 user 角色
if not roles:
# 查询 user 角色
user_role_stmt = select(Role).where(Role.name == "user")
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if user_role:
roles.append(
RoleResponse(
id=user_role.id,
name=user_role.name,
display_name=user_role.display_name,
description=user_role.description,
permissions=user_role.permissions or [],
is_default=user_role.is_default,
created_at=user_role.created_at,
updated_at=user_role.updated_at,
)
)
# 构建响应
user_info = PortalUserInfo(
employee_id=current_user.employee_id,
name=current_user.name,
department=current_user.department,
avatar=current_user.avatar,
roles=roles,
current_role=current_user.current_role,
)
return success_response(data=user_info.model_dump())
# --------------------------------------------------------------------------
# 切换当前角色
# --------------------------------------------------------------------------
@router.post("/switch-role")
async def switch_role(
body: SwitchRoleRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""切换当前角色。
更新 Redis Token 中的 current_role 字段,返回目标角色的入口 URL。
Args:
body: 切换角色请求
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含切换后的角色和重定向 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == body.new_role)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {body.new_role} 角色权限")
# 更新 Redis Token 中的 current_role
from app.dependencies import get_redis
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 从请求头获取 token
token = credentials.credentials
switch_success = await token_service.switch_role(token, body.new_role)
if not switch_success:
raise AppException(4003, "角色切换失败")
# 获取目标角色的入口 URL
redirect_url = _get_role_url(body.new_role)
logger.info(f"用户 {current_user.employee_id} 切换角色到 {body.new_role}")
return success_response(
data=SwitchRoleResponse(
current_role=body.new_role,
redirect_url=redirect_url,
).model_dump()
)
# --------------------------------------------------------------------------
# 获取角色对应的入口 URL
# --------------------------------------------------------------------------
@router.get("/entry/{role_name}")
async def get_role_entry(
role_name: str,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取角色对应的入口 URL。
Args:
role_name: 角色标识
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色信息和入口 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == role_name)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {role_name} 角色权限")
# 获取入口 URL
redirect_url = _get_role_url(role_name)
return success_response(
data={
"role": role_name,
"url": redirect_url,
"display_name": target_role.display_name,
}
)
# --------------------------------------------------------------------------
# 辅助函数:获取角色对应的 URL
# --------------------------------------------------------------------------
def _get_role_url(role_name: str) -> str:
"""获取角色对应的前端 URL。
Args:
role_name: 角色标识
Returns:
str: 前端 URL
"""
role_urls = {
"user": "/itdesk/",
"agent": "/itagent/",
"admin": "/itadmin/",
}
return role_urls.get(role_name, "/itdesk/")
+256
View File
@@ -0,0 +1,256 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板 API
# =============================================================================
# 说明:坐席端的快速回复模板管理接口,包括:
# 1. GET /api/quick-replies — 获取模板列表(按分类)
# 2. POST /api/quick-replies — 创建模板
# 3. PUT /api/quick-replies/{id} — 更新模板
# 4. DELETE /api/quick-replies/{id} — 删除模板
# =============================================================================
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import or_, and_
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.quick_reply_template import QuickReplyTemplate
from app.schemas.quick_reply import (
QuickReplyCreate,
QuickReplyResponse,
QuickReplyUpdate,
)
from app.utils.response import AppException, ERR_NOT_FOUND, ERR_UNAUTHORIZED, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 可选坐席认证(有 token 则认证,无 token 则跳过)
# --------------------------------------------------------------------------
async def get_optional_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Optional[Agent]:
"""可选坐席认证依赖。
有 Authorization 头时尝试认证,无或认证失败时返回 None。
Args:
authorization: 请求头中的 Authorization 字段
db: 数据库会话
Returns:
Optional[Agent]: 认证成功返回坐席对象,否则返回 None
"""
if not authorization:
return None
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
return None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
try:
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
return None
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
stmt = select(Agent).where(Agent.user_id == uid)
result = await db.execute(stmt)
agent = result.scalars().first()
return agent
finally:
try:
await redis_client.close()
except Exception:
pass
except Exception as e:
logger.warning(f"可选坐席认证失败: {e}")
return None
# --------------------------------------------------------------------------
# GET /api/quick-replies — 获取模板列表
# --------------------------------------------------------------------------
@router.get("/quick-replies")
async def list_quick_replies(
category: Optional[str] = Query(None, description="按分类过滤: 账号/网络/软件/硬件/通用"),
db: AsyncSession = Depends(get_db),
agent: Optional[Agent] = Depends(get_optional_agent),
):
"""获取快速回复模板列表。
支持按分类过滤,按 sort_order 排序。
坐席端可见性规则:
- 有认证:返回 approved + 自己的 pending_review
- 无认证:只返回 approved
Args:
category: 按分类过滤(可选)
db: 数据库会话
agent: 当前坐席(可选认证)
Returns:
Dict: 统一响应格式,包含模板列表
"""
stmt = select(QuickReplyTemplate).order_by(
QuickReplyTemplate.category, QuickReplyTemplate.sort_order
)
if category:
stmt = stmt.where(QuickReplyTemplate.category == category)
# 状态筛选:坐席端可见性规则
if agent:
# 有认证:approved + 自己的 pending_review
stmt = stmt.where(
or_(
QuickReplyTemplate.status == "approved",
and_(
QuickReplyTemplate.status == "pending_review",
QuickReplyTemplate.submitted_by == agent.id,
),
)
)
else:
# 无认证:只返回 approved
stmt = stmt.where(QuickReplyTemplate.status == "approved")
result = await db.execute(stmt)
templates = list(result.scalars().all())
items = [QuickReplyResponse.model_validate(t).model_dump() for t in templates]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# POST /api/quick-replies — 创建模板
# --------------------------------------------------------------------------
@router.post("/quick-replies")
async def create_quick_reply(
body: QuickReplyCreate,
db: AsyncSession = Depends(get_db),
):
"""创建快速回复模板。
Args:
body: 创建请求体(包含 category、title、content、variables、sort_order
db: 数据库会话
Returns:
Dict: 统一响应格式,包含创建的模板
"""
template = QuickReplyTemplate(
category=body.category,
title=body.title,
content=body.content,
variables=body.variables,
sort_order=body.sort_order,
)
db.add(template)
await db.flush()
logger.info(f"创建快速回复模板: category={body.category}, title={body.title}")
template_data = QuickReplyResponse.model_validate(template).model_dump()
return success_response(data=template_data)
# --------------------------------------------------------------------------
# PUT /api/quick-replies/{id} — 更新模板
# --------------------------------------------------------------------------
@router.put("/quick-replies/{template_id}")
async def update_quick_reply(
template_id: UUID,
body: QuickReplyUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新快速回复模板。
只更新传入的字段(部分更新)。
Args:
template_id: 模板ID
body: 更新请求体(所有字段可选)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的模板
"""
# 查找模板
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
result = await db.execute(stmt)
template = result.scalars().first()
if not template:
raise ERR_NOT_FOUND
# 只更新传入的字段
if body.category is not None:
template.category = body.category
if body.title is not None:
template.title = body.title
if body.content is not None:
template.content = body.content
if body.variables is not None:
template.variables = body.variables
if body.sort_order is not None:
template.sort_order = body.sort_order
db.add(template)
await db.flush()
logger.info(f"更新快速回复模板: id={template_id}")
template_data = QuickReplyResponse.model_validate(template).model_dump()
return success_response(data=template_data)
# --------------------------------------------------------------------------
# DELETE /api/quick-replies/{id} — 删除模板
# --------------------------------------------------------------------------
@router.delete("/quick-replies/{template_id}")
async def delete_quick_reply(
template_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""删除快速回复模板。
第一步使用物理删除。
Args:
template_id: 模板ID
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查找模板
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
result = await db.execute(stmt)
template = result.scalars().first()
if not template:
raise ERR_NOT_FOUND
# 物理删除
await db.delete(template)
await db.flush()
logger.info(f"删除快速回复模板: id={template_id}")
return success_response(data=None, message="删除成功")
+157
View File
@@ -0,0 +1,157 @@
# =============================================================================
# 企微IT智能服务台 — API 路由汇总
# =============================================================================
# 说明:汇总所有 API 子路由,统一挂载到 FastAPI 应用
# T02 阶段注册所有后端核心服务路由
# =============================================================================
from fastapi import APIRouter
# 导入各子路由模块
from app.api.wecom_callback import router as wecom_router
from app.api.conversations import router as conversations_router
from app.api.messages import router as messages_router
from app.api.agents import router as agents_router
from app.api.quick_replies import router as quick_replies_router
from app.api.h5 import router as h5_router
from app.api.agent_notes import router as agent_notes_router
from app.api.system import router as system_router
from app.api.wingman import router as wingman_router
from app.api.todo_items import router as todo_items_router
from app.api.troubleshooting_templates import router as troubleshooting_templates_router
from app.api.employees import router as employees_router
from app.api.upload import router as upload_router
from app.api.admin import router as admin_router
from app.api.portal import router as portal_router
from app.api.admin_roles import router as admin_roles_router
# 创建 API 路由器
# 所有子路由都会挂载到这个路由器上
api_router = APIRouter()
# --------------------------------------------------------------------------
# 注册所有子路由
# --------------------------------------------------------------------------
# 每个子路由都有对应的 prefix 和 tags,方便 Swagger 文档分类展示
# --------------------------------------------------------------------------
# 企微回调 API
# GET /api/wecom/callback — 验证URL有效性
# POST /api/wecom/callback — 接收企微推送消息
api_router.include_router(wecom_router, tags=["企微回调"])
# 会话管理 API
# GET /api/conversations — 获取会话列表
# GET /api/conversations/{id} — 获取会话详情
# POST /api/conversations/{id}/assign — 坐席接单
# POST /api/conversations/{id}/resolve — 结单
# POST /api/conversations/{id}/pin — 置顶/取消置顶
# POST /api/conversations/{id}/todo — 代办/取消代办
# POST /api/conversations/{id}/transfer — 转接
api_router.include_router(conversations_router, tags=["会话管理"])
# 消息管理 API
# GET /api/conversations/{id}/messages — 获取消息列表
# POST /api/conversations/{id}/messages — 坐席发送消息
# GET /api/conversations/{id}/messages/poll — 轮询新消息
api_router.include_router(messages_router, tags=["消息管理"])
# 坐席管理 API
# POST /api/agents/login — 坐席登录
# GET /api/agents/me — 获取当前坐席信息
# PUT /api/agents/me/status — 更新坐席状态
# GET /api/agents — 获取坐席列表
api_router.include_router(agents_router, tags=["坐席管理"])
# 快速回复模板 API
# GET /api/quick-replies — 获取模板列表
# POST /api/quick-replies — 创建模板
# PUT /api/quick-replies/{id} — 更新模板
# DELETE /api/quick-replies/{id} — 删除模板
api_router.include_router(quick_replies_router, tags=["快速回复"])
# H5 用户端 API
# POST /api/h5/oauth/callback — OAuth2回调
# GET /api/h5/user — 获取用户信息
# GET /api/h5/conversations/current — 获取当前会话
# POST /api/h5/conversations/current/messages — 发送消息
# GET /api/h5/conversations/current/messages/poll — 轮询新消息
# POST /api/h5/conversations/current/shake — 摇人
# GET /api/h5/approval-links — 获取审批链接
# GET /api/h5/software-downloads — 获取软件下载
api_router.include_router(h5_router, tags=["H5用户端"])
# 坐席备注 API
# GET /api/agent-notes/{employee_id} — 获取员工备注
# POST /api/agent-notes — 添加备注
# PUT /api/agent-notes/{id} — 更新备注
# DELETE /api/agent-notes/{id} — 删除备注
api_router.include_router(agent_notes_router, tags=["坐席备注"])
# 系统管理 API
# GET /api/system/emergency-mode — 查询应急模式状态
# PUT /api/system/emergency-mode — 切换应急模式开关
api_router.include_router(system_router, tags=["系统管理"])
# AI Wingman 智能副驾驶 API
# POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
# POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
# POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
api_router.include_router(wingman_router, tags=["AI Wingman"])
# 待办事项 API
# GET /api/todo-items — 获取当前坐席待办列表
# GET /api/todo-items/{id} — 获取待办详情
# PUT /api/todo-items/{id}/status — 更新待办状态
api_router.include_router(todo_items_router, tags=["待办事项"])
# 排查模板 API
# GET /api/troubleshooting-templates — 获取排查模板列表
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
# POST /api/troubleshooting-templates — 新增模板(管理员)
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
api_router.include_router(troubleshooting_templates_router, tags=["排查模板"])
# 员工管理 API
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
api_router.include_router(employees_router, tags=["员工管理"])
# 文件上传 API
# POST /api/upload — 上传文件(图片/文档)
# GET /api/media/{year}/{month}/{day}/{filename} — 访问上传的文件
api_router.include_router(upload_router, tags=["文件上传"])
# 管理后台 API
# GET /api/admin/dashboard/overview — 仪表盘统计
# GET /api/admin/configs — 获取配置分组
# PUT /api/admin/configs/{key} — 更新配置项
# GET /api/admin/configs/{key}/history — 配置变更历史
# GET /api/admin/agents — 坐席列表(管理视图)
# POST /api/admin/agents — 添加坐席
# PUT /api/admin/agents/{id} — 编辑坐席
# DELETE /api/admin/agents/{id} — 移除坐席
# GET /api/admin/integrations — 集成系统列表
# PUT /api/admin/integrations/{id} — 更新集成配置
# GET /api/admin/quick-replies/pending — 待审核快速回复
# PUT /api/admin/quick-replies/{id}/review — 审核快速回复
# GET /api/admin/assignment-mode — 获取分配模式
# PUT /api/admin/assignment-mode — 切换分配模式
# GET /api/admin/monitor/sessions — 会话监控
# GET /api/admin/search — 全局搜索
api_router.include_router(admin_router, tags=["管理后台"])
# Portal 统一入口 API
# GET /api/portal/roles — 获取当前用户角色信息
# POST /api/portal/switch-role — 切换当前角色
# GET /api/portal/entry/{role} — 获取角色对应的入口 URL
api_router.include_router(portal_router, tags=["统一入口"])
# 管理后台角色管理 API
# GET /api/admin/roles — 获取所有角色
# POST /api/admin/roles/assign — 分配角色
# POST /api/admin/roles/revoke — 撤销角色
# GET /api/admin/roles/mapping-rules — 获取映射规则
# POST /api/admin/roles/mapping-rules — 创建映射规则
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
api_router.include_router(admin_roles_router, tags=["角色管理"])
+130
View File
@@ -0,0 +1,130 @@
# =============================================================================
# 企微IT智能服务台 — 系统管理 API
# =============================================================================
# 说明:系统级配置管理接口,包括:
# 1. GET /api/system/emergency-mode — 查询应急模式状态
# 2. PUT /api/system/emergency-mode — 切换应急模式开关
# =============================================================================
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.system_config import SystemConfig
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 应急模式配置键(与 main.py init_data 保持一致)
EMERGENCY_MODE_KEY = "emergency_mode"
# --------------------------------------------------------------------------
# GET /api/system/emergency-mode — 查询应急模式状态
# --------------------------------------------------------------------------
@router.get("/system/emergency-mode")
async def get_emergency_mode(
db: AsyncSession = Depends(get_db),
):
"""查询应急模式状态。
返回当前应急模式的开关状态。
应急模式开启时,智能服务台降级,引导员工使用企微原生「员工服务」通道。
Returns:
Dict: 统一响应格式
data.emergency_mode: bool — 是否启用应急模式
data.employee_service_guide: str — 开启时的引导文案(仅开启时返回)
"""
# 从数据库读取 emergency_mode 配置
stmt = select(SystemConfig).where(
SystemConfig.config_key == EMERGENCY_MODE_KEY
)
result = await db.execute(stmt)
config = result.scalars().first()
# 配置不存在时默认关闭(安全默认值)
is_enabled = False
if config and config.config_value:
is_enabled = config.config_value.lower() in ("true", "1", "yes")
response_data = {"emergency_mode": is_enabled}
# 应急模式开启时,附带引导文案
if is_enabled:
response_data["employee_service_guide"] = (
"IT智能服务台正在进行系统维护,"
"请通过企业微信「通讯录 → 员工服务」联系IT支持人员,"
"我们将尽快为您处理。"
)
return success_response(data=response_data)
# --------------------------------------------------------------------------
# PUT /api/system/emergency-mode — 切换应急模式开关
# --------------------------------------------------------------------------
@router.put("/system/emergency-mode")
async def toggle_emergency_mode(
body: dict,
db: AsyncSession = Depends(get_db),
):
"""切换应急模式开关(仅限坐席/管理员操作)。
开启应急模式后:
- H5 用户端页面显示引导文案,提示走企微原生「员工服务」
- 坐席工作台顶部显示醒目的应急模式横幅
关闭应急模式后恢复正常服务。
Args:
body: 请求体,包含 emergency_mode: bool
Returns:
Dict: 统一响应格式
data.emergency_mode: bool — 切换后的状态
"""
enabled = body.get("emergency_mode", None)
if enabled is None:
raise AppException(1001, "emergency_mode 参数不能为空")
enabled_bool = bool(enabled)
# 查找或创建 emergency_mode 配置项
stmt = select(SystemConfig).where(
SystemConfig.config_key == EMERGENCY_MODE_KEY
)
result = await db.execute(stmt)
config = result.scalars().first()
new_value = "true" if enabled_bool else "false"
if config:
# 更新已有配置
config.config_value = new_value
else:
# 配置不存在时新建(兜底,正常情况由 init_data 创建)
config = SystemConfig(
config_key=EMERGENCY_MODE_KEY,
config_value=new_value,
description="应急模式开关(true=启用员工服务通道,智能服务台降级)",
)
db.add(config)
await db.flush()
status_text = "开启" if enabled_bool else "关闭"
logger.info(f"应急模式已{status_text}")
return success_response(
data={
"emergency_mode": enabled_bool,
"message": f"应急模式已{status_text}",
}
)
+439
View File
@@ -0,0 +1,439 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项 API
# =============================================================================
# 说明:提供待办事项的 CRUD 接口
# 接口列表:
# GET /api/todo-items — 获取当前坐席待办列表
# GET /api/todo-items/{id} — 获取待办详情
# PUT /api/todo-items/{id}/status — 更新待办状态
# Mock: 预置示例待办数据,不连接真实外部系统
# =============================================================================
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.utils.response import success_response, AppException
# 创建路由器
router = APIRouter(prefix="/todo-items", tags=["待办事项"])
# --------------------------------------------------------------------------
# 请求/响应 Schema
# --------------------------------------------------------------------------
class TodoStatusUpdateRequest(BaseModel):
"""更新待办状态请求 Schema。"""
status: str = Field(..., description="新状态: pending/processing/resolved")
class TodoItemResponse(BaseModel):
"""待办事项响应 Schema。"""
id: str
type: str
title: str
priority: str
description: dict
status: str
assigned_agent_id: Optional[str] = None
corp_id: str = ""
created_at: str
updated_at: str
class TodoItemListResponse(BaseModel):
"""待办事项列表响应 Schema。"""
items: List[TodoItemResponse]
total: int
# --------------------------------------------------------------------------
# Mock 数据 — 预置示例待办(共 20 条,覆盖全部类型 × 状态)
# --------------------------------------------------------------------------
MOCK_TODO_ITEMS: List[dict] = [
# ========== 工单(ticket==========
# 待处理
{
"id": "todo-001",
"type": "ticket",
"title": "VPN连接失败 — 财务部张伟",
"priority": "urgent",
"description": {
"employee_name": "张伟",
"department": "财务部",
"error": "VPN Error 691",
"steps": ["检查账号状态", "重置密码", "检查VPN配置"],
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T09:15:00Z",
"updated_at": "2026-06-05T09:15:00Z",
},
{
"id": "todo-007",
"type": "ticket",
"title": "OA系统登录异常 — 人事部刘芳",
"priority": "urgent",
"description": {
"employee_name": "刘芳",
"department": "人事部",
"error": "页面白屏,控制台报500错误",
"affected_count": 15,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T11:30:00Z",
"updated_at": "2026-06-05T11:30:00Z",
},
{
"id": "todo-009",
"type": "ticket",
"title": "WiFi 无法连接 — 研发部开放区",
"priority": "urgent",
"description": {
"employee_name": "陈明",
"department": "研发部",
"error": "获取IP失败,提示无法连接到此网络",
"location": "3楼开放区",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T08:00:00Z",
"updated_at": "2026-06-06T08:00:00Z",
},
{
"id": "todo-017",
"type": "ticket",
"title": "鼠标失灵 — 行政部周婷",
"priority": "normal",
"description": {
"employee_name": "周婷",
"department": "行政部",
"error": "USB鼠标间歇性失灵,更换接口无效",
"os": "Windows 11",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T09:00:00Z",
"updated_at": "2026-06-06T09:00:00Z",
},
# 进行中
{
"id": "todo-004",
"type": "ticket",
"title": "邮箱容量告警 — 市场部王强",
"priority": "high",
"description": {
"employee_name": "王强",
"department": "市场部",
"current_usage": "4.8GB / 5GB",
"action": "协助清理或申请扩容",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-04T14:30:00Z",
"updated_at": "2026-06-05T08:00:00Z",
},
{
"id": "todo-010",
"type": "ticket",
"title": "ERP系统响应慢 — 全公司反馈",
"priority": "high",
"description": {
"employee_name": "多个员工",
"department": "全公司",
"error": "ERP首页加载超过15秒",
"affected_count": 50,
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T10:00:00Z",
"updated_at": "2026-06-05T15:00:00Z",
},
# 已完成
{
"id": "todo-011",
"type": "ticket",
"title": "打印机驱动安装 — 市场部赵敏",
"priority": "normal",
"description": {
"employee_name": "赵敏",
"department": "市场部",
"device_model": "Canon LBP2900",
"solution": "从官网下载驱动并安装,测试打印正常",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-02T16:00:00Z",
},
# ========== 审批(approval==========
# 待处理
{
"id": "todo-002",
"type": "approval",
"title": "软件安装审批 — 设计部PS申请",
"priority": "high",
"description": {
"employee_name": "李娜",
"department": "设计部",
"software": "Adobe Photoshop 2026",
"license_type": "企业许可",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T10:20:00Z",
"updated_at": "2026-06-05T10:20:00Z",
},
{
"id": "todo-005",
"type": "approval",
"title": "权限升级审批 — 研发部数据库访问",
"priority": "high",
"description": {
"employee_name": "陈明",
"department": "研发部",
"target_system": "生产数据库",
"access_level": "只读",
"approver": "研发总监",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T08:45:00Z",
"updated_at": "2026-06-05T08:45:00Z",
},
{
"id": "todo-008",
"type": "approval",
"title": "新员工设备采购审批 — Q3批次",
"priority": "normal",
"description": {
"batch": "Q3新员工",
"count": 5,
"items": ["笔记本x5", "显示器x5", "键鼠套装x5"],
"budget": "65,000元",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T07:00:00Z",
"updated_at": "2026-06-05T07:00:00Z",
},
{
"id": "todo-018",
"type": "approval",
"title": "弹性福利审批 — 全体员工Q3",
"priority": "normal",
"description": {
"applicant": "人事部",
"type": "弹性福利",
"budget_per_person": "3000元",
"total_count": 120,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T07:00:00Z",
"updated_at": "2026-06-06T07:00:00Z",
},
# 进行中
{
"id": "todo-012",
"type": "approval",
"title": "预算审批 — IT部Q3采购",
"priority": "high",
"description": {
"department": "IT部",
"amount": "280,000元",
"items": ["服务器x2", "防火墙x2", "交换机x4"],
"approver": "CFO",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-04T09:00:00Z",
"updated_at": "2026-06-05T14:00:00Z",
},
# 已完成
{
"id": "todo-013",
"type": "approval",
"title": "会议室预订审批 — 销售部Q3客户拜访",
"priority": "normal",
"description": {
"employee_name": "刘军",
"department": "销售部",
"room": "5楼大会议室",
"time": "2026-06-10 14:00-17:00",
"result": "已批准",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-05-28T08:00:00Z",
"updated_at": "2026-05-29T10:00:00Z",
},
# ========== 设备(device==========
# 待处理
{
"id": "todo-003",
"type": "device",
"title": "工位打印机故障 — 3楼A区",
"priority": "normal",
"description": {
"location": "3楼A区打印间",
"device_model": "HP LaserJet Pro M404",
"issue": "卡纸,无法打印",
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T11:05:00Z",
"updated_at": "2026-06-05T11:05:00Z",
},
{
"id": "todo-014",
"type": "device",
"title": "核心交换机故障 — 机房",
"priority": "urgent",
"description": {
"location": "机房A区",
"device_model": "Cisco Catalyst 9300",
"issue": "端口3-12全部down,影响2楼所有工位",
"affected_count": 45,
},
"status": "pending",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-06T00:30:00Z",
"updated_at": "2026-06-06T00:30:00Z",
},
# 进行中
{
"id": "todo-006",
"type": "device",
"title": "会议室投影仪维修 — 5楼大会议室",
"priority": "normal",
"description": {
"location": "5楼大会议室",
"device_model": "Epson EB-X51",
"issue": "投影模糊,可能灯泡老化",
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-03T16:00:00Z",
"updated_at": "2026-06-04T10:00:00Z",
},
{
"id": "todo-015",
"type": "device",
"title": "服务器硬盘更换 — 虚拟化集群",
"priority": "high",
"description": {
"location": "机房B区",
"device_model": "Dell R740",
"issue": "硬盘预警,需更换并做好数据迁移",
"affected_vms": 12,
},
"status": "processing",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-06-05T09:00:00Z",
"updated_at": "2026-06-05T16:00:00Z",
},
# 已完成
{
"id": "todo-016",
"type": "device",
"title": "员工笔记本磁盘扩容 — 人事部吴婷",
"priority": "normal",
"description": {
"employee_name": "吴婷",
"department": "人事部",
"device_model": "ThinkPad X1 Carbon",
"solution": "更换1TB SSD,克隆系统,测试正常",
},
"status": "resolved",
"assigned_agent_id": "agent-001",
"corp_id": "ww1234567890",
"created_at": "2026-05-20T13:00:00Z",
"updated_at": "2026-05-22T17:00:00Z",
},
]
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.get("")
async def list_todo_items(
status: Optional[str] = None,
priority: Optional[str] = None,
):
"""获取当前坐席待办列表。
支持按状态和优先级过滤。
"""
items = MOCK_TODO_ITEMS
# 按状态过滤
if status:
items = [item for item in items if item["status"] == status]
# 按优先级过滤
if priority:
items = [item for item in items if item["priority"] == priority]
# 按优先级排序:urgent → high → normal
priority_order = {"urgent": 0, "high": 1, "normal": 2}
items = sorted(items, key=lambda x: priority_order.get(x["priority"], 3))
return success_response(data={
"items": [TodoItemResponse(**item).model_dump() for item in items],
"total": len(items),
})
@router.get("/{item_id}")
async def get_todo_item(item_id: str):
"""获取待办事项详情。"""
for item in MOCK_TODO_ITEMS:
if item["id"] == item_id:
return success_response(data=TodoItemResponse(**item).model_dump())
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
@router.put("/{item_id}/status")
async def update_todo_item_status(item_id: str, request: TodoStatusUpdateRequest):
"""更新待办事项状态。"""
# 校验状态值
valid_statuses = {"pending", "processing", "resolved"}
if request.status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"无效的状态值: {request.status},合法值为: {valid_statuses}",
)
for item in MOCK_TODO_ITEMS:
if item["id"] == item_id:
item["status"] = request.status
item["updated_at"] = datetime.now().isoformat()
return success_response(data=TodoItemResponse(**item).model_dump())
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
@@ -0,0 +1,719 @@
# =============================================================================
# 企微IT智能服务台 — 排查模板 API
# =============================================================================
# 说明:提供排查模板的 CRUD 接口
# 接口列表:
# GET /api/troubleshooting-templates — 获取排查模板列表
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
# POST /api/troubleshooting-templates — 新增模板(管理员)
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
# Mock: 预置 8 套常见问题模板(VPN/邮箱/系统/账号等)
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.utils.response import success_response, AppException
# 创建路由器
router = APIRouter(prefix="/troubleshooting-templates", tags=["排查模板"])
# --------------------------------------------------------------------------
# 请求/响应 Schema
# --------------------------------------------------------------------------
class PathStepSchema(BaseModel):
"""排障步骤路径节点 Schema。"""
label: str = Field(..., description="步骤标题")
status: str = Field(default="pending", description="步骤状态: done/current/pending")
class FlowchartNodeSchema(BaseModel):
"""决策树递归节点 Schema。"""
id: str = Field(..., description="节点唯一标识")
type: str = Field(..., description="节点类型: step/decision")
label: str = Field(..., description="节点标签")
status: Optional[str] = Field(None, description="节点状态: done/current/pending")
children: Optional[List["FlowchartNodeSchema"]] = Field(None, description="子节点列表")
yes_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'' 分支")
no_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'' 分支")
class TroubleshootingTemplateCreateRequest(BaseModel):
"""创建排查模板请求 Schema。"""
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
category: str = Field(default="system", description="分类: vpn/email/system/account")
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
is_active: bool = Field(default=True, description="是否启用")
class TroubleshootingTemplateUpdateRequest(BaseModel):
"""更新排查模板请求 Schema。"""
name: Optional[str] = Field(None, max_length=256, description="模板名称")
category: Optional[str] = Field(None, description="分类")
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
is_active: Optional[bool] = Field(None, description="是否启用")
class TroubleshootingTemplateResponse(BaseModel):
"""排查模板响应 Schema。"""
id: str
name: str
category: str
path_steps: List[Dict[str, Any]]
flowchart: Dict[str, Any]
is_active: bool
created_at: str
updated_at: str
class TroubleshootingTemplateListResponse(BaseModel):
"""排查模板列表响应 Schema。"""
items: List[TroubleshootingTemplateResponse]
total: int
# --------------------------------------------------------------------------
# Mock 数据 — 预置 8 套常见问题模板
# --------------------------------------------------------------------------
def _build_vpn_flowchart() -> Dict[str, Any]:
"""构建 VPN 故障排查流程图。"""
return {
"id": "fc-vpn-1",
"type": "step",
"label": "确认VPN客户端版本",
"status": "done",
"children": [
{
"id": "fc-vpn-2",
"type": "decision",
"label": "版本是否为最新?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-3",
"type": "step",
"label": "清除DNS缓存并重连",
"status": "current",
"children": [
{
"id": "fc-vpn-4",
"type": "decision",
"label": "重连是否成功?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-vpn-6",
"type": "step",
"label": "发起远程协助",
"status": "pending",
"children": [
{
"id": "fc-vpn-7",
"type": "decision",
"label": "远程能否解决?",
"status": "pending",
"yes_branch": {
"id": "fc-vpn-8",
"type": "step",
"label": "回访确认并结单",
"status": "pending",
},
"no_branch": {
"id": "fc-vpn-9",
"type": "step",
"label": "升级至二线团队",
"status": "pending",
},
},
],
},
},
],
},
"no_branch": {
"id": "fc-vpn-10",
"type": "step",
"label": "升级VPN客户端到最新版",
"status": "pending",
"children": [
{
"id": "fc-vpn-11",
"type": "step",
"label": "重试连接",
"status": "pending",
},
],
},
},
],
}
def _build_email_flowchart() -> Dict[str, Any]:
"""构建邮箱故障排查流程图。"""
return {
"id": "fc-email-1",
"type": "step",
"label": "确认邮箱账号状态",
"status": "done",
"children": [
{
"id": "fc-email-2",
"type": "decision",
"label": "账号是否被锁定?",
"status": "pending",
"yes_branch": {
"id": "fc-email-3",
"type": "step",
"label": "解锁账号并重置密码",
"status": "current",
},
"no_branch": {
"id": "fc-email-4",
"type": "step",
"label": "检查Outlook配置",
"status": "pending",
"children": [
{
"id": "fc-email-5",
"type": "decision",
"label": "配置是否正确?",
"status": "pending",
"yes_branch": {
"id": "fc-email-6",
"type": "step",
"label": "清理Outlook缓存",
"status": "pending",
},
"no_branch": {
"id": "fc-email-7",
"type": "step",
"label": "重新配置Outlook",
"status": "pending",
},
},
],
},
},
],
}
def _build_system_flowchart() -> Dict[str, Any]:
"""构建系统登录异常排查流程图。"""
return {
"id": "fc-sys-1",
"type": "step",
"label": "确认系统服务是否正常",
"status": "current",
"children": [
{
"id": "fc-sys-2",
"type": "decision",
"label": "系统服务是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-sys-3",
"type": "step",
"label": "清除浏览器缓存",
"status": "pending",
"children": [
{
"id": "fc-sys-4",
"type": "decision",
"label": "清除后是否恢复?",
"status": "pending",
"yes_branch": {
"id": "fc-sys-5",
"type": "step",
"label": "回访确认并结单",
"status": "pending",
},
"no_branch": {
"id": "fc-sys-6",
"type": "step",
"label": "更换浏览器重试",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-sys-7",
"type": "step",
"label": "联系运维检查服务端",
"status": "pending",
},
},
],
}
def _build_account_flowchart() -> Dict[str, Any]:
"""构建账号权限问题排查流程图。"""
return {
"id": "fc-acc-1",
"type": "step",
"label": "确认权限需求与合规性",
"status": "current",
"children": [
{
"id": "fc-acc-2",
"type": "decision",
"label": "权限是否符合策略?",
"status": "pending",
"yes_branch": {
"id": "fc-acc-3",
"type": "step",
"label": "提交权限审批流程",
"status": "pending",
"children": [
{
"id": "fc-acc-4",
"type": "step",
"label": "审批通过后配置权限",
"status": "pending",
},
],
},
"no_branch": {
"id": "fc-acc-5",
"type": "step",
"label": "建议替代方案或申请特批",
"status": "pending",
},
},
],
}
def _build_network_flowchart() -> Dict[str, Any]:
"""构建网络连接问题排查流程图。"""
return {
"id": "fc-net-1",
"type": "step",
"label": "确认网络连接状态",
"status": "current",
"children": [
{
"id": "fc-net-2",
"type": "decision",
"label": "能否ping通网关?",
"status": "pending",
"yes_branch": {
"id": "fc-net-3",
"type": "step",
"label": "检查DNS解析",
"status": "pending",
"children": [
{
"id": "fc-net-4",
"type": "decision",
"label": "DNS是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-net-5",
"type": "step",
"label": "检查防火墙规则",
"status": "pending",
},
"no_branch": {
"id": "fc-net-6",
"type": "step",
"label": "手动配置DNS服务器",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-net-7",
"type": "step",
"label": "检查网线和交换机端口",
"status": "pending",
},
},
],
}
def _build_printer_flowchart() -> Dict[str, Any]:
"""构建打印机故障排查流程图。"""
return {
"id": "fc-prt-1",
"type": "step",
"label": "确认打印机连接状态",
"status": "current",
"children": [
{
"id": "fc-prt-2",
"type": "decision",
"label": "打印机是否在线?",
"status": "pending",
"yes_branch": {
"id": "fc-prt-3",
"type": "step",
"label": "清除打印队列并重启打印服务",
"status": "pending",
"children": [
{
"id": "fc-prt-4",
"type": "decision",
"label": "打印是否恢复?",
"status": "pending",
"yes_branch": {
"id": "fc-prt-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-prt-6",
"type": "step",
"label": "重新安装打印机驱动",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-prt-7",
"type": "step",
"label": "检查网络连接和打印机电源",
"status": "pending",
},
},
],
}
def _build_office_flowchart() -> Dict[str, Any]:
"""构建 Office 软件问题排查流程图。"""
return {
"id": "fc-off-1",
"type": "step",
"label": "确认Office版本和激活状态",
"status": "current",
"children": [
{
"id": "fc-off-2",
"type": "decision",
"label": "Office是否正常激活?",
"status": "pending",
"yes_branch": {
"id": "fc-off-3",
"type": "step",
"label": "修复Office安装",
"status": "pending",
"children": [
{
"id": "fc-off-4",
"type": "decision",
"label": "修复后是否正常?",
"status": "pending",
"yes_branch": {
"id": "fc-off-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-off-6",
"type": "step",
"label": "卸载重装Office",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-off-7",
"type": "step",
"label": "重新激活Office许可证",
"status": "pending",
},
},
],
}
def _build_password_flowchart() -> Dict[str, Any]:
"""构建密码重置问题排查流程图。"""
return {
"id": "fc-pwd-1",
"type": "step",
"label": "确认账号状态和锁定原因",
"status": "current",
"children": [
{
"id": "fc-pwd-2",
"type": "decision",
"label": "账号是否被锁定?",
"status": "pending",
"yes_branch": {
"id": "fc-pwd-3",
"type": "step",
"label": "解锁账号并引导自助重置",
"status": "pending",
"children": [
{
"id": "fc-pwd-4",
"type": "decision",
"label": "自助重置是否成功?",
"status": "pending",
"yes_branch": {
"id": "fc-pwd-5",
"type": "step",
"label": "回访确认",
"status": "pending",
},
"no_branch": {
"id": "fc-pwd-6",
"type": "step",
"label": "管理员手动重置密码",
"status": "pending",
},
},
],
},
"no_branch": {
"id": "fc-pwd-7",
"type": "step",
"label": "检查SSO单点登录配置",
"status": "pending",
},
},
],
}
# 所有 Mock 模板数据
MOCK_TEMPLATES: List[dict] = [
{
"id": "tpl-vpn-001",
"name": "VPN连接故障",
"category": "vpn",
"path_steps": [
{"label": "确认VPN版本", "status": "done"},
{"label": "清除缓存重连", "status": "current"},
{"label": "远程排查", "status": "pending"},
{"label": "升级客户端", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_vpn_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
},
{
"id": "tpl-email-001",
"name": "邮箱登录故障",
"category": "email",
"path_steps": [
{"label": "确认邮箱状态", "status": "done"},
{"label": "重置密码", "status": "current"},
{"label": "检查配置", "status": "pending"},
{"label": "清理缓存", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_email_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-20T14:00:00Z",
},
{
"id": "tpl-system-001",
"name": "系统登录异常",
"category": "system",
"path_steps": [
{"label": "确认系统状态", "status": "current"},
{"label": "清除浏览器缓存", "status": "pending"},
{"label": "更换浏览器", "status": "pending"},
{"label": "检查网络权限", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_system_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-25T09:15:00Z",
},
{
"id": "tpl-account-001",
"name": "账号权限问题",
"category": "account",
"path_steps": [
{"label": "确认权限需求", "status": "current"},
{"label": "提交审批", "status": "pending"},
{"label": "配置权限", "status": "pending"},
{"label": "验证权限", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_account_flowchart(),
"is_active": True,
"created_at": "2025-06-01T08:00:00Z",
"updated_at": "2025-06-28T16:45:00Z",
},
{
"id": "tpl-network-001",
"name": "网络连接问题",
"category": "system",
"path_steps": [
{"label": "确认网络状态", "status": "current"},
{"label": "检查DNS配置", "status": "pending"},
{"label": "检查防火墙", "status": "pending"},
{"label": "更换网口/网线", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_network_flowchart(),
"is_active": True,
"created_at": "2025-06-05T10:00:00Z",
"updated_at": "2025-06-22T11:30:00Z",
},
{
"id": "tpl-printer-001",
"name": "打印机故障",
"category": "system",
"path_steps": [
{"label": "确认打印机状态", "status": "current"},
{"label": "清除打印队列", "status": "pending"},
{"label": "重新安装驱动", "status": "pending"},
{"label": "检查网络连接", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_printer_flowchart(),
"is_active": True,
"created_at": "2025-06-10T09:00:00Z",
"updated_at": "2025-07-01T08:00:00Z",
},
{
"id": "tpl-office-001",
"name": "Office软件问题",
"category": "system",
"path_steps": [
{"label": "确认Office版本", "status": "current"},
{"label": "修复安装", "status": "pending"},
{"label": "重新激活", "status": "pending"},
{"label": "卸载重装", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_office_flowchart(),
"is_active": True,
"created_at": "2025-06-12T14:00:00Z",
"updated_at": "2025-06-30T10:00:00Z",
},
{
"id": "tpl-password-001",
"name": "密码重置问题",
"category": "account",
"path_steps": [
{"label": "确认账号状态", "status": "current"},
{"label": "解锁账号", "status": "pending"},
{"label": "引导自助重置", "status": "pending"},
{"label": "管理员重置", "status": "pending"},
{"label": "回访确认", "status": "pending"},
],
"flowchart": _build_password_flowchart(),
"is_active": True,
"created_at": "2025-06-15T08:00:00Z",
"updated_at": "2025-07-01T09:00:00Z",
},
]
# --------------------------------------------------------------------------
# API 接口
# --------------------------------------------------------------------------
@router.get("")
async def list_troubleshooting_templates(
category: Optional[str] = None,
):
"""获取排查模板列表。
支持按分类过滤。
"""
items = MOCK_TEMPLATES
# 按分类过滤
if category:
items = [item for item in items if item["category"] == category]
# 只返回启用的模板
items = [item for item in items if item.get("is_active", True)]
return success_response(data={
"items": [TroubleshootingTemplateResponse(**item).model_dump() for item in items],
"total": len(items),
})
@router.get("/{template_id}")
async def get_troubleshooting_template(template_id: str):
"""获取排查模板详情。"""
for item in MOCK_TEMPLATES:
if item["id"] == template_id:
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
@router.post("")
async def create_troubleshooting_template(request: TroubleshootingTemplateCreateRequest):
"""新增排查模板(管理员)。"""
new_template = {
"id": f"tpl-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"name": request.name,
"category": request.category,
"path_steps": request.path_steps,
"flowchart": request.flowchart,
"is_active": request.is_active,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
MOCK_TEMPLATES.append(new_template)
return success_response(data=TroubleshootingTemplateResponse(**new_template).model_dump())
@router.put("/{template_id}")
async def update_troubleshooting_template(
template_id: str,
request: TroubleshootingTemplateUpdateRequest,
):
"""修改排查模板(管理员)。"""
for item in MOCK_TEMPLATES:
if item["id"] == template_id:
if request.name is not None:
item["name"] = request.name
if request.category is not None:
item["category"] = request.category
if request.path_steps is not None:
item["path_steps"] = request.path_steps
if request.flowchart is not None:
item["flowchart"] = request.flowchart
if request.is_active is not None:
item["is_active"] = request.is_active
item["updated_at"] = datetime.now().isoformat()
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
@router.delete("/{template_id}")
async def delete_troubleshooting_template(template_id: str):
"""删除排查模板(管理员)。"""
for i, item in enumerate(MOCK_TEMPLATES):
if item["id"] == template_id:
MOCK_TEMPLATES.pop(i)
return success_response(data=None, message=f"排查模板 {template_id} 已删除")
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
+206
View File
@@ -0,0 +1,206 @@
# =============================================================================
# 企微IT智能服务台 — 文件上传 API
# =============================================================================
# 说明:处理图片/文件上传,保存到服务器本地存储
# 1. POST /api/upload — 上传文件(图片/文件),返回文件URL
# 2. GET /api/media/{path} — 静态文件服务(开发环境)
# 文件存储路径:./uploads/YYYY/MM/DD/{uuid}.{ext}
# =============================================================================
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from app.utils.response import success_response
from app.api.h5 import _get_current_employee
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 文件存储配置
# --------------------------------------------------------------------------
# 上传文件的根目录(Docker 环境中映射为 Volume 持久化存储)
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
# 允许上传的文件扩展名(白名单,防止上传可执行文件等危险文件)
ALLOWED_EXTENSIONS = {
# 图片
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg",
# 文档
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"txt", "csv", "md", "rtf",
# 压缩包
"zip", "rar", "7z", "tar", "gz",
# 其他
"log", "json", "xml", "yaml", "yml",
}
# 单文件最大大小(默认 20MB
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(20 * 1024 * 1024))) # 20MB
# 图片最大大小(默认 10MB
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", str(10 * 1024 * 1024))) # 10MB
# 图片类型扩展名集合
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"}
def _get_file_extension(filename: str) -> str:
"""从文件名中提取小写扩展名。
Args:
filename: 原始文件名
Returns:
str: 小写扩展名(不含点号),如 "png"
"""
# os.path.splitext 返回 (root, ext)ext 含点号如 ".png"
ext = os.path.splitext(filename)[1].lower().lstrip(".")
return ext or "bin" # 无扩展名时默认 bin
def _generate_storage_path(extension: str) -> tuple[Path, str]:
"""生成文件存储路径(按日期分目录)。
目录结构:uploads/YYYY/MM/DD/{uuid}.{ext}
同时返回完整本地路径和用于API访问的相对URL路径。
Args:
extension: 文件扩展名(如 "png"
Returns:
tuple: (本地文件完整路径, API访问的URL路径)
"""
now = datetime.now()
# 按日期建子目录,方便按时间归档和清理
date_dir = UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
# 确保目录存在(exist_ok=True 避免并发创建时报错)
date_dir.mkdir(parents=True, exist_ok=True)
# 使用 UUID 避免文件名冲突和安全风险(不使用原始文件名存储)
file_id = uuid.uuid4().hex[:12] # 12位足够短且唯一
filename = f"{file_id}.{extension}"
local_path = date_dir / filename
# URL 路径:/api/media/YYYY/MM/DD/{uuid}.{ext}
url_path = f"/api/media/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
return local_path, url_path
# --------------------------------------------------------------------------
# POST /api/upload — 上传文件
# --------------------------------------------------------------------------
@router.post("/upload")
async def upload_file(
file: UploadFile = File(..., description="上传的文件(图片或文档)"),
employee_id: str = Depends(_get_current_employee),
):
"""上传文件到服务器。
处理流程:
1. 校验文件扩展名(白名单)
2. 校验文件大小(图片10MB,其他20MB)
3. 按日期目录存储文件
4. 返回文件访问URL
Args:
file: FastAPI UploadFile 对象
Returns:
Dict: 统一响应格式,包含文件URL、文件名、文件大小、文件类型
"""
# 1. 提取并校验文件扩展名
ext = _get_file_extension(file.filename or "unknown")
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: .{ext},允许的类型: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
# 2. 读取文件内容并校验大小
content = await file.read()
file_size = len(content)
# 图片和普通文件分别校验大小
is_image = ext in IMAGE_EXTENSIONS
max_size = MAX_IMAGE_SIZE if is_image else MAX_FILE_SIZE
size_label = "10MB" if is_image else "20MB"
if file_size > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小 {file_size / 1024 / 1024:.1f}MB 超过限制({size_label}",
)
# 3. 生成存储路径并保存文件
local_path, url_path = _generate_storage_path(ext)
try:
# 以二进制模式写入文件
with open(local_path, "wb") as f:
f.write(content)
except OSError as e:
logger.error(f"文件保存失败: {e}")
raise HTTPException(status_code=500, detail="文件保存失败,请重试")
# 4. 返回文件信息
logger.info(f"文件上传成功: {url_path} ({file_size} bytes, {file.filename})")
return success_response(data={
"url": url_path, # 文件访问URL(前端用于展示/下载)
"filename": file.filename, # 原始文件名(显示用)
"file_size": file_size, # 文件大小(字节)
"msg_type": "image" if is_image else "file", # 消息类型(前端根据此字段区分展示)
"extension": ext, # 文件扩展名
})
# --------------------------------------------------------------------------
# GET /api/media/{year}/{month}/{day}/{filename} — 静态文件服务
# --------------------------------------------------------------------------
# 注意:生产环境由 Nginx 直接提供静态文件服务(性能更好)
# 此接口仅用于开发环境,或 Nginx 未配置静态文件时的降级方案
@router.get("/media/{year}/{month}/{day}/{filename}")
async def serve_media_file(
year: str,
month: str,
day: str,
filename: str,
):
"""提供上传文件的静态访问。
开发环境使用 FastAPI 直接返回文件;
生产环境建议 Nginx 配置 location /api/media/ 直接代理到 uploads 目录。
Args:
year: 年份(路径参数)
month: 月份(路径参数)
day: 日期(路径参数)
filename: 文件名(路径参数)
Returns:
FileResponse: 文件响应
"""
file_path = UPLOAD_DIR / year / month / day / filename
# 安全检查:防止路径遍历攻击(如 ../../etc/passwd
# resolve() 解析符号链接和 .. ,然后检查是否在 UPLOAD_DIR 内
try:
resolved = file_path.resolve()
upload_root = UPLOAD_DIR.resolve()
if not str(resolved).startswith(str(upload_root)):
raise HTTPException(status_code=403, detail="禁止访问")
except (ValueError, OSError):
raise HTTPException(status_code=403, detail="禁止访问")
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
# FileResponse 自动根据扩展名设置 Content-Type
return FileResponse(file_path)
+276
View File
@@ -0,0 +1,276 @@
# =============================================================================
# 企微IT智能服务台 — 企微回调 API
# =============================================================================
# 说明:处理企微服务器的回调请求,包括:
# 1. GET /api/wecom/callback — 验证URL有效性(企微配置回调URL时调用)
# 2. POST /api/wecom/callback — 接收企微推送的消息
#
# 重构记录(2026-06):
# - 移除手动创建 Redis/WecomService/AIService 实例的模式
# - 改用 dependencies 模块提供的共享服务实例
# - 不再手动 close() 服务实例(由应用生命周期管理)
# =============================================================================
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import _get_session_factory
from app.dependencies import (
get_shared_redis,
get_shared_wecom_service,
get_shared_ai_handler,
)
from app.services.ai_handler import AIHandler
from app.services.cache_service import CacheService
from app.services.message_router import MessageRouter
from app.services.scoring_service import ScoringService
from app.services.wecom_service import WecomService
from app.utils.wecom_crypto import WecomCrypto
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# 加解密工具实例(懒加载单例,避免导入时因无效配置导致 base64 解码失败)
_wecom_crypto: WecomCrypto | None = None
def _get_wecom_crypto() -> WecomCrypto:
"""获取加解密工具单例(延迟初始化)。
在测试环境中,settings 中的 EncodingAESKey 可能是无效的占位值,
延迟初始化可以避免模块导入时就触发 base64 解码错误。
"""
global _wecom_crypto
if _wecom_crypto is None:
from app.config import settings
_wecom_crypto = WecomCrypto(
token=settings.wecom_token,
encoding_aes_key=settings.wecom_encoding_aes_key,
corp_id=settings.wecom_corp_id,
)
return _wecom_crypto
@router.get("/wecom/callback")
async def verify_url(
msg_signature: str = Query(..., description="企微签名"),
timestamp: str = Query(..., description="时间戳"),
nonce: str = Query(..., description="随机数"),
echostr: str = Query(..., description="加密的验证字符串"),
):
"""验证企微回调URL有效性。
企微管理后台配置回调URL时,会发送 GET 请求验证。
验证流程:
1. 验证签名 SHA1(sort(token, timestamp, nonce, echostr))
2. 解密 echostr
3. 返回解密后的明文
Args:
msg_signature: 企微签名
timestamp: 时间戳
nonce: 随机数
echostr: 加密的验证字符串
Returns:
str: 解密后的 echostr 明文
"""
try:
# 验证签名并解密 echostr
plaintext = _get_wecom_crypto().decrypt_echostr(
msg_signature=msg_signature,
timestamp=timestamp,
nonce=nonce,
echostr=echostr,
)
logger.info("企微回调URL验证成功")
return Response(content=plaintext, media_type="text/plain")
except ValueError as e:
logger.error(f"企微回调URL验证失败: {e}")
return Response(content=f"验证失败: {e}", media_type="text/plain", status_code=400)
@router.post("/wecom/callback")
async def receive_message(
request: Request,
msg_signature: str = Query(..., description="企微签名"),
timestamp: str = Query(..., description="时间戳"),
nonce: str = Query(..., description="随机数"),
):
"""接收企微推送的消息。
企微将员工发送的消息通过此接口推送过来。
处理流程:
1. 读取 XML 请求体
2. 解密消息(验证签名 + AES 解密)
3. 解析消息内容
4. 路由到 MessageRouter 处理
5. 返回 "success" 字符串(企微要求)
重构说明:使用 dependencies 模块提供的共享服务实例,
不再手动创建/关闭 Redis、WecomService、AIService。
企微推送的消息格式(加密后):
<xml>
<ToUserName><![CDATA[corp_id]]></ToUserName>
<AgentID>1000002</AgentID>
<Encrypt><![CDATA[加密内容]]></Encrypt>
</xml>
Args:
request: FastAPI 请求对象(读取 XML 请求体)
msg_signature: 企微签名
timestamp: 时间戳
nonce: 随机数
Returns:
str: "success" 字符串(企微要求的固定响应)
"""
try:
# 1. 读取 XML 请求体
xml_body = (await request.body()).decode("utf-8")
logger.debug(f"收到企微回调: xml_length={len(xml_body)}")
# 2. 解密消息
message_dict = _get_wecom_crypto().decrypt_message(
xml_body=xml_body,
msg_signature=msg_signature,
timestamp=timestamp,
nonce=nonce,
)
# 3. 提取消息关键字段
from_user_id = message_dict.get("FromUserName", "")
content = message_dict.get("Content", "")
msg_type = message_dict.get("MsgType", "text")
agent_id = message_dict.get("AgentID", "")
event = message_dict.get("Event", "")
msg_id = message_dict.get("MsgId", "")
# 提取非文本消息的媒体字段(图片/语音/视频/文件/位置)
media_id: str = message_dict.get("MediaId", "")
pic_url: str = message_dict.get("PicUrl", "")
msg_format: str = message_dict.get("Format", "")
file_name: str = message_dict.get("FileName", "")
file_size: str = message_dict.get("FileSize", "")
# 位置消息字段
location_x: str = message_dict.get("Location_X", "")
location_y: str = message_dict.get("Location_Y", "")
location_label: str = message_dict.get("Label", "")
# 4. 处理事件消息(如员工进入应用)
if event:
await _handle_event(event, from_user_id, message_dict)
return Response(content="success", media_type="text/plain")
# 5. 处理各类消息(文本 + 非文本)
# 文本消息必须有 Content 字段;非文本消息(image/voice/video/file/location
# 没有 Content 字段,content 可能为空字符串,这是正常的
if msg_type == "text" and (not from_user_id or not content):
logger.warning("文本消息缺少发送者或内容,忽略")
return Response(content="success", media_type="text/plain")
elif msg_type != "text" and not from_user_id:
logger.warning("非文本消息缺少发送者,忽略")
return Response(content="success", media_type="text/plain")
# 6. 路由消息到 MessageRouter(使用共享服务实例)
session_factory = _get_session_factory()
async with session_factory() as db:
try:
# 获取共享服务实例(不再手动创建/关闭)
wecom_service = get_shared_wecom_service()
ai_handler = get_shared_ai_handler()
redis_client = get_shared_redis()
# ScoringService 需要当前 db 会话,仍需按请求创建
scoring_service = ScoringService(db)
# CacheService 使用共享 Redis 客户端
cache_service = CacheService(redis_client)
# 创建消息路由器
message_router = MessageRouter(
db=db,
wecom_service=wecom_service,
scoring_service=scoring_service,
ai_handler=ai_handler,
cache_service=cache_service,
)
# 构建 extra_data(存储各消息类型的额外元数据)
extra_data: dict = {}
if msg_type == "image":
extra_data["pic_url"] = pic_url
elif msg_type == "voice":
extra_data["format"] = msg_format
elif msg_type == "video":
extra_data["thumb_media_id"] = message_dict.get("ThumbMediaId", "")
elif msg_type == "location":
extra_data["location_x"] = location_x
extra_data["location_y"] = location_y
extra_data["label"] = location_label
extra_data["scale"] = message_dict.get("Scale", "")
# 路由消息
await message_router.route_message(
from_user_id=from_user_id,
content=content,
msg_type=msg_type,
msg_id=msg_id if msg_id else None,
media_id=media_id if media_id else None,
extra_data=extra_data if extra_data else None,
file_name=file_name if file_name else None,
file_size=int(file_size) if file_size else None,
)
# 提交事务
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"消息路由处理失败: {e}", exc_info=True)
# 即使处理失败,也返回 "success" 避免企微重试
# 但记录错误日志以便排查
return Response(content="success", media_type="text/plain")
except ValueError as e:
# 解密失败,记录日志但仍返回 success 避免企微重试
logger.error(f"消息解密失败: {e}")
return Response(content="success", media_type="text/plain")
except Exception as e:
# 其他未知错误,记录日志但仍返回 success
logger.error(f"消息处理未知错误: {e}", exc_info=True)
return Response(content="success", media_type="text/plain")
async def _handle_event(
event: str, from_user_id: str, message_dict: dict
) -> None:
"""处理企微事件消息。
事件类型:
- subscribe: 员工关注应用
- unsubscribe: 员工取消关注
- enter_agent: 员工进入应用
Args:
event: 事件类型
from_user_id: 发送者企微 UserID
message_dict: 完整消息字典
"""
if event == "enter_agent":
logger.info(f"员工进入应用: user_id={from_user_id}")
elif event == "subscribe":
logger.info(f"员工关注应用: user_id={from_user_id}")
elif event == "unsubscribe":
logger.info(f"员工取消关注: user_id={from_user_id}")
else:
logger.info(f"收到事件消息: event={event}, user_id={from_user_id}")
+227
View File
@@ -0,0 +1,227 @@
# =============================================================================
# 企微IT智能服务台 — AI Wingman API 路由
# =============================================================================
# 说明:坐席端 AI 智能副驾驶 API,包含 3 个核心端点:
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
#
# 所有端点需要坐席认证(get_current_agent
# =============================================================================
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import dep_wingman_service
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.services.wingman_service import WingmanService
from app.utils.response import ERR_NOT_FOUND, success_response
# 复用坐席认证依赖
from app.api.agents import get_current_agent
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 辅助函数
# --------------------------------------------------------------------------
async def _validate_conversation(
conversation_id: str,
agent: Agent,
db: AsyncSession,
) -> Conversation:
"""验证会话存在性并返回会话对象。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
Returns:
Conversation: 会话对象
Raises:
AppException: 会话不存在
"""
stmt = select(Conversation).where(Conversation.id == conversation_id)
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise ERR_NOT_FOUND
return conversation
async def _get_recent_messages(
conversation_id: str,
db: AsyncSession,
limit: int = 20,
) -> list[dict]:
"""获取会话最近的消息历史(转换为字典列表)。
Args:
conversation_id: 会话ID
db: 数据库会话
limit: 获取的消息条数
Returns:
list[dict]: 消息字典列表
"""
stmt = (
select(Message)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 按时间正序排列(最早的在前)
messages.reverse()
# 转换为字典列表
return [
{
"id": msg.id,
"sender_type": msg.sender_type,
"sender_name": msg.sender_name,
"content": msg.content,
"msg_type": msg.msg_type,
"created_at": msg.created_at.isoformat() if msg.created_at else "",
}
for msg in messages
]
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/draft
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/draft")
async def generate_draft(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成 AI 草稿回复。
基于当前会话的消息历史,让 Wingman Agent 生成坐席可以采纳的草稿回复。
Args:
conversation_id: 会话ID
agent: 当前坐席(通过认证依赖注入)
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含草稿内容、置信度和推理说明
"""
# 1. 验证坐席身份 + 会话存在性
await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的消息历史(最近 20 条)
messages = await _get_recent_messages(conversation_id, db, limit=20)
# 3. 调用 WingmanService 生成草稿
result = await wingman_service.generate_draft(
conversation_id=conversation_id,
messages=messages,
db=db,
)
return success_response(data=result)
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/summary
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/summary")
async def generate_summary(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成会话自动摘要。
基于完整对话生成结构化摘要,包含问题、原因、解决方案。
通常在结单时调用。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含问题、原因、解决方案
"""
# 1. 验证坐席身份 + 会话存在性
await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的完整消息历史(最多 50 条)
messages = await _get_recent_messages(conversation_id, db, limit=50)
# 3. 调用 WingmanService 生成摘要
result = await wingman_service.generate_summary(
conversation_id=conversation_id,
messages=messages,
)
return success_response(data=result)
# --------------------------------------------------------------------------
# POST /api/conversations/{conversation_id}/wingman/tags
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/wingman/tags")
async def suggest_tags(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
wingman_service: WingmanService = Depends(dep_wingman_service),
):
"""生成自动标签建议。
基于对话内容建议标签分类,包含标签列表、分类和优先级。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
wingman_service: Wingman 服务实例
Returns:
Dict: 统一响应格式,包含建议标签、分类和优先级
"""
# 1. 验证坐席身份 + 会话存在性
conversation = await _validate_conversation(conversation_id, agent, db)
# 2. 从数据库读取该会话的消息历史(最近 20 条)
messages = await _get_recent_messages(conversation_id, db, limit=20)
# 3. 获取已有标签(用于避免重复建议)
existing_tags = {}
if hasattr(conversation, 'tags') and conversation.tags:
existing_tags = conversation.tags if isinstance(conversation.tags, dict) else {}
# 4. 调用 WingmanService 生成标签建议
result = await wingman_service.suggest_tags(
conversation_id=conversation_id,
messages=messages,
existing_tags=existing_tags,
)
return success_response(data=result)
+278
View File
@@ -0,0 +1,278 @@
# =============================================================================
# 企微IT智能服务台 — WebSocket 端点
# =============================================================================
# 说明:提供 WebSocket 端点,供坐席前端和H5用户端建立长连接,实现实时推送。
# 核心功能:
# 1. 接受坐席的 WebSocket 连接请求(含 token 认证)— /ws/{agent_id}
# 2. 接受H5员工的 WebSocket 连接请求(含 token 认证)— /ws/h5/{employee_id}
# 3. 维持连接,监听客户端消息(主要是心跳 ping)
# 4. 连接断开时自动清理注册信息
# 安全(WS-01):
# 握手时从 query param 取 token → 查 Redis 验证 → 不通过则 close(code=4001)
# 防止未授权用户冒充坐席/员工建立 WS 连接
#
# 端点路径:
# - 坐席端:/ws/{agent_id}?token=xxx
# - H5员工端:/ws/h5/{employee_id}?token=xxx
# 为什么不挂 /api 前缀:WebSocket 不是 REST API,不走 Vite 的 /api 代理配置
# =============================================================================
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service
logger = logging.getLogger(__name__)
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
router = APIRouter()
# 认证失败时的 WebSocket 关闭码
# 4001 = 自定义码,表示"未授权"(4000+ 为应用自定义范围)
WS_CLOSE_UNAUTHORIZED = 4001
@router.websocket("/ws/{agent_id}")
async def websocket_endpoint(
websocket: WebSocket,
agent_id: str,
token: str = Query(default="", description="登录 token,用于 WebSocket 认证"),
) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
做什么:
1. 验证 token 有效性(查 Redis
2. 验证 token 与 agent_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager
4. 进入消息接收循环,处理客户端发送的消息
5. 连接断开时清理注册信息
为什么需要 token 认证(WS-01):
- 之前 /ws/{agent_id} 无任何认证,任何人知道 URL 即可冒充任意坐席
- 攻击者可监听所有消息、发送伪造消息,是 P0 级安全漏洞
- 修复后,必须提供与 agent_id 匹配的有效 token 才能建立连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取)
token: 登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# WS-01: Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
# 先 accept 再 close,否则客户端收不到关闭帧
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的坐席信息
# Redis 中存储格式: agent:token:{token} -> agent_user_id
# (与坐席登录 API /api/agents/login 存储格式一致)
try:
stored_agent_id = await cache_service.get(f"agent:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接:token 验证依赖 Redis,无法验证身份
# 如果降级放行,攻击者可在 Redis 故障时用任意 agent_id 冒充坐席
logger.error(f"Redis 查询失败,拒绝 WS 连接: agent_id={agent_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 agent_id 一致性
if not stored_agent_id:
# token 不存在(已过期或伪造)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=token无效或已过期")
return
if stored_agent_id != agent_id:
# token 对应的坐席与请求的 agent_id 不匹配(冒充)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-agent mismatch")
logger.warning(
f"WebSocket 拒绝连接: agent_id={agent_id}, "
f"原因=token对应坐席{stored_agent_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册连接(内部会调用 websocket.accept()
await ws_manager.connect(agent_id, websocket)
logger.info(f"坐席 WebSocket 连接已认证: agent_id={agent_id}")
try:
# 消息接收循环
# 保持连接打开,监听客户端发来的消息
# 即使客户端不发消息,这个循环也必须保持,否则连接会关闭
while True:
# 等待接收客户端消息(阻塞等待)
data = await websocket.receive_json()
# 处理心跳 ping
# 前端每 30 秒发送一次 ping,后端回复 pong
# 作用:检测连接是否存活,防止中间代理(如 Nginx)因超时断开连接
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
logger.debug(f"WebSocket 心跳: agent_id={agent_id}")
# 处理输入指示器 typing 事件
# 前端在用户输入时发送 typing 事件,后端广播给同一会话的其他参与者
elif data.get("type") == "typing":
conversation_id = data.get("conversation_id")
sender_name = data.get("sender_name", agent_id)
if conversation_id:
# 广播给所有坐席(包含 sender_type 和 sender_id
# 前端可据此过滤掉自己的 typing 事件)
await ws_manager.broadcast({
"type": "typing",
"data": {
"conversation_id": conversation_id,
"sender_id": agent_id,
"sender_name": sender_name,
"sender_type": "agent",
}
})
else:
# 未来可扩展处理其他类型的客户端消息
logger.debug(
f"WebSocket 收到未知消息: agent_id={agent_id}, "
f"type={data.get('type', 'unknown')}"
)
except WebSocketDisconnect:
# 客户端主动断开连接(正常行为)
# 清理 ConnectionManager 中的注册信息
ws_manager.disconnect(agent_id)
logger.info(f"坐席断开 WebSocket 连接: agent_id={agent_id}")
except Exception as e:
# 其他异常(如网络错误、JSON 解析错误等)
# 确保注册信息被清理
ws_manager.disconnect(agent_id)
logger.warning(f"WebSocket 异常断开: agent_id={agent_id}, error={e}")
# ==========================================================================
# H5员工 WebSocket 端点
# ==========================================================================
@router.websocket("/ws/h5/{employee_id}")
async def h5_websocket_endpoint(
websocket: WebSocket,
employee_id: str,
token: str = Query(default="", description="H5员工登录 token,用于 WebSocket 认证"),
) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。
做什么:
1. 验证 employee token 有效性(查 Redis
2. 验证 token 与 employee_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager 的员工连接表
4. 进入消息接收循环,处理心跳 ping
5. 连接断开时清理注册信息
为什么需要 H5 WS 连接:
- H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等)
- 当前仅通过 3 秒轮询获取更新,实时性不足
- WS 推送 + 轮询降级,双通道保证消息可达
认证机制(与坐席端一致):
- Redis 中存储格式: employee:token:{token} -> employee_id
- (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取)
token: H5员工登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的员工信息
# Redis 中存储格式: employee:token:{token} -> employee_id
# (与H5登录 API /api/h5/mock-login 存储格式一致)
try:
stored_employee_id = await cache_service.get(f"employee:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接(与坐席端一致的安全策略)
logger.error(f"Redis 查询失败,拒绝 H5 WS 连接: employee_id={employee_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 employee_id 一致性
if not stored_employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=token无效或已过期")
return
if stored_employee_id != employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-employee mismatch")
logger.warning(
f"H5 WebSocket 拒绝连接: employee_id={employee_id}, "
f"原因=token对应员工{stored_employee_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册员工连接(内部会调用 websocket.accept()
await ws_manager.connect_employee(employee_id, websocket)
logger.info(f"H5员工 WebSocket 连接已认证: employee_id={employee_id}")
try:
# 消息接收循环
# H5员工端目前只发送心跳 ping,不需要发送 typing 等事件
while True:
data = await websocket.receive_json()
# 处理心跳 ping
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
logger.debug(f"H5 WebSocket 心跳: employee_id={employee_id}")
else:
logger.debug(
f"H5 WebSocket 收到未知消息: employee_id={employee_id}, "
f"type={data.get('type', 'unknown')}"
)
except WebSocketDisconnect:
# 客户端主动断开连接
ws_manager.disconnect_employee(employee_id)
logger.info(f"H5员工断开 WebSocket 连接: employee_id={employee_id}")
except Exception as e:
# 其他异常
ws_manager.disconnect_employee(employee_id)
logger.warning(f"H5 WebSocket 异常断开: employee_id={employee_id}, error={e}")
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 配置管理模块
# =============================================================================
# 说明:使用 pydantic-settings 从环境变量读取所有配置项
# 优先级:环境变量 > .env 文件 > 默认值
# 所有配置项集中管理,避免散落在代码各处
# =============================================================================
from typing import List
import redis.asyncio as aioredis
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""应用配置类。
使用 pydantic-settings 自动从环境变量读取配置值。
支持 .env 文件自动加载(开发环境便利)。
Attributes:
wecom_corp_id: 企业微信企业ID
wecom_agent_id: 企业微信应用AgentId
wecom_secret: 企业微信应用Secret
wecom_token: 企业微信回调Token
wecom_encoding_aes_key: 企业微信回调EncodingAESKey43位)
database_url: PostgreSQL 数据库连接地址
redis_url: Redis 连接地址
backend_host: 后端监听地址
backend_port: 后端监听端口
cors_origins: CORS 允许的源地址(逗号分隔)
"""
# ----------------------------------------------------------------------
# 企微自建应用配置
# ----------------------------------------------------------------------
# 企业ID(在企微管理后台 > 我的企业 > 企业信息 中查看)
wecom_corp_id: str = "ww1234567890abcdef"
# 应用AgentId(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_agent_id: str = "1000002"
# 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看)
wecom_secret: str = "your-agent-secret"
# 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置)
wecom_token: str = "your-callback-token"
# 回调EncodingAESKey43位字符串,用于消息加解密)
wecom_encoding_aes_key: str = "your-aes-key-43-characters-long-encoding-key"
# ----------------------------------------------------------------------
# 数据库配置
# ----------------------------------------------------------------------
# PostgreSQL 连接地址
# Docker 环境使用容器名 postgres,本地开发使用 localhost
database_url: str = "postgresql://wecom:wecom_secret@localhost:5432/wecom_it_desk"
# ----------------------------------------------------------------------
# Redis 配置
# ----------------------------------------------------------------------
# Redis 连接地址
# Docker 环境使用容器名 redis,本地开发使用 localhost
redis_url: str = "redis://localhost:6379/0"
# ----------------------------------------------------------------------
# 服务配置
# ----------------------------------------------------------------------
# 后端监听地址(0.0.0.0 表示监听所有网卡)
backend_host: str = "0.0.0.0"
# 后端监听端口
backend_port: int = 8000
# CORS 允许的源地址(逗号分隔的字符串)
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:5175"
# ----------------------------------------------------------------------
# AI 服务配置(Dify
# ----------------------------------------------------------------------
# Dify API 端点(兼容 OpenAI Chat Completions 格式)
# 必须通过环境变量 DIFY_API_URL 配置,不设置默认值(防止凭据泄露)
dify_api_url: str = ""
# Dify API Key(格式:base_url|app_id|app_name
# 必须通过环境变量 DIFY_API_KEY 配置,不设置默认值(防止凭据泄露)
dify_api_key: str = ""
# Dify API 请求超时(秒),在网络慢时可调大
dify_timeout: int = 30
# ----------------------------------------------------------------------
# AI Wingman 服务配置(Dify Agent 2 — 坐席端辅助)
# ----------------------------------------------------------------------
# 坐席端 Wingman 专用 Dify API 端点(与员工端 Agent 分开)
# 留空则禁用 Wingman 功能(不影响主流程)
dify_wingman_api_url: str = ""
# 坐席端 Wingman Dify API Key(需要新建 Agent 后填入,留空则禁用)
# 格式:base_url|app_id|app_name(与 dify_api_key 相同格式)
dify_wingman_api_key: str = ""
# Wingman API 请求超时(秒)
dify_wingman_timeout: int = 30
# ----------------------------------------------------------------------
# Mock 登录配置(测试阶段使用,跳过企微 OAuth2)
# ----------------------------------------------------------------------
# 是否启用 Mock 登录(默认 false,生产环境必须关闭)
mock_login_enabled: bool = False
# ----------------------------------------------------------------------
# Pydantic-settings 配置
# ----------------------------------------------------------------------
model_config = SettingsConfigDict(
# 自动从 .env 文件加载环境变量
env_file=".env",
# .env 文件编码
env_file_encoding="utf-8",
# 环境变量名大小写不敏感
case_sensitive=False,
# 额外字段不允许(防止拼写错误的配置被忽略)
extra="ignore",
)
@property
def cors_origins_list(self) -> List[str]:
"""将 CORS 源地址字符串解析为列表。
将逗号分隔的字符串(如 "http://a,http://b"
转换为列表(如 ["http://a", "http://b"]),
方便 FastAPI 的 CORSMiddleware 使用。
Returns:
List[str]: CORS 允许的源地址列表
"""
# 去除每项的前后空格,过滤空字符串
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
def create_redis_client(self) -> aioredis.Redis:
"""创建 Redis 异步客户端实例。
自动附加 protocol=2 参数,强制使用 RESP2 协议。
原因:Windows 版 Redis 3.x 不支持 RESP3 协议(HELLO 命令),
而 redis-py 8.0+ 默认使用 RESP3,会导致连接失败。
全项目统一使用此方法创建 Redis 客户端,避免协议不匹配。
Returns:
aioredis.Redis: 配置好的 Redis 异步客户端
"""
return aioredis.from_url(self.redis_url, protocol=2)
# 创建全局配置实例
# 整个应用通过 from app.config import settings 使用同一个实例
settings = Settings()
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 数据库连接与 Session 管理
# =============================================================================
# 说明:使用 SQLAlchemy 2.0 的异步引擎和会话管理,负责:
# 1. 创建异步数据库引擎(懒加载,支持 PostgreSQL 和 SQLite
# 2. 创建异步会话工厂
# 3. 提供 get_db 依赖注入函数(FastAPI 路由中使用)
# 4. 自动建表(SQLite 本地开发时自动创建所有表)
# =============================================================================
import logging
from typing import AsyncGenerator, Optional
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
# 导入配置(读取数据库连接地址)
from app.config import settings
from app.utils.response import AppException
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# 声明式基类(单独定义,不依赖引擎创建)
# ----------------------------------------------------------------------
# 所有模型类都继承自 Base,SQLAlchemy 通过它来检测所有模型定义
# Alembic 也通过 Base.metadata 来生成迁移脚本
# ----------------------------------------------------------------------
class Base(DeclarativeBase):
"""SQLAlchemy 声明式基类。
所有模型类都继承此类,SQLAlchemy 通过它管理所有表的元数据。
"""
pass
# ----------------------------------------------------------------------
# 懒加载引擎和会话工厂
# ----------------------------------------------------------------------
_engine: Optional[object] = None
_async_session_factory: Optional[async_sessionmaker] = None
_tables_created: bool = False # 标记是否已自动建表
def _is_sqlite() -> bool:
"""判断当前数据库 URL 是否为 SQLite。"""
return "sqlite" in settings.database_url.lower()
def _get_engine():
"""懒加载获取数据库引擎。
支持 PostgreSQL 和 SQLite 两种后端:
- PostgreSQL: 使用 asyncpg 异步驱动,带连接池
- SQLite: 使用 aiosqlite 异步驱动,无需连接池
"""
global _engine
if _engine is None:
db_url = settings.database_url
if _is_sqlite():
# SQLite 异步驱动:aiosqlite
# 不需要连接池,SQLite 是单文件数据库
_engine = create_async_engine(
db_url,
echo=False,
)
logger.info(f"使用 SQLite 数据库: {db_url}")
else:
# PostgreSQL 异步驱动:asyncpg
_engine = create_async_engine(
db_url.replace("postgresql://", "postgresql+asyncpg://"),
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
logger.info(f"使用 PostgreSQL 数据库: {db_url.split('@')[-1]}")
return _engine
def _get_session_factory() -> async_sessionmaker:
"""懒加载获取会话工厂。"""
global _async_session_factory
if _async_session_factory is None:
_async_session_factory = async_sessionmaker(
_get_engine(),
class_=AsyncSession,
expire_on_commit=False,
)
return _async_session_factory
async def _ensure_tables():
"""自动建表(仅 SQLite 本地开发时使用)。
PostgreSQL 环境应使用 Alembic 迁移管理表结构。
SQLite 环境下直接用 metadata.create_all 创建所有表,省去迁移步骤。
"""
global _tables_created
if _tables_created:
return
_tables_created = True
if _is_sqlite():
# 导入所有模型,确保 Base.metadata 知道所有表
import app.models # noqa: F401
engine = _get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("SQLite 自动建表完成")
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话的依赖注入函数。
在 FastAPI 路由中通过 Depends(get_db) 注入数据库会话。
使用 async with 确保会话在使用后正确关闭。
使用 try/finally 确保异常时也能回滚和关闭。
Yields:
AsyncSession: 异步数据库会话
"""
# 首次调用时自动建表(SQLite
await _ensure_tables()
# 创建一个新的数据库会话(懒加载会话工厂)
try:
session_factory = _get_session_factory()
except Exception as e:
logger.error(f"数据库连接失败(无法创建会话工厂): {e}")
raise AppException(1006, f"数据库连接失败: {str(e)}")
async with session_factory() as session:
try:
# 将会话交给路由函数使用
yield session
# 路由函数执行成功后提交事务
await session.commit()
except Exception:
# 路由函数执行失败时回滚事务
await session.rollback()
# 重新抛出异常,让 FastAPI 的异常处理器处理
raise
+266
View File
@@ -0,0 +1,266 @@
# =============================================================================
# 企微IT智能服务台 — 统一认证依赖
# =============================================================================
# 说明:提供统一的认证依赖函数,支持:
# 1. get_current_user: 获取当前用户信息(包含角色)
# 2. require_role: 角色验证装饰器
# 3. require_admin: 管理员权限验证
# =============================================================================
import json
import logging
from dataclasses import dataclass
from functools import wraps
from typing import List, Optional
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings
from app.services.token_service import TokenService
logger = logging.getLogger(__name__)
# HTTP Bearer 认证方案
security = HTTPBearer()
@dataclass
class UserInfo:
"""用户信息数据类。
Attributes:
employee_id: 企微 UserID
name: 用户姓名
department: 部门
avatar: 头像URL
roles: 角色列表
current_role: 当前选择的角色
login_source: 登录来源
"""
employee_id: str
name: str
department: str
avatar: str
roles: List[str]
current_role: str
login_source: str
# Redis 连接池(单例)
_redis_pool: Optional[aioredis.Redis] = None
async def get_redis() -> aioredis.Redis:
"""获取 Redis 连接。
Returns:
aioredis.Redis: Redis 异步客户端
"""
global _redis_pool
if _redis_pool is None:
_redis_pool = settings.create_redis_client()
return _redis_pool
# 共享服务实例(用于 wecom_callback.py 等模块)
# 这些函数提供同步获取服务实例的方式,用于非 FastAPI DI 的场景
def get_shared_redis() -> aioredis.Redis:
"""获取 Redis 客户端(同步版本,用于非 async 场景)。
Returns:
aioredis.Redis: Redis 客户端实例
"""
return settings.create_redis_client()
def get_shared_wecom_service():
"""获取 WecomService 共享实例。
Returns:
WecomService: 企微服务实例
"""
from app.services.wecom_service import WecomService
return WecomService(settings.create_redis_client())
def get_shared_ai_handler():
"""获取 AIHandler 共享实例。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
# FastAPI Depends 函数(用于路由依赖注入)
async def dep_redis() -> aioredis.Redis:
"""Redis 客户端依赖注入。
Returns:
aioredis.Redis: Redis 异步客户端
"""
return await get_redis()
def dep_wecom_service():
"""WecomService 依赖注入。
Returns:
WecomService: 企微服务实例
"""
from app.services.wecom_service import WecomService
return WecomService(settings.create_redis_client())
def dep_ai_handler():
"""AIHandler 依赖注入。
Returns:
AIHandler: AI 处理器实例
"""
from app.services.ai_handler import AIHandler
from app.services.ai_service import AIService
return AIHandler(ai_service=AIService())
def dep_wingman_service():
"""WingmanService 依赖注入。
Returns:
WingmanService: AI Wingman 服务实例
"""
from app.services.wingman_service import WingmanService
return WingmanService()
# 应用生命周期管理函数
async def init_shared_services():
"""初始化共享服务(应用启动时调用)。
创建 Redis 连接池,初始化共享服务实例。
"""
global _redis_pool
_redis_pool = settings.create_redis_client()
logger.info("共享服务初始化完成")
async def cleanup_shared_services():
"""清理共享服务(应用关闭时调用)。
关闭 Redis 连接池。
"""
global _redis_pool
if _redis_pool:
await _redis_pool.close()
_redis_pool = None
logger.info("共享服务清理完成")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> UserInfo:
"""统一认证依赖:从 Token 获取用户信息。
支持新旧两种 Token 格式。
Args:
credentials: HTTP Bearer Token
Returns:
UserInfo: 用户信息
Raises:
HTTPException: Token 无效或已过期
"""
token = credentials.credentials
# 获取 Redis 连接
redis_client = await get_redis()
# 创建 Token 服务
token_service = TokenService(redis_client)
# 获取用户信息
user_info = await token_service.get_user_info(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
return UserInfo(
employee_id=user_info["employee_id"],
name=user_info.get("name", ""),
department=user_info.get("department", ""),
avatar=user_info.get("avatar", ""),
roles=user_info.get("roles", ["user"]),
current_role=user_info.get("current_role", "user"),
login_source=user_info.get("login_source", "portal"),
)
def require_role(*required_roles: str):
"""角色验证装饰器。
检查用户是否拥有指定角色之一。
Args:
*required_roles: 允许的角色列表
Returns:
装饰器函数
Example:
@router.get("/api/admin/dashboard")
@require_role("admin")
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
pass
"""
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):
logger.warning(
f"用户 {current_user.employee_id} 角色不足: "
f"拥有 {current_user.roles}, 需要 {required_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要以下角色之一: {', '.join(required_roles)}",
)
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
def require_admin(func):
"""管理员权限验证装饰器。
等同于 @require_role("admin")。
Example:
@router.get("/api/admin/dashboard")
@require_admin
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
pass
"""
return require_role("admin")(func)
+6
View File
@@ -0,0 +1,6 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统集成模块包
# =============================================================================
# 说明:各外部系统的 API 客户端、数据模型、异常定义等
# 当前已实现:火绒终端安全
#
@@ -0,0 +1,3 @@
# =============================================================================
# 企微IT智能服务台 — 火绒终端安全集成模块包
# =============================================================================
+658
View File
@@ -0,0 +1,658 @@
# =============================================================================
# 企微IT智能服务台 — 火绒终端安全 API 客户端
# =============================================================================
# 说明:封装火绒API的签名、请求、响应处理
# 核心功能:
# 1. HRESS 签名实现(Authorization Header 方式)
# 2. 统一请求封装(超时、重试、异常处理)
# 3. P0 接口:终端列表 _list / 终端详情 _info2 / 高危漏洞 _leak / 病毒事件 _virus_events
# 4. P1 接口:终端隔离/解除 _create(netctrl) / 快速扫描 / 在线终端查询
# 签名算法(来自火绒官方API文档 v1):
# Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
# Signature = urlencode(base64(hmac-sha1(AccessKeySecret,
# AccessKeyId + "\n" + Expires + "\n" + HTTP-METHOD + "\n"
# + Content-MD5 + "\n" + CanonicalizedResource)))
# CanonicalizedResource = API路径(无前导/),含排序后的查询参数
# Content-MD5 = base64(md5_digest(body_bytes)) — RFC2616
# 使用方式:
# client = HuorongClient(access_key_id="...", access_key_secret="...", base_url="...")
# terminals = await client.list_terminals()
# =============================================================================
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import httpx
from .exceptions import (
HuorongApiError,
HuorongAuthError,
HuorongConnectionError,
HuorongError,
)
from .models import (
HuorongApiResponse,
TerminalBasicInfo,
TerminalDetailV2,
TerminalLeakInfo,
VirusEventStats,
VirusHandleResult,
)
logger = logging.getLogger(__name__)
# 默认请求超时(秒)— 火绒内网响应通常在1秒内,3秒足够兜底
# 注意:_virus_events 查询全部终端时可能较慢,需要更长超时
DEFAULT_TIMEOUT = 10.0
# 默认分页大小
DEFAULT_PAGE_SIZE = 20
# 签名有效期(秒)— 请求签名中的 Expires 字段
SIGN_EXPIRES_SECONDS = 300
class HuorongClient:
"""火绒终端安全 API 客户端。
封装了火绒API的签名认证、请求发送和响应解析。
所有方法均为异步(async),使用 httpx.AsyncClient 发送请求。
签名方式:HRESS Authorization Header
参考:火绒终端安全管理系统API说明文档 v1
Attributes:
access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID
access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key
base_url: 火绒API内网地址(如 http://huorong.oa.servyou-it.com:8080
timeout: 请求超时秒数
"""
def __init__(
self,
access_key_id: str,
access_key_secret: str,
base_url: str,
timeout: float = DEFAULT_TIMEOUT,
):
"""初始化火绒API客户端。
Args:
access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID
access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key
base_url: 火绒API内网地址(不含尾部斜杠)
timeout: 请求超时秒数,默认3秒
"""
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
# 确保 base_url 不以 / 结尾,拼接路径时统一加 /
self.base_url = base_url.rstrip("/")
self.timeout = timeout
# ======================================================================
# 签名实现(HRESS Authorization Header 方式)
# ======================================================================
def _compute_content_md5(self, body_bytes: bytes) -> str:
"""计算请求体的 Content-MD5RFC2616)。
算法步骤:
1. 计算请求体的 MD5 二进制摘要(128位)
2. 对二进制摘要进行 base64 编码
注意:不是对32位十六进制字符串编码,而是对原始二进制摘要编码。
Args:
body_bytes: 请求体的字节内容
Returns:
str: base64 编码的 MD5 摘要
"""
md5_digest = hashlib.md5(body_bytes).digest()
return base64.b64encode(md5_digest).decode("utf-8")
def _build_canonicalized_resource(self, path: str) -> str:
"""构建 CanonicalizedResource。
根据火绒API文档:
1. 将 CanonicalizedResource 置为空字符串
2. 设置要访问的资源路径(去掉前导 /),如 "api/clnts/_list"
3. 如果请求包含子资源(查询参数),按字典序排列,
以 & 为分隔符生成子资源字符串,末尾添加 ? 和子资源字符串
示例:
- /api/clnts/_list → "api/clnts/_list"
- /api/group/_info?group_id=1 → "api/group/_info?group_id=1"
Args:
path: 请求路径(如 /api/clnts/_list
Returns:
str: CanonicalizedResource 字符串
"""
# 去掉前导 /
return path.lstrip("/")
def _sign_request(
self,
method: str,
path: str,
body_bytes: bytes = b"",
) -> Dict[str, str]:
"""生成火绒API请求签名(HRESS Authorization Header 方式)。
签名算法(来自火绒官方API文档):
┌─────────────────────────────────────────────────────────────────┐
│ Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature │
│ │
│ Signature = urlencode(base64(hmac-sha1(AccessKeySecret, │
│ AccessKeyId + "\\n"
│ + Expires + "\\n"
│ + HTTP-METHOD + "\\n"
│ + Content-MD5 + "\\n"
│ + CanonicalizedResource))) │
└─────────────────────────────────────────────────────────────────┘
其中:
- AccessKeyId: 标识用户身份
- Expires: Unix 时间戳,签名过期时间(当前时间 + 300秒)
- HTTP-METHOD: POST(火绒API统一使用 POST
- Content-MD5: 请求体的 RFC2616 MD5base64编码)
- CanonicalizedResource: API资源路径(去掉前导/)
Args:
method: HTTP方法(POST
path: 请求路径(如 /api/clnts/_list
body_bytes: 请求体字节内容
Returns:
Dict[str, str]: 包含 Authorization 和 Content-Type 的 Header 字典
"""
# 1. 计算过期时间(Unix时间戳)
expires = str(int(time.time()) + SIGN_EXPIRES_SECONDS)
# 2. 计算 Content-MD5RFC2616: MD5 二进制摘要 → base64
content_md5 = self._compute_content_md5(body_bytes) if body_bytes else ""
# 3. 构建 CanonicalizedResource(去掉前导 /
canonicalized_resource = self._build_canonicalized_resource(path)
# 4. 构建签名字符串
string_to_sign = (
self.access_key_id + "\n"
+ expires + "\n"
+ method + "\n"
+ content_md5 + "\n"
+ canonicalized_resource
)
# 5. HMAC-SHA1 签名 → base64 编码 → URL 编码
signature_raw = hmac.new(
self.access_key_secret.encode("utf-8"), # 密钥
string_to_sign.encode("utf-8"), # 待签名字符串
hashlib.sha1, # 算法
).digest()
signature_b64 = base64.b64encode(signature_raw).decode("utf-8")
signature_encoded = quote(signature_b64, safe="")
# 6. 拼接 Authorization Header
# 格式: "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
authorization = f"HRESS{self.access_key_id}:{expires}:{signature_encoded}"
return {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
}
# ======================================================================
# 通用请求方法
# ======================================================================
async def _request(
self,
path: str,
body: Optional[Dict[str, Any]] = None,
) -> HuorongApiResponse:
"""发送签名请求到火绒API。
统一处理:
1. HRESS 签名 Authorization Header 生成
2. HTTP请求发送(POST,超时控制)
3. 响应解析和错误码处理
4. 异常分类(认证/连接/API业务错误)
Args:
path: API路径(如 /api/clnts/_list
body: 请求体字典(可选)
Returns:
HuorongApiResponse: 火绒API响应
Raises:
HuorongConnectionError: 网络不通或超时
HuorongAuthError: 签名验证失败
HuorongApiError: 火绒API返回业务错误
"""
# 构建完整URL
url = f"{self.base_url}{path}"
# 序列化请求体为字节(签名基于字节内容)
body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") if body else b"{}"
# 生成签名 Header
headers = self._sign_request("POST", path, body_bytes)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
logger.debug(
f"火绒API请求: POST {url}\n"
f" AccessKeyID: {self.access_key_id}\n"
f" Path: {path}\n"
f" Body: {body_bytes[:200].decode('utf-8', errors='replace')}\n"
f" Authorization: {headers.get('Authorization', 'N/A')[:60]}..."
)
response = await client.post(url, headers=headers, content=body_bytes)
# HTTP层面错误
if response.status_code == 401:
raise HuorongAuthError()
if response.status_code != 200:
raise HuorongApiError(
code=response.status_code,
message=f"HTTP {response.status_code}: {response.text[:200]}",
)
# 解析JSON响应
resp_data = response.json()
api_resp = HuorongApiResponse(**resp_data)
# 火绒业务错误码处理
# 官方文档定义的错误码:
# - errno=0: 成功
# - errno=1: 认证失败
# - errno=2: 参数错误
# - errno=3: 服务器内部错误
# - errno=4: API未授权
if api_resp.errcode != 0:
if api_resp.errcode == 1 or api_resp.errcode in (401, 403):
raise HuorongAuthError(f"认证/权限失败: {api_resp.errmsg}")
if api_resp.errcode == 4:
raise HuorongApiError(
code=api_resp.errcode,
message=f"API未授权: {api_resp.errmsg}",
)
raise HuorongApiError(
code=api_resp.errcode,
message=api_resp.errmsg,
)
return api_resp
except httpx.TimeoutException:
raise HuorongConnectionError(f"火绒API请求超时({self.timeout}秒): {url}")
except httpx.ConnectError:
raise HuorongConnectionError(f"无法连接火绒服务器: {url}")
except (HuorongAuthError, HuorongApiError, HuorongConnectionError):
# 已分类异常,直接向上抛出
raise
except Exception as e:
# 未预期异常,包装为通用错误
logger.error(f"火绒API未预期异常: {type(e).__name__}: {e}")
raise HuorongError(code=-1, message=f"火绒API调用异常: {e}")
# ======================================================================
# P0 接口:查询能力
# ======================================================================
async def list_terminals(
self,
group_id: Optional[str] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询终端基本信息列表。
火绒API: POST /api/clnts/_list
官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0)
本方法将 page/per_page 转换为 limit/offset,保持外部接口一致。
Args:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(总数) 和 items(TerminalBasicInfo列表)
"""
# 火绒API使用 limit/offset 分页,不是 page/per_page
limit = min(per_page, 200) # 火绒限制最大200
offset = (page - 1) * limit
body: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if group_id:
body["group_id"] = int(group_id)
resp = await self._request("/api/clnts/_list", body)
# 解析响应数据
data = resp.data or {}
raw_items = data.get("list", [])
total = data.get("total", 0)
items = [TerminalBasicInfo(**item) for item in raw_items]
return {
"total": total,
"items": items,
}
async def get_terminal_detail(
self,
client_id: str,
optional_fields: Optional[List[str]] = None,
) -> TerminalDetailV2:
"""获取终端详细信息v2。
火绒API: POST /api/clnts/_info2
用途:获取终端硬件/软件/资产/网络配置等详细信息
Args:
client_id: 终端唯一ID
optional_fields: 需要返回的可选信息块
可选值: hardware, software, assets, netconf
默认全部返回
Returns:
TerminalDetailV2: 终端详细信息
"""
if optional_fields is None:
optional_fields = ["hardware", "software", "assets", "netconf"]
body = {
"client_id": client_id,
"optional_fields": optional_fields,
}
resp = await self._request("/api/clnts/_info2", body)
data = resp.data or {}
return TerminalDetailV2(**data)
async def list_terminal_leaks(
self,
group_id: Optional[str] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询存在高危漏洞未修复的终端。
火绒API: POST /api/clnts/_leak
官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0)
说明:返回的是"存在高危漏洞的终端列表",不是漏洞详情。
每条记录是终端信息,字段名与 _list 不同:
- cid (非 client_id)
- hostname (非 computer_name)
- ip_addr (非 local_ip)
- stat (1=离线,2=在线,3=异常,非 is_online 布尔值)
外层有 all_client(终端总数)和 risk_client(高危终端数)统计。
Args:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(高危终端总数), risk_client(高危终端数),
all_client(全部终端数) 和 items(TerminalLeakInfo列表)
"""
# 火绒API使用 limit/offset 分页
limit = min(per_page, 200)
offset = (page - 1) * limit
body: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if group_id:
body["group_id"] = int(group_id)
resp = await self._request("/api/clnts/_leak", body)
# 解析响应数据
data = resp.data or {}
raw_items = data.get("list", [])
# _leak 不返回 total,但有 all_client 和 risk_client 统计
all_client = data.get("all_client", 0)
risk_client = data.get("risk_client", 0)
items = [TerminalLeakInfo(**item) for item in raw_items]
return {
"total": risk_client, # 高危终端总数 = risk_client
"all_client": all_client, # 全部终端数
"risk_client": risk_client, # 高危终端数
"items": items,
}
async def get_virus_events(
self,
client_id: Optional[str] = None,
group_id: Optional[str] = None,
query_type: int = 2,
begin_time: Optional[int] = None,
end_time: Optional[int] = None,
page: int = 1,
per_page: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询终端病毒事件统计。
火绒API: POST /api/clnts/_virus_events
官方参数:
- type: 查询类型(必填)
0=使用终端唯一标识查询(client_id字段必填)
1=使用分组ID查询(group_id字段必填)
2=查询全部终端日志(client_id和group_id字段可忽略)
- client_id: 终端唯一标识
- group_id: 分组ID
- begin_time/end_time: 日志范围时间(Unix时间戳,默认全部时间)
- limit/offset: 分页参数
说明:返回终端维度的病毒日志统计,含 count(总数) 和
result{success/fail/ignored/trusted}(处理结果明细)。
Args:
client_id: 终端唯一IDtype=0时必填)
group_id: 分组IDtype=1时必填)
query_type: 查询类型,默认2(查全部)
begin_time: 日志开始时间(Unix时间戳,可选)
end_time: 日志结束时间(Unix时间戳,可选)
page: 页码(从1开始,内部转换为offset)
per_page: 每页条数(内部转换为limit)
Returns:
Dict: 包含 total(总数) 和 items(VirusEventStats列表)
"""
# 火绒API使用 limit/offset 分页
limit = min(per_page, 200)
offset = (page - 1) * limit
body: Dict[str, Any] = {
"type": query_type,
"limit": limit,
"offset": offset,
}
# 根据查询类型添加可选参数
if query_type == 0 and client_id:
body["client_id"] = client_id
if query_type in (0, 1) and group_id:
body["group_id"] = int(group_id)
# 时间范围过滤
if begin_time:
body["begin_time"] = begin_time
if end_time:
body["end_time"] = end_time
resp = await self._request("/api/clnts/_virus_events", body)
data = resp.data or {}
raw_items = data.get("list", [])
total = data.get("total", 0)
items = [VirusEventStats(**item) for item in raw_items]
return {
"total": total,
"items": items,
}
# ======================================================================
# P1 接口:控制能力
# ======================================================================
async def isolate_terminal(
self,
client_ids: List[str],
) -> Dict[str, Any]:
"""隔离终端(断网)。
火绒API: POST /api/task/_create (type=netctrl, net_isolation=true)
安全等级: 🔴 高危操作,调用方必须确保:
1. 仅 admin 角色可调用
2. 已完成二次确认
3. 已记录操作原因
Args:
client_ids: 目标终端ID列表
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "netctrl",
"net_isolation": True,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.warning(f"火绒终端隔离操作: client_ids={client_ids}")
return resp.data or {}
async def unisolate_terminal(
self,
client_ids: List[str],
) -> Dict[str, Any]:
"""解除终端隔离(恢复网络)。
火绒API: POST /api/task/_create (type=netctrl, net_isolation=false)
Args:
client_ids: 目标终端ID列表
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "netctrl",
"net_isolation": False,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端解除隔离: client_ids={client_ids}")
return resp.data or {}
async def create_scan_task(
self,
client_ids: List[str],
scan_type: str = "quick_scan",
) -> Dict[str, Any]:
"""创建终端扫描任务。
火绒API: POST /api/task/_create
扫描类型: quick_scan(快速扫描) / full_scan(全盘扫描) / custom_scan(自定义扫描)
Args:
client_ids: 目标终端ID列表
scan_type: 扫描类型,默认快速扫描
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": scan_type,
"clients": client_ids,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端扫描任务: type={scan_type}, client_ids={client_ids}")
return resp.data or {}
async def send_notification(
self,
client_ids: List[str],
content: str,
) -> Dict[str, Any]:
"""向终端发送通知。
火绒API: POST /api/task/_create (type=message)
Args:
client_ids: 目标终端ID列表
content: 通知内容
Returns:
Dict: 火绒API响应的data部分
"""
body = {
"type": "message",
"clients": client_ids,
"content": content,
}
resp = await self._request("/api/task/_create", body)
logger.info(f"火绒终端通知: client_ids={client_ids}, content={content[:50]}")
return resp.data or {}
# ======================================================================
# 测试连接
# ======================================================================
async def test_connection(self) -> Dict[str, Any]:
"""测试火绒API连接是否正常。
使用 _list 接口(page=1, per_page=1)进行轻量级连接测试,
验证签名是否正确、网络是否可达。
Returns:
Dict: 包含 success(bool) 和 message(str)
"""
try:
result = await self.list_terminals(page=1, per_page=1)
return {
"success": True,
"message": f"连接成功,共 {result.get('total', 0)} 个终端",
"total_terminals": result.get("total", 0),
}
except HuorongAuthError as e:
return {
"success": False,
"message": f"认证失败: {e.message}",
}
except HuorongConnectionError as e:
return {
"success": False,
"message": f"连接失败: {e.message}",
}
except HuorongError as e:
return {
"success": False,
"message": f"测试失败: {e.message}",
}
@@ -0,0 +1,87 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成配置管理
# =============================================================================
# 说明:从系统配置表(system_configs)读取火绒 AccessKey/Secret/BaseUrl
# 构建火绒API客户端实例
# =============================================================================
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
from .client import HuorongClient
from .exceptions import HuorongConfigError
logger = logging.getLogger(__name__)
# 火绒配置在 system_configs 表中的 key 前缀
HUORONG_CONFIG_PREFIX = "integration_huorong_"
async def get_huorong_client(db: AsyncSession) -> HuorongClient:
"""从系统配置表构建火绒API客户端。
读取 integration_huorong_ 前缀的配置项,构建 HuorongClient 实例。
如果任何必填配置缺失,抛出 HuorongConfigError。
Args:
db: 数据库会话
Returns:
HuorongClient: 已配置的火绒API客户端实例
Raises:
HuorongConfigError: AccessKey ID/Secret/Base URL 任一缺失
"""
# 读取三个必填配置
result = await db.execute(
select(SystemConfig).where(
SystemConfig.config_key.startswith(HUORONG_CONFIG_PREFIX)
)
)
configs = list(result.scalars().all())
# 构建 key→value 映射
config_map = {cfg.config_key: cfg.config_value for cfg in configs}
access_key_id = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_id", "")
access_key_secret = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_secret", "")
base_url = config_map.get(f"{HUORONG_CONFIG_PREFIX}base_url", "")
# 校验必填项
if not access_key_id or not access_key_secret or not base_url:
missing = []
if not access_key_id:
missing.append("AccessKey ID")
if not access_key_secret:
missing.append("AccessKey Secret")
if not base_url:
missing.append("Base URL")
raise HuorongConfigError(
f"火绒集成配置不完整,缺失: {', '.join(missing)},请先在管理后台完成配置"
)
return HuorongClient(
access_key_id=access_key_id,
access_key_secret=access_key_secret,
base_url=base_url,
)
async def is_huorong_configured(db: AsyncSession) -> bool:
"""检查火绒集成是否已完整配置。
Args:
db: 数据库会话
Returns:
bool: 三项配置均存在且非空时返回 True
"""
try:
client = await get_huorong_client(db)
return bool(client.access_key_id and client.access_key_secret and client.base_url)
except HuorongConfigError:
return False
@@ -0,0 +1,63 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成自定义异常
# =============================================================================
# 说明:火绒API调用中可能抛出的各种异常类型
# 包含:认证错误、连接超时、API错误码等
# =============================================================================
class HuorongError(Exception):
"""火绒集成基础异常。
所有火绒相关异常的父类,便于统一捕获处理。
Attributes:
code: 错误码(火绒API返回的errcode,或自定义错误码)
message: 错误描述
"""
def __init__(self, code: int = -1, message: str = "火绒API调用失败"):
self.code = code
self.message = message
super().__init__(f"[HuorongError:{code}] {message}")
class HuorongAuthError(HuorongError):
"""火绒认证失败异常。
场景:AccessKey ID/Secret 无效、签名校验失败、权限不足
火绒API返回 errcode=401 或签名相关错误时抛出。
"""
def __init__(self, message: str = "火绒API认证失败,请检查AccessKey配置"):
super().__init__(code=401, message=message)
class HuorongConnectionError(HuorongError):
"""火绒连接失败异常。
场景:内网地址不通、超时、DNS解析失败
"""
def __init__(self, message: str = "无法连接火绒服务器,请检查网络和Base URL配置"):
super().__init__(code=502, message=message)
class HuorongConfigError(HuorongError):
"""火绒配置缺失异常。
场景:AccessKey ID/Secret/Base URL 未在系统配置中设置
"""
def __init__(self, message: str = "火绒集成未配置,请先在管理后台设置AccessKey和Base URL"):
super().__init__(code=400, message=message)
class HuorongApiError(HuorongError):
"""火绒API业务错误。
场景:火绒API返回非0 errcode(如参数错误、终端不存在等)
"""
def __init__(self, code: int, message: str):
super().__init__(code=code, message=message)
+373
View File
@@ -0,0 +1,373 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成数据模型
# =============================================================================
# 说明:火绒API请求/响应的 Pydantic 数据模型
# 包含:终端信息、漏洞信息、病毒事件、任务下发等
# =============================================================================
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
# ==========================================================================
# 通用响应模型
# ==========================================================================
class HuorongApiResponse(BaseModel):
"""火绒API统一响应模型。
火绒所有API返回格式一致(官方API文档 v1):
成功时: { "errno": 0, "errmsg": "", "data": { ... } }
失败时: { "errno": 1, "errmsg": "Authentication failed" }
官方错误码定义:
- errno=0: 成功
- errno=1: 认证失败
- errno=2: 参数错误
- errno=3: 服务器内部错误
- errno=4: API未授权
注意:火绒API始终使用 errno(不是 errcode)。
使用 model_validator 在验证前将 errno 归一化为 errcode
保持内部代码统一使用 errcode 字段。
Attributes:
errcode: 错误码,0表示成功(从 errno 归一化而来)
errmsg: 错误描述(成功时为空字符串)
data: 业务数据(成功时非None)
"""
@model_validator(mode='before')
@classmethod
def normalize_error_fields(cls, data: Any) -> Any:
"""将火绒API返回的 errno 字段归一化为 errcode。
火绒API在认证失败等错误场景下返回 errno 而非 errcode
此验证器在 Pydantic 字段校验前将 errno 转换为 errcode
统一后续处理逻辑。
Args:
data: 原始输入数据(通常为dict)
Returns:
归一化后的数据
"""
if isinstance(data, dict) and 'errno' in data and 'errcode' not in data:
data['errcode'] = data.pop('errno')
return data
errcode: int = Field(..., description="错误码,0=成功")
errmsg: str = Field(default="ok", description="错误描述")
data: Optional[Any] = Field(default=None, description="业务数据")
# ==========================================================================
# 终端基本信息 — /api/clnts/_list 返回
# ==========================================================================
class TerminalBasicInfo(BaseModel):
"""终端基本信息(_list 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
注意:API返回的字段名与之前猜测不同,已根据官方文档修正。
Attributes:
id: 内部数据库ID
client_id: 终端唯一ID(40位十六进制字符串,用于所有任务下发)
client_name: 客户端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP(客户端连接控制中心使用的IP)
mac: MAC地址
group_id: 分组ID
os_version: 操作系统版本
version: 火绒客户端版本
definitions: 病毒库更新时间
is_online: 在线状态
last_connect_time: 最后连接时间(Unix时间戳)
last_seen_time: 最后可见时间(Unix时间戳)
first_appear_time: 首次出现时间(Unix时间戳)
"""
id: Optional[int] = Field(default=None, description="内部数据库ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="客户端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
group_id: Optional[Any] = Field(default=None, description="分组IDint或str")
os_version: str = Field(default="", description="操作系统版本")
version: str = Field(default="", description="火绒客户端版本")
definitions: str = Field(default="", description="病毒库更新时间")
is_online: bool = Field(default=False, description="在线状态")
last_connect_time: Optional[int] = Field(default=None, description="最后连接时间")
last_seen_time: Optional[int] = Field(default=None, description="最后可见时间")
first_appear_time: Optional[int] = Field(default=None, description="首次出现时间")
class TerminalListRequest(BaseModel):
"""终端列表查询请求。
Attributes:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始)
per_page: 每页条数
"""
group_id: Optional[str] = Field(default=None, description="分组ID")
page: int = Field(default=1, ge=1, description="页码")
per_page: int = Field(default=20, ge=1, le=100, description="每页条数")
# ==========================================================================
# 终端详细信息v2 — /api/clnts/_info2 返回
# ==========================================================================
class HardwareInfo(BaseModel):
"""终端硬件信息。
Attributes:
cpu: CPU信息
memory: 内存信息
disk: 磁盘信息
motherboard: 主板信息
network_card: 网卡信息
"""
cpu: str = Field(default="", description="CPU信息")
memory: str = Field(default="", description="内存信息")
disk: str = Field(default="", description="磁盘信息")
motherboard: str = Field(default="", description="主板信息")
network_card: str = Field(default="", description="网卡信息")
class SoftwareInfo(BaseModel):
"""已安装软件条目。
Attributes:
name: 软件名称
version: 版本号
publisher: 发布者
"""
name: str = Field(default="", description="软件名称")
version: str = Field(default="", description="版本号")
publisher: str = Field(default="", description="发布者")
class AssetInfo(BaseModel):
"""资产信息。
Attributes:
asset_tag: 资产标签
serial_number: 序列号
"""
asset_tag: str = Field(default="", description="资产标签")
serial_number: str = Field(default="", description="序列号")
class NetworkConfig(BaseModel):
"""网络配置信息。
Attributes:
ip: IP地址
gateway: 网关
dns: DNS服务器
adapter_info: 网卡适配器信息
"""
ip: str = Field(default="", description="IP地址")
gateway: str = Field(default="", description="网关")
dns: str = Field(default="", description="DNS服务器")
adapter_info: str = Field(default="", description="网卡适配器信息")
class TerminalDetailV2(BaseModel):
"""终端详细信息v2(_info2 接口返回)。
通过 optional_fields 参数指定需要返回的信息块:
- hardware: 硬件信息
- software: 已安装软件
- assets: 资产信息
- netconf: 网络配置
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
hardware: 硬件信息(可选)
software: 已安装软件列表(可选)
assets: 资产信息(可选)
netconf: 网络配置(可选)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件信息")
software: Optional[List[SoftwareInfo]] = Field(default=None, description="已安装软件")
assets: Optional[AssetInfo] = Field(default=None, description="资产信息")
netconf: Optional[NetworkConfig] = Field(default=None, description="网络配置")
class TerminalDetailRequest(BaseModel):
"""终端详细信息查询请求。
Attributes:
client_id: 终端唯一ID
optional_fields: 需要返回的可选信息块列表
"""
client_id: str = Field(..., description="终端唯一ID")
optional_fields: List[str] = Field(
default_factory=lambda: ["hardware", "software", "assets", "netconf"],
description="可选信息块: hardware/software/assets/netconf",
)
# ==========================================================================
# 漏洞信息 — /api/clnts/_leak 返回
# 说明:_leak 接口返回的是"存在高危漏洞未修复的终端列表",
# 每条记录是终端信息(非漏洞详情),API不返回具体漏洞CVE列表。
# 外层还有 all_client(终端总数)和 risk_client(高危终端数)统计。
# ==========================================================================
class TerminalLeakInfo(BaseModel):
"""存在高危漏洞的终端信息(_leak 接口返回的每条记录)。
注意:_leak 返回的是终端维度数据,不是漏洞维度。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名不同!
Attributes:
cid: 终端唯一ID_leak 中叫 cid_list 中叫 client_id
hostname: 计算机名(_leak 中叫 hostname_list 中叫 computer_name
client_name: 终端名称
group_name: 分组名称
group_id: 分组ID
ip_addr: 本地IP_leak 中叫 ip_addr_list 中叫 local_ip
call_ip: 连接IP_leak 中叫 call_ip_list 中叫 connect_ip
mac: MAC地址
osver: 操作系统版本(_leak 中叫 osver_list 中叫 os_version
os_type: 终端类型(如 Windows
prodver: 火绒客户端版本(_leak 中叫 prodver_list 中叫 version
virdb: 病毒库版本(Unix时间戳,_leak 中叫 virdb_list 中叫 definitions
stat: 在线状态码(1=离线, 2=在线, 3=异常,_list 中是 is_online 布尔值)
"""
cid: str = Field(..., description="终端唯一ID")
hostname: str = Field(default="", description="计算机名")
client_name: str = Field(default="", description="终端名称")
group_name: str = Field(default="", description="分组名称")
group_id: Optional[Any] = Field(default=None, description="分组ID")
ip_addr: str = Field(default="", description="本地IP")
call_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
osver: str = Field(default="", description="操作系统版本")
os_type: str = Field(default="", description="终端类型")
prodver: str = Field(default="", description="火绒客户端版本")
virdb: Optional[Any] = Field(default=None, description="病毒库版本(Unix时间戳)")
stat: int = Field(default=1, description="在线状态码: 1=离线 2=在线 3=异常")
# ==========================================================================
# 病毒事件 — /api/clnts/_virus_events 返回
# 说明:_virus_events 返回终端维度的病毒日志统计,
# 含总数(count)和4种处理结果(result)的明细。
# 请求需指定 type: 0=按client_id查, 1=按group_id查, 2=查全部
# ==========================================================================
class VirusHandleResult(BaseModel):
"""病毒事件处理结果统计。
Attributes:
success: 处理成功数
fail: 处理失败数
ignored: 暂不处理数
trusted: 已信任数
"""
success: int = Field(default=0, description="处理成功数")
fail: int = Field(default=0, description="处理失败数")
ignored: int = Field(default=0, description="暂不处理数")
trusted: int = Field(default=0, description="已信任数")
class VirusEventStats(BaseModel):
"""终端病毒事件统计(_virus_events 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名基本一致。
Attributes:
group_id: 分组ID
client_id: 终端唯一ID
client_name: 终端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP
mac: MAC地址
count: 病毒日志总数
result: 处理结果统计(success/fail/ignored/trusted
"""
group_id: Optional[Any] = Field(default=None, description="分组ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="终端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
count: int = Field(default=0, description="病毒日志总数")
result: Optional[VirusHandleResult] = Field(default=None, description="处理结果统计")
# ==========================================================================
# 终端任务 — /api/task/_create
# ==========================================================================
class TaskCreateRequest(BaseModel):
"""终端任务创建请求。
支持的任务类型:
- quick_scan: 快速扫描
- full_scan: 全盘扫描
- custom_scan: 自定义扫描
- netctrl: 终端隔离/解除
- message: 发送通知
Attributes:
task_type: 任务类型
client_ids: 目标终端ID列表
net_isolation: 是否隔离(仅 netctrl 类型有效)
message_content: 通知内容(仅 message 类型有效)
"""
task_type: str = Field(..., description="任务类型: quick_scan/full_scan/custom_scan/netctrl/message")
client_ids: List[str] = Field(..., min_length=1, description="目标终端ID列表")
net_isolation: Optional[bool] = Field(default=None, description="是否隔离(仅netctrl类型)")
message_content: Optional[str] = Field(default=None, description="通知内容(仅message类型)")
# ==========================================================================
# 终端安全画像(聚合模型,供前端直接使用)
# ==========================================================================
class TerminalSecurityProfile(BaseModel):
"""终端安全画像(聚合模型)。
将终端基本信息+安全状态聚合成一个模型,供坐席端直接展示。
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
ip: 本地IP
mac: MAC地址
os_version: 操作系统版本
is_online: 在线状态
group_name: 分组名称
hardware: 硬件概要
high_risk_leaks: 高危漏洞数
uncleaned_virus: 未处理病毒事件数
security_score: 安全评分(0-100,综合漏洞+病毒+在线状态)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
ip: str = Field(default="", description="本地IP")
mac: str = Field(default="", description="MAC地址")
os_version: str = Field(default="", description="操作系统版本")
is_online: bool = Field(default=False, description="在线状态")
group_name: str = Field(default="", description="分组名称")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件概要")
high_risk_leaks: int = Field(default=0, description="高危漏洞数")
uncleaned_virus: int = Field(default=0, description="未处理病毒事件数")
security_score: int = Field(default=100, description="安全评分(0-100)")
@@ -0,0 +1,15 @@
# 联软LV7000 API集成模块
"""
提供联软LV7000终端安全管理系统的API客户端。
认证方式:三层认证(IP白名单 + 账号密码 + Token
- 第一层:IP白名单(在联软后台配置WhiteListServerIp
- 第二层:账号密码(ApiAccount + ApiPassword
- 第三层:一次性Token(先调getToken获取,30分钟有效)
核心P0接口:
- queryDevByParams:按条件查询终端(含strusername员工账号映射)
- getDevAllInfo:终端详细信息(硬件+软件+资产+网络)
- getUserInfoByAccount:按账号查用户信息
- getAllOrgInfo:全量组织架构同步
"""
+604
View File
@@ -0,0 +1,604 @@
# 联软LV7000 API客户端
"""
联软LV7000终端安全管理系统 API 客户端。
认证流程:
1. 第一层:IP白名单(在联软后台配置,调用时自动生效)
2. 第二层:账号密码(ApiAccount + ApiPassword
3. 第三层:Token(先调getToken获取,30分钟有效,自动缓存+刷新)
接口调用方式:
- GET请求:参数通过query string传递
- POST请求:参数通过form-data传递
- 统一携带 token + apiAccount + apiPassword + validatekey
使用示例:
client = LianruanClient(base_url, api_account, api_password, validate_key)
terminals = await client.query_dev_by_params(strusername="songxian")
detail = await client.get_dev_all_info(strdevname="IT-SONGXIAN")
"""
import time
import logging
from typing import Optional
import httpx
from app.integrations.lianruan.exceptions import (
LianruanApiError,
LianruanAuthError,
LianruanConnectionError,
)
from app.integrations.lianruan.models import (
TerminalBasicInfo,
TerminalAllInfo,
UserInfo,
OrgDeptInfo,
OnlineStatus,
TerminalSoftwareInfo,
)
logger = logging.getLogger(__name__)
class LianruanClient:
"""联软LV7000 API客户端。
Attributes:
base_url: 联软API地址,如 http://192.168.x.x:30098
api_account: API账号
api_password: API密码
validate_key: 验证密钥
_token: 缓存的Token
_token_expire: Token过期时间戳
"""
def __init__(
self,
base_url: str,
api_account: str,
api_password: str,
validate_key: str = "",
timeout: float = 30.0,
):
self.base_url = base_url.rstrip("/")
self.api_account = api_account
self.api_password = api_password
self.validate_key = validate_key
self.timeout = timeout
# Token缓存(30分钟有效,提前5分钟刷新)
self._token: str = ""
self._token_expire: float = 0.0
# httpx异步客户端(连接池复用)
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建httpx异步客户端(懒初始化+连接池复用)"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=self.timeout,
verify=False, # 内网自签证书
)
return self._client
async def close(self) -> None:
"""关闭httpx客户端,释放连接池"""
if self._client and not self._client.is_closed:
await self._client.aclose()
# ==========================================================================
# Token管理
# ==========================================================================
async def _ensure_token(self) -> str:
"""确保Token有效,过期则自动刷新。
联软Token默认30分钟有效,提前5分钟刷新。
Returns:
str: 有效的Token字符串
"""
now = time.time()
# Token还有5分钟以上有效期,直接复用
if self._token and now < self._token_expire - 300:
return self._token
# 重新获取Token
logger.info("联软Token过期或为空,正在刷新...")
try:
client = await self._get_client()
url = f"{self.base_url}/token"
params = {
"act": "getToken",
"apiAccount": self.api_account,
"apiPassword": self.api_password,
}
if self.validate_key:
params["validatekey"] = self.validate_key
resp = await client.get(url, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "SUCCESS":
raise LianruanAuthError(
f"获取Token失败: {data.get('msg', '未知错误')}",
detail=str(data),
)
self._token = data.get("data", data.get("rows", ""))
if not self._token:
# 有些版本返回格式不同
self._token = str(data.get("token", ""))
# 30分钟有效期
self._token_expire = now + 1800
logger.info("联软Token刷新成功,有效期至 %s",
time.strftime("%H:%M:%S", time.localtime(self._token_expire)))
return self._token
except httpx.ConnectError as e:
raise LianruanConnectionError(
f"无法连接联软服务器 {self.base_url}: {e}",
detail=str(e),
)
except httpx.TimeoutException as e:
raise LianruanConnectionError(
f"连接联软服务器超时: {e}",
detail=str(e),
)
# ==========================================================================
# 通用请求方法
# ==========================================================================
async def _request(
self,
path: str,
act: str,
params: Optional[dict] = None,
method: str = "GET",
) -> dict:
"""发送请求到联软API。
自动携带认证参数(token + apiAccount + apiPassword)。
Args:
path: API路径,如 /terminal 或 /querydeptuser
act: 操作类型,如 queryDevByParams
params: 额外业务参数
method: 请求方法(GET/POST
Returns:
dict: 联软API返回的JSON数据
Raises:
LianruanAuthError: 认证失败
LianruanApiError: 业务错误
LianruanConnectionError: 网络错误
"""
token = await self._ensure_token()
client = await self._get_client()
# 构建完整参数:认证参数 + 业务参数
full_params = {
"act": act,
"apiAccount": self.api_account,
"apiPassword": self.api_password,
"token": token,
}
if self.validate_key:
full_params["validatekey"] = self.validate_key
if params:
full_params.update(params)
url = f"{self.base_url}{path}"
try:
if method.upper() == "POST":
resp = await client.post(url, data=full_params)
else:
resp = await client.get(url, params=full_params)
resp.raise_for_status()
data = resp.json()
except httpx.ConnectError as e:
raise LianruanConnectionError(
f"无法连接联软服务器: {e}",
detail=str(e),
)
except httpx.TimeoutException as e:
raise LianruanConnectionError(
f"请求联软超时: {e}",
detail=str(e),
)
except httpx.HTTPStatusError as e:
raise LianruanApiError(
f"联软HTTP错误 {e.response.status_code}",
status=str(e.response.status_code),
detail=str(e),
)
# 检查联软业务状态码
status = data.get("status", "")
if status == "INVALID":
# Token可能过期,清除缓存重试一次
self._token = ""
self._token_expire = 0
raise LianruanAuthError(
f"联软认证失败(IP不在白名单或Token无效): {data.get('msg', '')}",
detail=str(data),
)
elif status == "ERROR":
raise LianruanApiError(
f"联软API错误: {data.get('msg', '')}",
status=status,
detail=str(data),
)
elif status == "Exceed":
raise LianruanApiError(
f"联软数据量超限: {data.get('msg', '')}",
status=status,
detail=str(data),
)
elif status != "SUCCESS":
raise LianruanApiError(
f"联软未知状态: {status} - {data.get('msg', '')}",
status=status,
detail=str(data),
)
return data
# ==========================================================================
# P0接口 — 终端设备查询
# ==========================================================================
async def query_dev_by_params(
self,
strusername: str = "",
strdevname: str = "",
strdevip: str = "",
strmac: str = "",
page: int = 1,
per_page: int = 20,
) -> dict:
"""查询终端设备(核心映射接口)。
⭐ strusername 参数可直接按员工账号查终端,这是联软最大的优势!
Args:
strusername: 员工账号(映射金钥匙)
strdevname: 计算机名
strdevip: IP地址
strmac: MAC地址
page: 页码(从1开始)
per_page: 每页条数
Returns:
dict: {"items": [TerminalBasicInfo], "total": int}
"""
params: dict = {}
if strusername:
params["strusername"] = strusername
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
if strmac:
params["strmac"] = strmac
# 联软分页参数
params["page"] = str(page)
params["rows"] = str(per_page)
data = await self._request("/terminal", "queryDevByParams", params)
rows = data.get("rows", [])
total = data.get("total", len(rows))
items = [TerminalBasicInfo(**row) for row in rows]
return {"items": items, "total": total}
async def get_dev_all_info(
self,
strdevname: str = "",
strdevip: str = "",
) -> TerminalAllInfo:
"""查询终端详细信息(极详细硬件+软件+资产+网络)。
比火绒_info2更丰富,包含逻辑磁盘使用率、显示器信息、内存条详情。
Args:
strdevname: 计算机名(二选一)
strdevip: IP地址(二选一)
Returns:
TerminalAllInfo: 终端详细信息
"""
params: dict = {}
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
data = await self._request(
"/devallinfoshowwithpaging", "getDevAllInfo", params
)
# 返回格式:data.equipment + data.equipmentdetail
equipment = data.get("equipment", data.get("rows", [{}]))
if isinstance(equipment, list) and equipment:
equipment = equipment[0]
equipment_detail = data.get("equipmentdetail", {})
if isinstance(equipment_detail, list) and equipment_detail:
equipment_detail = equipment_detail[0]
dev_detail = equipment_detail.get("devdetail", equipment_detail)
# 解析硬件详情
result = TerminalAllInfo(
strdevname=equipment.get("strdevname", ""),
strip1=equipment.get("strip1", ""),
strmac=equipment.get("strmac", ""),
strdeptname=equipment.get("strdeptname", ""),
strusername=equipment.get("strusername", ""),
struserdes=equipment.get("struserdes", ""),
stros=equipment.get("stros", ""),
strdomain=equipment.get("strdomain", ""),
istatus=dev_detail.get("istatus", equipment.get("istatus", "")),
strverofuaagent=dev_detail.get("strverofuaagent", ""),
devassetno=dev_detail.get("devassetno", ""),
devgroup=dev_detail.get("devgroup", ""),
)
# 解析硬件列表
self._parse_hardware_list(dev_detail, "CPUInformation", result.cpu)
self._parse_hardware_list(dev_detail, "MemoryInformation", result.memory)
self._parse_hardware_list(dev_detail, "HardDiskInformation", result.hard_disk)
self._parse_hardware_list(dev_detail, "GraphicsCardInformation", result.graphics_card)
self._parse_hardware_list(dev_detail, "MainboardInformation", result.mainboard)
# 解析逻辑磁盘(含使用率)
for ld in dev_detail.get("LogicalDiskInformation", []):
result.logical_disk.append(LogicalDiskInfo(
name=ld.get("strlogicaldiskname", ""),
file_system=ld.get("strfilesystem", ""),
total_size=ld.get("strtotalsize", ""),
free_space=ld.get("strfreespace", ""),
usage_percent=ld.get("strusagepercent", ""),
))
# 解析网卡
for nc in dev_detail.get("NetworkCardInformation", []):
result.network_card.append(NetworkCardInfo(
name=nc.get("strnetcardname", ""),
is_wireless=nc.get("iswireless", ""),
vendor=nc.get("strnetcardvendor", ""),
mac=nc.get("strnetcardmac", ""),
))
# 解析显示器
for d in dev_detail.get("DisplayInformation", []):
result.display.append(DisplayInfo(
vendor=d.get("strdisplayvendor", ""),
model=d.get("strdisplaymodel", ""),
serial=d.get("strdisplayserial", ""),
size=d.get("strdisplaysize", ""),
))
return result
def _parse_hardware_list(
self, dev_detail: dict, key: str, target_list: list
) -> None:
"""解析硬件信息列表(CPU/内存/硬盘等)"""
from app.integrations.lianruan.models import HardwareInfo
for item in dev_detail.get(key, []):
target_list.append(HardwareInfo(
name=item.get("strcpuname", item.get("strmemname", item.get("strdiskname", ""))),
model=item.get("strcpumodel", item.get("strmemmodel", item.get("strdiskmodel", ""))),
vendor=item.get("strcpuvendor", item.get("strmemvendor", item.get("strdiskvendor", ""))),
capacity=item.get("strcpufrequency", item.get("strmemcapacity", item.get("strdiskcapacity", ""))),
serial=item.get("strcpuserial", item.get("strmemserial", item.get("strdiskserial", ""))),
))
# ==========================================================================
# P0接口 — 组织架构/用户
# ==========================================================================
async def get_user_info_by_account(self, useraccount: str) -> Optional[UserInfo]:
"""按账号查询用户信息。
Args:
useraccount: 用户账号
Returns:
UserInfo或None
"""
data = await self._request(
"/querydeptuser",
"getUserInfoByAccount",
{"useraccount": useraccount},
)
rows = data.get("rows", data.get("row", []))
if rows:
row = rows[0] if isinstance(rows, list) else rows
return UserInfo(**row)
return None
async def get_all_org_info(self) -> list[OrgDeptInfo]:
"""获取全量组织架构(部门+用户)。
用于定时同步,构建组织架构映射。
Returns:
list[OrgDeptInfo]: 部门列表,每个部门含用户列表
"""
data = await self._request("/querydeptuser", "getAllOrgInfo")
rows = data.get("rows", [])
result = []
for dept_data in rows:
users = []
for u in dept_data.get("users", []):
users.append(UserInfo(**u))
result.append(OrgDeptInfo(
deptid=dept_data.get("deptid", ""),
deptname=dept_data.get("deptname", ""),
parentid=dept_data.get("parentid", ""),
users=users,
))
return result
# ==========================================================================
# P1接口 — 准入控制
# ==========================================================================
async def exist_online_user(
self, username: str, strdevip: str = ""
) -> OnlineStatus:
"""查询终端用户是否在线。
可精确判断某员工在某IP是否当前在线。
Args:
username: 用户名
strdevip: IP地址(可选)
Returns:
OnlineStatus: 在线状态
"""
params = {"username": username}
if strdevip:
params["strdevip"] = strdevip
data = await self._request(
"/access/onlineUser", "existOnlineUser", params
)
is_online = data.get("data", "0") == "1"
return OnlineStatus(
username=username,
ip=strdevip,
is_online=is_online,
)
# ==========================================================================
# P1接口 — 终端操作
# ==========================================================================
async def notice_agent_msg(
self, strdevip: str, message: str
) -> bool:
"""向终端推送弹窗消息。
Args:
strdevip: 终端IP
message: 消息内容
Returns:
bool: 是否成功
"""
data = await self._request(
"/terminal",
"noticeAgentMsg",
{"strdevip": strdevip, "msg": message},
)
return data.get("status") == "SUCCESS"
async def remote_wake_up(
self, strdevip: str, strmac: str
) -> bool:
"""远程唤醒终端。
Args:
strdevip: 终端IP
strmac: 终端MAC地址
Returns:
bool: 是否成功
"""
data = await self._request(
"/terminal",
"remoteWakeUp",
{"strdevip": strdevip, "strmac": strmac},
)
return data.get("status") == "SUCCESS"
async def query_software_by_dev(
self, strdevname: str = "", strdevip: str = ""
) -> Optional[TerminalSoftwareInfo]:
"""查询终端安装软件。
Args:
strdevname: 计算机名
strdevip: IP地址
Returns:
TerminalSoftwareInfo或None
"""
params: dict = {}
if strdevname:
params["strdevname"] = strdevname
if strdevip:
params["strdevip"] = strdevip
data = await self._request("/software", "querysoftwarebydev", params)
rows = data.get("rows", [])
if not rows:
return None
row = rows[0] if isinstance(rows, list) else rows
softwares = []
for s in row.get("softwares", []):
softwares.append(SoftwareInfo(
name=s.get("strsoftware", ""),
version=s.get("strversion", ""),
vendor=s.get("strvendor", ""),
install_date=s.get("installdate", ""),
))
return TerminalSoftwareInfo(
strdevname=row.get("strdevname", ""),
strdevip=row.get("strdevip", ""),
strmac=row.get("strmac", ""),
strusername=row.get("strusername", ""),
softwares=softwares,
)
# ==========================================================================
# 测试连接
# ==========================================================================
async def test_connection(self) -> dict:
"""测试联软API连接。
使用getToken接口验证:
1. 网络连通性
2. IP白名单
3. 账号密码正确性
4. Token获取成功
Returns:
dict: {"success": bool, "message": str}
"""
try:
token = await self._ensure_token()
if token:
return {
"success": True,
"message": "联软API连接成功,Token获取正常",
}
else:
return {
"success": False,
"message": "Token获取失败,返回为空",
}
except LianruanAuthError as e:
return {"success": False, "message": e.message}
except LianruanConnectionError as e:
return {"success": False, "message": e.message}
except Exception as e:
return {"success": False, "message": f"未知错误: {str(e)}"}
@@ -0,0 +1,98 @@
# 联软LV7000配置管理
"""
从system_configs表读取联软API配置,构建LianruanClient实例。
联软配置键(前缀 integration_lianruan_):
- integration_lianruan_base_url: 联软API地址(如 http://192.168.x.x:30098
- integration_lianruan_api_account: API账号
- integration_lianruan_api_password: API密码
- integration_lianruan_validate_key: 验证密钥(可选)
配置方式:管理后台 → 系统集成 → 联软LV7000 → 填入账号密码
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.lianruan.client import LianruanClient
from app.integrations.lianruan.exceptions import LianruanConfigError
from app.models.system_config import SystemConfig
logger = logging.getLogger(__name__)
# 联软配置键前缀(与 admin_service INTEGRATION_DEFINITIONS 中的 key_prefix 一致)
_PREFIX = "integration_lianruan_"
async def _get_lianruan_config_value(db: AsyncSession, key_suffix: str) -> str:
"""读取单个联软配置值。
Args:
db: 数据库会话
key_suffix: 配置键后缀(如 base_url / api_account
Returns:
str: 配置值,不存在返回空字符串
"""
full_key = f"{_PREFIX}{key_suffix}"
from sqlalchemy import select
result = await db.execute(select(SystemConfig).where(SystemConfig.key == full_key))
config_row = result.scalar_one_or_none()
return config_row.value if config_row else ""
async def get_lianruan_config(db: AsyncSession) -> dict:
"""从system_configs表读取联软配置。
Args:
db: 数据库会话
Returns:
dict: 包含 base_url / api_account / api_password / validate_key
Raises:
LianruanConfigError: 配置缺失
"""
base_url = await _get_lianruan_config_value(db, "base_url")
api_account = await _get_lianruan_config_value(db, "api_account")
api_password = await _get_lianruan_config_value(db, "api_password")
validate_key = await _get_lianruan_config_value(db, "validate_key")
if not base_url:
raise LianruanConfigError("联软API未配置:缺少Base URL")
if not api_account:
raise LianruanConfigError("联软API未配置:缺少API账号")
if not api_password:
raise LianruanConfigError("联软API未配置:缺少API密码")
return {
"base_url": base_url,
"api_account": api_account,
"api_password": api_password,
"validate_key": validate_key,
}
async def get_lianruan_client(db: AsyncSession) -> LianruanClient:
"""构建联软API客户端实例。
从system_configs表读取配置,创建LianruanClient。
Args:
db: 数据库会话
Returns:
LianruanClient: 已配置的联软客户端
Raises:
LianruanConfigError: 配置缺失
"""
cfg = await get_lianruan_config(db)
return LianruanClient(
base_url=cfg["base_url"],
api_account=cfg["api_account"],
api_password=cfg["api_password"],
validate_key=cfg.get("validate_key", ""),
)
@@ -0,0 +1,61 @@
# 联软LV7000异常体系
"""
定义联软API集成的异常类层级。
层级:
LianruanError — 基类(所有联软异常)
├── LianruanConfigError — 配置缺失(未填写账号/密码/BaseURL)
├── LianruanAuthError — 认证失败(IP不在白名单/账号密码错误/Token过期)
├── LianruanConnectionError — 网络连接失败(超时/拒绝连接)
└── LianruanApiError — API业务错误(参数错误/数据超限/其他)
"""
class LianruanError(Exception):
"""联软异常基类"""
def __init__(self, message: str, detail: str = ""):
self.message = message
self.detail = detail
super().__init__(message)
class LianruanConfigError(LianruanError):
"""配置缺失异常。
场景:未配置联软 BaseURL / ApiAccount / ApiPassword
"""
pass
class LianruanAuthError(LianruanError):
"""认证失败异常。
场景:
- IP不在白名单(status=INVALID
- 账号密码错误
- Token过期(需重新获取)
"""
pass
class LianruanConnectionError(LianruanError):
"""网络连接失败异常。
场景:超时/拒绝连接/DNS解析失败
"""
pass
class LianruanApiError(LianruanError):
"""API业务错误异常。
场景:
- 参数错误(status=ERROR
- 数据量超限(status=Exceed
- 其他业务异常
"""
def __init__(self, message: str, status: str = "", detail: str = ""):
self.status = status # 联软返回的status字段(ERROR/Exceed等)
super().__init__(message, detail)
+193
View File
@@ -0,0 +1,193 @@
# 联软LV7000数据模型
"""
定义联软API返回数据的Pydantic模型。
核心模型:
- TerminalBasicInfo:终端基本信息(queryDevByParams返回)
- TerminalAllInfo:终端详细信息(getDevAllInfo返回,极详细)
- UserInfo:用户信息(getUserInfoByAccount返回)
- OrgInfo:组织架构信息(getAllOrgInfo返回)
- OnlineStatus:终端在线状态(existOnlineUser返回)
"""
from typing import Optional
from pydantic import BaseModel, Field
# ==========================================================================
# 终端基本信息(queryDevByParams返回)
# ==========================================================================
class TerminalBasicInfo(BaseModel):
"""终端基本信息 — 最核心的映射数据源。
⭐ strusername + struserdes 字段直接提供员工账号→终端映射!
这是联软相比火绒最大的优势。
"""
# 终端标识
strdevname: str = Field(default="", description="计算机名")
strdevip: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
# ⭐ 员工映射字段(核心价值)
strusername: str = Field(default="", description="使用该终端的用户账号(映射金钥匙)")
struserdes: str = Field(default="", description="用户姓名/描述")
# 组织信息
strdeptname: str = Field(default="", description="所属部门名")
# 状态
istatus: str = Field(default="", description="终端状态(1=在线/0=离线)")
# 网络
strswitchname: str = Field(default="", description="接入交换机名")
strifname: str = Field(default="", description="交换机接口名")
# 联系方式
strmail: str = Field(default="", description="用户邮箱")
strphone: str = Field(default="", description="用户电话")
# 其他
strdomain: str = Field(default="", description="Windows域")
strdevtype: str = Field(default="", description="设备类型")
# ==========================================================================
# 终端详细信息(getDevAllInfo返回)
# ==========================================================================
class HardwareInfo(BaseModel):
"""硬件组件信息"""
name: str = Field(default="", description="名称")
model: str = Field(default="", description="型号")
vendor: str = Field(default="", description="厂商")
capacity: str = Field(default="", description="容量")
serial: str = Field(default="", description="序列号")
class LogicalDiskInfo(BaseModel):
"""逻辑磁盘信息(含使用率,判断磁盘满)"""
name: str = Field(default="", description="卷标")
file_system: str = Field(default="", description="文件系统")
total_size: str = Field(default="", description="总量")
free_space: str = Field(default="", description="可用空间")
usage_percent: str = Field(default="", description="使用率")
class NetworkCardInfo(BaseModel):
"""网卡信息"""
name: str = Field(default="", description="名称")
is_wireless: str = Field(default="", description="是否无线")
vendor: str = Field(default="", description="厂商")
mac: str = Field(default="", description="MAC地址")
class DisplayInfo(BaseModel):
"""显示器信息(多屏配置排查)"""
vendor: str = Field(default="", description="厂商")
model: str = Field(default="", description="型号")
serial: str = Field(default="", description="序列号")
size: str = Field(default="", description="尺寸")
class TerminalAllInfo(BaseModel):
"""终端详细信息 — 极其详细,比火绒_info2更丰富。
包含:设备基础+硬件+软件+资产+网络配置。
特别是逻辑磁盘使用率和显示器信息,是火绒没有的。
"""
# 设备基础
strdevname: str = Field(default="", description="计算机名")
strip1: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
strnatip: str = Field(default="", description="NAT IP")
macverdor: str = Field(default="", description="MAC厂商")
strdevtype: str = Field(default="", description="设备类型")
# 组织+用户
strdeptname: str = Field(default="", description="所属部门")
strusername: str = Field(default="", description="用户账号⭐")
struserdes: str = Field(default="", description="用户姓名⭐")
# 时间
dtdevuptime: str = Field(default="", description="最近上线时间")
dtdevdowntime: str = Field(default="", description="最近下线时间")
dtdevfirstfoundtime: str = Field(default="", description="首次发现时间")
# 系统
stros: str = Field(default="", description="操作系统")
strdomain: str = Field(default="", description="Windows域")
strserialnumber: str = Field(default="", description="序列号")
strmainboardtype: str = Field(default="", description="主板型号")
# 客户端详情
strverofuaagent: str = Field(default="", description="安全助手版本")
istatus: str = Field(default="", description="在线状态")
devassetno: str = Field(default="", description="设备资产号")
devgroup: str = Field(default="", description="设备所属设备组")
# 硬件详情(列表)
mainboard: list[HardwareInfo] = Field(default_factory=list, description="主板信息")
cpu: list[HardwareInfo] = Field(default_factory=list, description="CPU信息")
memory: list[HardwareInfo] = Field(default_factory=list, description="内存信息")
hard_disk: list[HardwareInfo] = Field(default_factory=list, description="硬盘信息")
logical_disk: list[LogicalDiskInfo] = Field(default_factory=list, description="逻辑磁盘")
graphics_card: list[HardwareInfo] = Field(default_factory=list, description="显卡信息")
network_card: list[NetworkCardInfo] = Field(default_factory=list, description="网卡信息")
display: list[DisplayInfo] = Field(default_factory=list, description="显示器信息")
# ==========================================================================
# 用户信息(getUserInfoByAccount返回)
# ==========================================================================
class UserInfo(BaseModel):
"""用户信息"""
deptid: str = Field(default="", description="部门ID")
userid: str = Field(default="", description="用户ID")
useraccount: str = Field(default="", description="用户账号")
username: str = Field(default="", description="用户姓名")
# ==========================================================================
# 组织架构信息(getAllOrgInfo返回)
# ==========================================================================
class OrgDeptInfo(BaseModel):
"""部门信息"""
deptid: str = Field(default="", description="部门ID")
deptname: str = Field(default="", description="部门名称")
parentid: str = Field(default="", description="父部门ID")
users: list[UserInfo] = Field(default_factory=list, description="部门下用户列表")
# ==========================================================================
# 终端在线状态(existOnlineUser返回)
# ==========================================================================
class OnlineStatus(BaseModel):
"""终端在线状态"""
username: str = Field(default="", description="用户名")
ip: str = Field(default="", description="IP地址")
is_online: bool = Field(default=False, description="是否在线")
# ==========================================================================
# 软件信息(querysoftwarebydev返回)
# ==========================================================================
class SoftwareInfo(BaseModel):
"""软件安装信息"""
name: str = Field(default="", description="软件名称")
version: str = Field(default="", description="版本")
vendor: str = Field(default="", description="厂商")
install_date: str = Field(default="", description="安装日期")
class TerminalSoftwareInfo(BaseModel):
"""终端安装软件信息"""
strdevname: str = Field(default="", description="计算机名")
strdevip: str = Field(default="", description="IP地址")
strmac: str = Field(default="", description="MAC地址")
strusername: str = Field(default="", description="用户账号")
softwares: list[SoftwareInfo] = Field(default_factory=list, description="软件列表")
@@ -0,0 +1,35 @@
# =============================================================================
# RAGFlow 集成模块
# =============================================================================
from .client import RagflowClient
from .config import get_ragflow_client
from .exceptions import (
RagflowApiError,
RagflowAuthError,
RagflowConfigError,
RagflowConnectionError,
RagflowError,
)
from .models import (
DatasetInfo,
DocAggregate,
DocumentInfo,
RetrievalChunk,
RetrievalResult,
)
__all__ = [
"RagflowClient",
"get_ragflow_client",
"RagflowError",
"RagflowConfigError",
"RagflowAuthError",
"RagflowApiError",
"RagflowConnectionError",
"RetrievalChunk",
"DocAggregate",
"RetrievalResult",
"DatasetInfo",
"DocumentInfo",
]
+449
View File
@@ -0,0 +1,449 @@
# =============================================================================
# RAGFlow API 客户端
# =============================================================================
# 说明:封装 RAGFlow 知识检索引擎的 API 调用
# 核心功能:
# 1. 知识检索 — POST /api/v1/retrieval(核心接口)
# 2. 数据集管理 — 列出/创建/删除知识库
# 3. 文档管理 — 上传/列出/删除文档
# 4. 测试连接 — 验证 API Key 是否有效
# 认证方式:Authorization: Bearer <API_KEY>
# 参考文档:https://ragflow.io/docs/http_api_reference
# =============================================================================
import logging
from typing import Any, Dict, List, Optional
import httpx
from .exceptions import (
RagflowApiError,
RagflowAuthError,
RagflowConfigError,
RagflowConnectionError,
RagflowError,
)
from .models import (
DatasetInfo,
DocAggregate,
DocumentInfo,
RetrievalChunk,
RetrievalResult,
)
logger = logging.getLogger(__name__)
# 默认请求超时(秒)
DEFAULT_TIMEOUT = 30.0
# 默认分页大小
DEFAULT_PAGE_SIZE = 20
class RagflowClient:
"""RAGFlow API 客户端。
封装 RAGFlow 知识检索引擎的 API 调用,支持:
- 知识检索(核心功能)
- 数据集(知识库)管理
- 文档管理
- 连接测试
使用方式:
client = RagflowClient(
api_key="sk-xxx",
base_url="http://10.80.0.85:9380"
)
result = await client.retrieval("VPN怎么连?", dataset_ids=["xxx"])
"""
def __init__(
self,
api_key: str,
base_url: str = "http://10.80.0.85:9380",
timeout: float = DEFAULT_TIMEOUT,
):
"""初始化 RAGFlow 客户端。
Args:
api_key: RAGFlow API KeyBearer Token
base_url: RAGFlow API 基础地址(不含尾部斜杠)
timeout: 默认请求超时(秒)
Raises:
RagflowConfigError: API Key 为空
"""
if not api_key:
raise RagflowConfigError("RAGFlow API Key 不能为空")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
"""构建请求头。
Returns:
Dict: 包含 Authorization 和 Content-Type 的请求头
"""
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
json_data: Optional[Dict] = None,
params: Optional[Dict] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""统一请求封装。
Args:
method: HTTP 方法(GET/POST/PUT/DELETE
path: API 路径(如 /api/v1/retrieval
json_data: JSON 请求体
params: 查询参数
timeout: 覆盖默认超时
Returns:
Dict: API 响应的 JSON 数据
Raises:
RagflowAuthError: 认证失败(401
RagflowApiError: API 返回错误
RagflowConnectionError: 网络连接失败
"""
url = f"{self.base_url}{path}"
req_timeout = timeout or self.timeout
try:
async with httpx.AsyncClient() as client:
response = await client.request(
method=method,
url=url,
headers=self._headers(),
json=json_data,
params=params,
timeout=req_timeout,
)
# 处理 HTTP 错误
if response.status_code == 401:
raise RagflowAuthError("RAGFlow API Key 无效或已过期")
if response.status_code >= 400:
try:
err_body = response.json()
err_msg = err_body.get("message", response.text)
except Exception:
err_msg = response.text
raise RagflowApiError(
code=response.status_code,
message=f"RAGFlow API 错误 ({response.status_code}): {err_msg}",
)
# 解析响应
result = response.json()
# RAGFlow 统一响应格式:{code: 0, data: ..., message: ...}
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "未知错误"),
)
return result
except httpx.TimeoutException:
raise RagflowConnectionError(f"RAGFlow 请求超时 ({req_timeout}s): {path}")
except httpx.ConnectError:
raise RagflowConnectionError(f"RAGFlow 连接失败: {self.base_url}")
except (RagflowAuthError, RagflowApiError, RagflowConnectionError):
raise
except Exception as e:
raise RagflowError(f"RAGFlow 请求异常: {str(e)}")
# ==========================================================================
# 测试连接
# ==========================================================================
async def test_connection(self) -> Dict[str, Any]:
"""测试 RAGFlow API 连接。
通过列出数据集(limit=1)验证 API Key 是否有效。
Returns:
Dict: {success: bool, message: str}
"""
try:
result = await self.list_datasets(page=1, page_size=1)
return {
"success": True,
"message": f"连接成功,共 {result.get('total', 0)} 个知识库",
}
except RagflowAuthError:
return {"success": False, "message": "API Key 无效或已过期"}
except RagflowConnectionError as e:
return {"success": False, "message": f"连接失败: {e.message}"}
except RagflowError as e:
return {"success": False, "message": e.message}
# ==========================================================================
# 知识检索(核心接口)
# ==========================================================================
async def retrieval(
self,
question: str,
dataset_ids: Optional[List[str]] = None,
document_ids: Optional[List[str]] = None,
similarity_threshold: float = 0.2,
vector_similarity_weight: float = 0.3,
top_k: int = 1024,
keyword: bool = False,
highlight: bool = False,
) -> RetrievalResult:
"""知识检索 — 从知识库中搜索相关文档片段。
这是 RAGFlow 的核心接口,用于根据用户问题检索最相关的文本块。
Args:
question: 用户查询问题
dataset_ids: 要搜索的数据集ID列表(与 document_ids 二选一)
document_ids: 要搜索的文档ID列表
similarity_threshold: 最小相似度阈值(0-1),默认 0.2
vector_similarity_weight: 向量相似度权重(0-1),默认 0.3
top_k: 参与计算的块数量,默认 1024
keyword: 是否启用关键词匹配,默认 False
highlight: 是否高亮匹配术语,默认 False
Returns:
RetrievalResult: 检索结果(含文本块、文档聚合、总数)
Raises:
RagflowError: 检索失败
"""
body: Dict[str, Any] = {
"question": question,
"similarity_threshold": similarity_threshold,
"vector_similarity_weight": vector_similarity_weight,
"top_k": top_k,
"keyword": keyword,
"highlight": highlight,
}
if dataset_ids:
body["dataset_ids"] = dataset_ids
if document_ids:
body["document_ids"] = document_ids
result = await self._request("POST", "/api/v1/retrieval", json_data=body)
data = result.get("data", {})
# 解析文本块
chunks = [
RetrievalChunk.model_validate(chunk)
for chunk in data.get("chunks", [])
]
# 解析文档聚合
doc_aggs = [
DocAggregate.model_validate(agg)
for agg in data.get("doc_aggs", [])
]
return RetrievalResult(
chunks=chunks,
doc_aggs=doc_aggs,
total=data.get("total", 0),
)
# ==========================================================================
# 数据集(知识库)管理
# ==========================================================================
async def list_datasets(
self,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出所有数据集(知识库)。
Args:
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DatasetInfo], total: int}
"""
result = await self._request(
"GET",
"/api/v1/datasets",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DatasetInfo.model_validate(ds)
for ds in data.get("datasets", [])
]
return {"items": items, "total": data.get("total", 0)}
async def create_dataset(
self,
name: str,
embedding_model: str = "BAAI/bge-m3@BAAI",
chunk_method: str = "naive",
permission: str = "me",
) -> DatasetInfo:
"""创建数据集(知识库)。
Args:
name: 数据集名称
embedding_model: 向量模型
chunk_method: 分块方法(naive/qa/book/laws 等)
permission: 权限(me/team
Returns:
DatasetInfo: 创建的数据集信息
"""
body = {
"name": name,
"embedding_model": embedding_model,
"chunk_method": chunk_method,
"permission": permission,
}
result = await self._request("POST", "/api/v1/datasets", json_data=body)
return DatasetInfo.model_validate(result.get("data", {}))
async def delete_dataset(self, dataset_ids: List[str]) -> bool:
"""删除数据集。
Args:
dataset_ids: 要删除的数据集ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
"/api/v1/datasets",
json_data={"ids": dataset_ids},
)
return True
# ==========================================================================
# 文档管理
# ==========================================================================
async def list_documents(
self,
dataset_id: str,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出数据集中的文档。
Args:
dataset_id: 数据集ID
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DocumentInfo], total: int}
"""
result = await self._request(
"GET",
f"/api/v1/datasets/{dataset_id}/documents",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DocumentInfo.model_validate(doc)
for doc in data.get("documents", [])
]
return {"items": items, "total": data.get("total", 0)}
async def upload_document(
self,
dataset_id: str,
file_path: str,
file_name: Optional[str] = None,
) -> DocumentInfo:
"""上传文档到数据集。
Args:
dataset_id: 数据集ID
file_path: 本地文件路径
file_name: 文件名(可选,默认取 file_path 的文件名)
Returns:
DocumentInfo: 上传的文档信息
"""
import os
if not os.path.exists(file_path):
raise RagflowError(f"文件不存在: {file_path}")
fname = file_name or os.path.basename(file_path)
url = f"{self.base_url}/api/v1/datasets/{dataset_id}/documents"
try:
async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f:
response = await client.post(
url=url,
headers={"Authorization": f"Bearer {self.api_key}"},
files={"file": (fname, f)},
timeout=60.0,
)
if response.status_code == 401:
raise RagflowAuthError()
result = response.json()
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "上传失败"),
)
docs = result.get("data", {}).get("documents", [])
if docs:
return DocumentInfo.model_validate(docs[0])
return DocumentInfo(name=fname)
except (RagflowAuthError, RagflowApiError):
raise
except Exception as e:
raise RagflowError(f"文档上传失败: {str(e)}")
async def delete_documents(
self,
dataset_id: str,
document_ids: List[str],
) -> bool:
"""删除文档。
Args:
dataset_id: 数据集ID
document_ids: 要删除的文档ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
f"/api/v1/datasets/{dataset_id}/documents",
json_data={"ids": document_ids},
)
return True
@@ -0,0 +1,61 @@
# =============================================================================
# RAGFlow 配置加载器
# =============================================================================
# 说明:从数据库 system_configs 表加载 RAGFlow 配置,创建客户端实例
# 配置项:integration_ragflow_api_url + integration_ragflow_api_key
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
from .client import RagflowClient
from .exceptions import RagflowConfigError
logger = logging.getLogger(__name__)
# 默认 RAGFlow API 地址(生产环境)
DEFAULT_RAGFLOW_BASE_URL = "http://10.80.0.85:9380"
async def _get_config(db: AsyncSession, key: str) -> str:
"""从数据库读取单个配置值。"""
result = await db.execute(
select(SystemConfig.config_value).where(SystemConfig.config_key == key)
)
row = result.scalar()
return row if row else ""
async def get_ragflow_client(db: AsyncSession) -> RagflowClient:
"""从数据库配置创建 RAGFlow 客户端实例。
读取 system_configs 表中的
- integration_ragflow_api_url: RAGFlow API 地址
- integration_ragflow_api_key: RAGFlow API Key
Args:
db: 数据库会话
Returns:
RagflowClient: 客户端实例
Raises:
RagflowConfigError: 配置缺失
"""
api_url = await _get_config(db, "integration_ragflow_api_url")
api_key = await _get_config(db, "integration_ragflow_api_key")
# 如果数据库没有配置,使用默认地址
if not api_url:
api_url = DEFAULT_RAGFLOW_BASE_URL
if not api_key:
raise RagflowConfigError(
"RAGFlow API Key 未配置,请在管理后台 → 集成管理 → RAGFlow 中设置"
)
return RagflowClient(api_key=api_key, base_url=api_url)
@@ -0,0 +1,35 @@
# =============================================================================
# RAGFlow API 异常定义
# =============================================================================
class RagflowError(Exception):
"""RAGFlow 基础异常。"""
def __init__(self, message: str = "RAGFlow 错误"):
self.message = message
super().__init__(self.message)
class RagflowConfigError(RagflowError):
"""配置错误(缺少 API Key 或 Base URL)。"""
def __init__(self, message: str = "RAGFlow 配置缺失"):
super().__init__(message)
class RagflowAuthError(RagflowError):
"""认证失败(API Key 无效)。"""
def __init__(self, message: str = "RAGFlow 认证失败"):
super().__init__(message)
class RagflowApiError(RagflowError):
"""API 调用失败(非 200 响应)。"""
def __init__(self, code: int = 0, message: str = "RAGFlow API 错误"):
self.code = code
super().__init__(message)
class RagflowConnectionError(RagflowError):
"""网络连接失败。"""
def __init__(self, message: str = "RAGFlow 连接失败"):
super().__init__(message)
+110
View File
@@ -0,0 +1,110 @@
# =============================================================================
# RAGFlow API 数据模型
# =============================================================================
# 说明:定义 RAGFlow API 请求/响应的 Pydantic 数据模型
# 参考:https://ragflow.io/docs/http_api_reference
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class RetrievalChunk(BaseModel):
"""检索返回的单个文本块。
Attributes:
id: 块唯一ID
content: 块内容文本
document_id: 所属文档ID
document_keyword: 所属文档名称
similarity: 综合相似度分数
term_similarity: 关键词相似度
vector_similarity: 向量相似度
highlight: 高亮标记的内容可选
"""
id: str = Field(default="", description="块唯一ID")
content: str = Field(default="", description="块内容文本")
document_id: str = Field(default="", description="所属文档ID")
document_keyword: str = Field(default="", description="所属文档名称")
similarity: float = Field(default=0.0, description="综合相似度分数")
term_similarity: float = Field(default=0.0, description="关键词相似度")
vector_similarity: float = Field(default=0.0, description="向量相似度")
highlight: Optional[str] = Field(default=None, description="高亮标记的内容")
model_config = {"from_attributes": True}
class DocAggregate(BaseModel):
"""文档聚合统计。
Attributes:
doc_id: 文档ID
doc_name: 文档名称
count: 命中的块数量
"""
doc_id: str = Field(default="", description="文档ID")
doc_name: str = Field(default="", description="文档名称")
count: int = Field(default=0, description="命中块数量")
model_config = {"from_attributes": True}
class RetrievalResult(BaseModel):
"""检索结果。
Attributes:
chunks: 命中的文本块列表
doc_aggs: 按文档聚合统计
total: 命中总数
"""
chunks: List[RetrievalChunk] = Field(default_factory=list, description="命中文本块列表")
doc_aggs: List[DocAggregate] = Field(default_factory=list, description="文档聚合统计")
total: int = Field(default=0, description="命中总数")
model_config = {"from_attributes": True}
class DatasetInfo(BaseModel):
"""数据集(知识库)信息。
Attributes:
id: 数据集ID
name: 数据集名称
chunk_method: 分块方法
permission: 权限
document_count: 文档数量
embedding_model: 向量模型
create_time: 创建时间
update_time: 更新时间
"""
id: str = Field(default="", description="数据集ID")
name: str = Field(default="", description="数据集名称")
chunk_method: str = Field(default="naive", description="分块方法")
permission: str = Field(default="me", description="权限")
document_count: int = Field(default=0, description="文档数量")
embedding_model: str = Field(default="", description="向量模型")
create_time: Optional[str] = Field(default=None, description="创建时间")
update_time: Optional[str] = Field(default=None, description="更新时间")
model_config = {"from_attributes": True}
class DocumentInfo(BaseModel):
"""文档信息。
Attributes:
id: 文档ID
name: 文档名称
chunk_method: 分块方法
chunk_count: 块数量
create_time: 创建时间
update_time: 更新时间
"""
id: str = Field(default="", description="文档ID")
name: str = Field(default="", description="文档名称")
chunk_method: str = Field(default="naive", description="分块方法")
chunk_count: int = Field(default=0, description="块数量")
create_time: Optional[str] = Field(default=None, description="创建时间")
update_time: Optional[str] = Field(default=None, description="更新时间")
model_config = {"from_attributes": True}
+533
View File
@@ -0,0 +1,533 @@
# =============================================================================
# 企微IT智能服务台 — FastAPI 应用入口
# =============================================================================
# 说明:FastAPI 应用的主入口文件,负责:
# 1. 创建 FastAPI 应用实例
# 2. 配置 CORS 跨域支持
# 3. 挂载 API 路由
# 4. 注册全局异常处理器
# 5. 添加启动事件(初始化默认数据)
# 6. 提供健康检查端点
# =============================================================================
import json
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
# 导入配置(读取环境变量)
from app.config import settings
# 导入路由汇总
from app.api.router import api_router
# 导入共享服务生命周期管理
from app.dependencies import init_shared_services, cleanup_shared_services
# 导入异常处理器和异常类
from app.utils.response import AppException, app_exception_handler
# 配置日志格式
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 应用生命周期管理(启动和关闭事件)
# --------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理。
在应用启动时执行初始化操作如插入默认数据
在应用关闭时执行清理操作
"""
# ===== 启动事件 =====
logger.info("🚀 企微IT智能服务台启动中...")
# 校验关键配置项(防止生产环境忘记配置导致静默失败)
_validate_config()
# 初始化共享服务实例(Redis/AIService/WecomService/AIHandler
# 这些实例在应用运行期间复用,避免每次请求重新创建导致资源泄漏
await init_shared_services()
# 自动建表(开发阶段,生产环境应用 Alembic 迁移)
await _auto_create_tables()
# 初始化默认数据
await _init_default_data()
logger.info("✅ 企微IT智能服务台启动完成")
yield # 应用运行中
# ===== 关闭事件 =====
logger.info("👋 企微IT智能服务台关闭中...")
# 清理共享服务实例(关闭 Redis 连接、httpx 连接池等)
await cleanup_shared_services()
logger.info("✅ 企微IT智能服务台已关闭")
# --------------------------------------------------------------------------
# 配置校验(启动时检查关键配置项是否为占位符)
# --------------------------------------------------------------------------
# 占位符列表:这些默认值在 config.py 中设置,生产环境必须替换
_PLACEHOLDER_VALUES = {
"wecom_corp_id": "ww1234567890abcdef",
"wecom_secret": "your-agent-secret",
"wecom_token": "your-callback-token",
"wecom_encoding_aes_key": "your-aes-key-43-characters-long-encoding-key",
}
def _validate_config():
"""校验关键配置项是否为占位符。
生产环境部署时如果忘记修改 config.py 中的占位符值
会导致 AES 解密静默失败企微 API 调用 400 等问题
此函数在启动时检查这些关键配置输出醒目警告
"""
warnings = []
for key, placeholder in _PLACEHOLDER_VALUES.items():
actual_value = getattr(settings, key, "")
if actual_value == placeholder:
warnings.append(f" ⚠️ {key} = '{placeholder}' (未配置!)")
if warnings:
logger.warning(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
"⚠️ 检测到以下关键配置仍为占位符,请修改 .env 或环境变量:\n"
+ "\n".join(warnings)
+ "\n"
" 企微回调消息将无法正常解密!\n"
" 参考 .env.example 或项目部署手册进行配置。\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
else:
logger.info("✅ 关键配置校验通过")
# --------------------------------------------------------------------------
# 自动建表(开发阶段使用)
# --------------------------------------------------------------------------
async def _auto_create_tables():
"""自动创建所有数据库表。
开发阶段使用根据模型定义自动创建表
生产环境应使用 Alembic 迁移来管理表结构变更
工作原理
1. 获取 engine懒加载
2. 通过 Base.metadata 收集所有模型定义
3. 执行 CREATE TABLE IF NOT EXISTS
"""
from app.database import _get_engine, Base
# 导入所有模型,确保 Base.metadata 知道所有表的定义
# 如果不导入,Base.metadata 里只有基类,不会建任何表
import app.models # noqa: F401
engine = _get_engine()
async with engine.begin() as conn:
# checkfirst=True: 只创建不存在的表,不会覆盖已有表和数据
await conn.run_sync(Base.metadata.create_all, checkfirst=True)
logger.info("数据库表检查/创建完成")
# --------------------------------------------------------------------------
# 初始化默认数据
# --------------------------------------------------------------------------
async def _init_default_data():
"""初始化默认数据。
当数据库表为空时插入预置配置数据包括
1. system_configs 系统配置关键词阈值话术等
2. funny_phrases 趣味话术
3. quick_reply_templates 快速回复模板
4. approval_links 审批流程链接
5. software_downloads 软件下载入口
只在表为空时插入避免重复插入
"""
from app.database import _get_session_factory
from app.models.system_config import SystemConfig
from app.models.funny_phrase import FunnyPhrase
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
async_session_factory = _get_session_factory()
async with async_session_factory() as db:
try:
# 1. 初始化系统配置
await _init_system_configs(db, SystemConfig)
# 2. 初始化趣味话术
await _init_funny_phrases(db, FunnyPhrase)
# 3. 初始化快速回复模板
await _init_quick_reply_templates(db, QuickReplyTemplate)
# 4. 初始化审批流程链接
await _init_approval_links(db, ApprovalLink)
# 5. 初始化软件下载入口
await _init_software_downloads(db, SoftwareDownload)
await db.commit()
logger.info("默认数据初始化完成")
except Exception as e:
await db.rollback()
logger.error(f"默认数据初始化失败: {e}")
async def _init_system_configs(db, SystemConfig):
"""初始化系统配置项。"""
from sqlalchemy import select, func
count_stmt = select(func.count(SystemConfig.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"system_configs 已有 {count} 条数据,跳过初始化")
return
configs = [
SystemConfig(config_key="hand_raise_keywords", config_value=json.dumps(["转人工", "人工", "人工服务", "真人", "客服", "帮我转人工", "找人工"], ensure_ascii=False), description="举手触发关键词"),
SystemConfig(config_key="emotion_keywords_angry", config_value=json.dumps(["崩溃", "愤怒", "投诉", "差劲", "垃圾", "太差了", "受不了"], ensure_ascii=False), description="愤怒情绪关键词"),
SystemConfig(config_key="emotion_keywords_urgent", config_value=json.dumps(["", "紧急", "马上", "立刻", "赶紧", "十万火急", "快点"], ensure_ascii=False), description="紧急情绪关键词"),
SystemConfig(config_key="emotion_keywords_worried", config_value=json.dumps(["担心", "害怕", "出错", "丢失", "完蛋", "糟糕"], ensure_ascii=False), description="担忧情绪关键词"),
SystemConfig(config_key="intervene_round_threshold", config_value="3", description="需介入追问轮次阈值"),
SystemConfig(config_key="urgency_base_keyword_score", config_value="1", description="关键词匹配基础加分"),
SystemConfig(config_key="urgency_emotion_bonus", config_value="1", description="情绪标记加成分"),
SystemConfig(config_key="urgency_vip_bonus", config_value="1", description="VIP加成分"),
SystemConfig(config_key="urgency_repeat_bonus", config_value="1", description="重复追问加成分"),
SystemConfig(config_key="polling_interval_seconds", config_value="3", description="坐席轮询间隔(秒)"),
SystemConfig(config_key="access_token_buffer_seconds", config_value="300", description="access_token提前刷新时间(秒)"),
SystemConfig(config_key="emergency_mode", config_value="false", description="应急模式开关(true=启用员工服务通道,智能服务台降级)"),
]
db.add_all(configs)
await db.flush()
logger.info(f"初始化 system_configs: {len(configs)}")
async def _init_funny_phrases(db, FunnyPhrase):
"""初始化趣味话术。"""
from sqlalchemy import select, func
count_stmt = select(func.count(FunnyPhrase.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"funny_phrases 已有 {count} 条数据,跳过初始化")
return
phrases = [
FunnyPhrase(scene="shake", content="大哥,俺这就去摇人,稍等...", tone="亲切", sort_order=1),
FunnyPhrase(scene="keyword", content="收到!这就帮您摇位大神来", tone="稍正式", sort_order=1),
FunnyPhrase(scene="waiting", content="人还在路上,别急别急~", tone="安抚", sort_order=1),
FunnyPhrase(scene="connected", content="人摇来了!IT坐席为您服务", tone="明确交接", sort_order=1),
FunnyPhrase(scene="timeout", content="坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇", tone="降级安抚", sort_order=1),
FunnyPhrase(scene="vip", content="这就帮您安排专家,请稍候", tone="正式", sort_order=1),
]
db.add_all(phrases)
await db.flush()
logger.info(f"初始化 funny_phrases: {len(phrases)}")
async def _init_quick_reply_templates(db, QuickReplyTemplate):
"""初始化快速回复模板。"""
from sqlalchemy import select, func
count_stmt = select(func.count(QuickReplyTemplate.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"quick_reply_templates 已有 {count} 条数据,跳过初始化")
return
templates = [
QuickReplyTemplate(category="账号", title="密码重置", content="您好{employee_name},您的密码重置链接已发送至您的企业邮箱,请在30分钟内完成操作。", variables=["employee_name"], sort_order=1),
QuickReplyTemplate(category="账号", title="账号解锁", content="您好,您的账号已解锁,请5分钟后重新尝试登录。如仍有问题请联系IT服务台。", variables=[], sort_order=2),
QuickReplyTemplate(category="网络", title="VPN连接指引", content="请按以下步骤操作:1.打开VPN客户端 2.选择\u201c公司内网\u201d 3.输入域账号密码 4.点击连接。详细图文教程请查看右侧\u201c操作步骤\u201d", variables=[], sort_order=3),
QuickReplyTemplate(category="网络", title="WiFi连接", content="公司WiFi名称:Office-5G,密码请咨询前台或查看工位标签。", variables=[], sort_order=4),
QuickReplyTemplate(category="软件", title="软件安装申请", content="您好,软件安装需要提交审批申请。请在右侧\u201c审批流程\u201d中点击\u201c软件安装申请\u201d链接提交。", variables=[], sort_order=5),
QuickReplyTemplate(category="硬件", title="设备报修", content="您好,设备报修请提交工单。请在右侧\u201c审批流程\u201d中点击\u201c设备报修\u201d链接提交,IT会在24小时内联系您。", variables=[], sort_order=6),
QuickReplyTemplate(category="通用", title="会话结束", content="您好,请问还有其他问题吗?如无其他问题,我将结束本次服务。祝您工作顺利!", variables=[], sort_order=7),
QuickReplyTemplate(category="通用", title="稍等回复", content="收到,我正在为您查询,请稍等片刻。", variables=[], sort_order=8),
]
db.add_all(templates)
await db.flush()
logger.info(f"初始化 quick_reply_templates: {len(templates)}")
async def _init_approval_links(db, ApprovalLink):
"""初始化审批流程链接。"""
from sqlalchemy import select, func
count_stmt = select(func.count(ApprovalLink.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"approval_links 已有 {count} 条数据,跳过初始化")
return
links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1),
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2),
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3),
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4),
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6),
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7),
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8),
]
db.add_all(links)
await db.flush()
logger.info(f"初始化 approval_links: {len(links)}")
async def _init_software_downloads(db, SoftwareDownload):
"""初始化软件下载入口。"""
from sqlalchemy import select, func
count_stmt = select(func.count(SoftwareDownload.id))
result = await db.execute(count_stmt)
count = result.scalar() or 0
if count > 0:
logger.debug(f"software_downloads 已有 {count} 条数据,跳过初始化")
return
downloads = [
SoftwareDownload(category="办公", name="企业微信", version="最新版", platform="全平台", download_url="https://work.weixin.qq.com/#download", sort_order=1),
SoftwareDownload(category="办公", name="WPS Office", version="12.1", platform="Windows/Mac", download_url="https://www.wps.cn/download", sort_order=2),
SoftwareDownload(category="办公", name="Microsoft Teams", version="最新版", platform="全平台", download_url="https://www.microsoft.com/teams/download", sort_order=3),
SoftwareDownload(category="开发", name="VS Code", version="1.90", platform="Windows/Mac/Linux", download_url="https://code.visualstudio.com/download", sort_order=4),
SoftwareDownload(category="开发", name="Git", version="2.45", platform="Windows/Mac", download_url="https://git-scm.com/download", sort_order=5),
SoftwareDownload(category="安全", name="公司VPN客户端", version="3.2", platform="Windows/Mac", download_url="https://内部下载地址/vpn-client", sort_order=6),
SoftwareDownload(category="工具", name="7-Zip", version="24.06", platform="Windows", download_url="https://www.7-zip.org/download", sort_order=7),
SoftwareDownload(category="工具", name="PDF阅读器", version="最新版", platform="Windows/Mac", download_url="https://get.adobe.com/reader/", sort_order=8),
]
db.add_all(downloads)
await db.flush()
logger.info(f"初始化 software_downloads: {len(downloads)}")
# --------------------------------------------------------------------------
# 创建 FastAPI 应用
# --------------------------------------------------------------------------
def create_app() -> FastAPI:
"""创建并配置 FastAPI 应用实例。
使用工厂函数模式方便测试时创建不同的应用实例
Returns:
FastAPI: 配置好的应用实例
"""
# 创建 FastAPI 实例
# lifespan: 应用生命周期管理(启动/关闭事件)
app = FastAPI(
title="企微IT智能服务台",
description="基于企微自建应用消息API的IT服务坐席系统",
version="1.0.0",
lifespan=lifespan,
)
# ----------------------------------------------------------------------
# 配置 CORS(跨域资源共享)
# ----------------------------------------------------------------------
# 为什么需要 CORS:前端和后端运行在不同端口,浏览器会阻止跨域请求
# allow_origins: 允许的前端地址列表
# allow_credentials: 允许携带 Cookie
# allow_methods: 允许的 HTTP 方法(仅允许必要的方法)
# allow_headers: 允许的请求头(仅允许必要的头)
# ----------------------------------------------------------------------
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Employee-Id"],
)
# ----------------------------------------------------------------------
# 速率限制(防止暴力破解和 DDoS)
# ----------------------------------------------------------------------
# slowapi 为每个 IP 维护请求计数器(默认内存后端)
# 登录接口严格限制(防暴力破解),普通接口宽松限制(防滥用)
# ----------------------------------------------------------------------
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from starlette.responses import JSONResponse as RateLimitJSONResponse
from app.utils.response import error_response as _rl_error_response
# 速率限制器:按客户端 IP 维度限制
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
# 注册速率限制超限处理器
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request, exc: RateLimitExceeded):
"""速率限制超限响应:返回 429 状态码和友好提示。"""
return RateLimitJSONResponse(
status_code=429,
content=_rl_error_response(429, f"请求过于频繁,请 {exc.detail} 后重试"),
)
# ----------------------------------------------------------------------
# 注册全局异常处理器
# ----------------------------------------------------------------------
# 当业务逻辑抛出 AppException 时,自动转换为统一响应格式
# ----------------------------------------------------------------------
app.add_exception_handler(AppException, app_exception_handler)
# ----------------------------------------------------------------------
# 注册兜底异常处理器(捕获所有未预期的异常,避免裸 500)
# ----------------------------------------------------------------------
# 数据库连接失败、Redis 异常、第三方库错误等非 AppException 异常
# 都会被捕获并返回统一格式的错误响应,同时记录详细日志
# ----------------------------------------------------------------------
import traceback
from fastapi.responses import JSONResponse
from app.utils.response import error_response
@app.exception_handler(Exception)
async def catch_all_exception_handler(request, exc):
"""兜底异常处理器:捕获所有未预期异常。
安全处理
- 详细异常信息记录到日志供排查
- 响应只返回通用错误信息避免泄露内部细节
"""
# 记录完整错误堆栈(用于排查问题)
logger.error(f"未预期异常: {exc}\n{traceback.format_exc()}")
# 返回统一格式的错误响应(HTTP 200 + 业务错误码)
# 安全:响应不包含具体异常信息,仅返回通用消息
return JSONResponse(
status_code=200,
content=error_response(1005, "服务器内部错误,请稍后重试或联系管理员")
)
# ----------------------------------------------------------------------
# 请求日志 + 兜底异常中间件
# ----------------------------------------------------------------------
# 使用中间件而非 exception_handler 来捕获所有异常
# 原因:FastAPI 的 @app.exception_handler(Exception) 在某些情况下
# 无法捕获异常(如依赖注入 yield 阶段的异常),而中间件更可靠
# ----------------------------------------------------------------------
import traceback as tb_module
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse, JSONResponse as StarJSONResponse
from app.utils.response import error_response as _error_response
@app.middleware("http")
async def catch_errors_and_log(request: Request, call_next):
"""请求日志 + 兜底异常中间件。
1. 记录每个请求的方法路径状态码
2. 捕获所有未处理异常返回统一格式的 JSON 错误响应
"""
# 使用 print 而非 logger,确保输出立即可见(调试阶段)
print(f">>> [MW] 收到请求: {request.method} {request.url.path}", flush=True)
try:
response: StarletteResponse = await call_next(request)
print(f"<<< [MW] 响应完成: {request.method} {request.url.path}{response.status_code}", flush=True)
return response
except Exception as e:
# 捕获所有未处理异常(包括依赖注入阶段的异常)
# 安全:详细日志仅记录,响应不泄露异常信息
error_tb = tb_module.format_exc()
print(f"!!! [MW] 未捕获异常: {request.method} {request.url.path}\n{error_tb}", flush=True)
logger.error(f"!!! 未捕获异常: {request.method} {request.url.path}\n{error_tb}")
# 返回统一格式的 JSON 错误响应(HTTP 200 + 业务错误码 1005
# 安全:响应不包含具体异常信息
return StarJSONResponse(
status_code=200,
content=_error_response(1005, "服务器内部错误,请稍后重试或联系管理员"),
)
# ----------------------------------------------------------------------
# 挂载 API 路由
# ----------------------------------------------------------------------
# 注意:nginx 已经通过 location /api/ 处理了前缀路由,
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
app.include_router(api_router)
# ----------------------------------------------------------------------
# 挂载 WebSocket 路由
# ----------------------------------------------------------------------
# WebSocket 端点不挂 /api 前缀,直接注册在根路径
# 原因:WebSocket 不是 REST API,前端通过 /ws/{agent_id} 连接
# Vite 开发服务器单独配置了 /ws 的 WebSocket 代理
# ----------------------------------------------------------------------
from app.api.ws import router as ws_router
app.include_router(ws_router)
# ----------------------------------------------------------------------
# 诊断端点(调试用,生产环境删除)
# ----------------------------------------------------------------------
@app.get("/test-ping", tags=["诊断"])
async def test_ping():
"""简单测试 — 不依赖数据库和 Redis"""
return {"code": 0, "message": "success", "data": {"message": "pong"}}
@app.get("/test-error", tags=["诊断"])
async def test_error():
"""测试异常处理 — 故意抛出异常"""
raise Exception("这是故意抛出的测试异常")
# ----------------------------------------------------------------------
# 健康检查端点
# ----------------------------------------------------------------------
# 用于 Docker 健康检查和负载均衡探针
# 返回简单的 JSON 表示服务正在运行
@app.get("/health", tags=["系统"])
async def health_check():
"""健康检查端点。
返回服务运行状态用于
- Docker 健康检查
- 负载均衡探针
- 监控系统检测服务是否存活
"""
return {"status": "ok", "service": "wecom-it-smart-desk"}
# ----------------------------------------------------------------------
# 打印所有已注册的路由(调试用)
# ----------------------------------------------------------------------
routes_info = []
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
routes_info.append(f" {', '.join(route.methods)} {route.path}")
if routes_info:
logger.info(f"已注册路由 ({len(routes_info)} 个):\n" + "\n".join(routes_info))
else:
logger.warning("⚠️ 没有注册任何路由!")
return app
# 创建应用实例(uvicorn 通过 app.main:app 引用此对象)
app = create_app()
+44
View File
@@ -0,0 +1,44 @@
# =============================================================================
# 企微IT智能服务台 — 模型包初始化
# =============================================================================
# 说明:导出所有模型类,方便 Alembic 和其他模块统一导入
# 注意:即使某些模型在当前文件未直接使用,也必须导入
# 否则 Alembic 无法检测到这些模型,不会生成对应的迁移脚本
# =============================================================================
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.agent import Agent
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.system_config import SystemConfig
from app.models.funny_phrase import FunnyPhrase
from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
from app.models.agent_note import AgentNote
from app.models.employee import Employee
from app.models.todo_item import TodoItem
from app.models.troubleshooting_template import TroubleshootingTemplate
from app.models.config_change_log import ConfigChangeLog
from app.models.role import Role
from app.models.user_role import UserRole
from app.models.role_mapping_rule import RoleMappingRule
# 所有模型类的列表,方便遍历
__all__ = [
"Conversation",
"Message",
"Agent",
"QuickReplyTemplate",
"SystemConfig",
"FunnyPhrase",
"ApprovalLink",
"SoftwareDownload",
"AgentNote",
"Employee",
"TodoItem",
"TroubleshootingTemplate",
"ConfigChangeLog",
"Role",
"UserRole",
"RoleMappingRule",
]
+146
View File
@@ -0,0 +1,146 @@
# =============================================================================
# 企微IT智能服务台 — 坐席模型
# =============================================================================
# 说明:对应数据库 agents 表,存储坐席(IT服务人员)信息
# 坐席状态:online(在线)/offline(离线)/busy(忙碌)
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Agent(Base):
"""坐席模型 — 对应 agents 表。
记录坐席的基本信息和状态用于消息分配和负载管理
Attributes:
id: 坐席唯一标识UUID数据库自动生成
user_id: 企微用户ID唯一关联企微通讯录
name: 坐席姓名
status: 坐席状态online/offline/busy
current_load: 当前服务会话数
max_load: 最大同时服务数默认5
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "agents"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企微用户ID(唯一,用于关联企微通讯录和登录认证)
user_id: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
comment="企微用户ID(唯一)",
)
# 坐席姓名
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="坐席姓名",
)
# 坐席状态(CHECK 约束:只能取三种值)
# online: 在线,可以接收新的会话分配
# offline: 离线,不接收任何会话
# busy: 忙碌,不接收新会话但继续处理已有的
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="offline",
comment="坐席状态: online/offline/busy",
)
# 当前服务会话数(分配新会话时 +1,结单时 -1)
current_load: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="当前服务会话数",
)
# 最大同时服务数(坐席同时处理的会话数上限)
# 默认5个,可根据坐席能力调整
max_load: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=5,
comment="最大同时服务数",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# 角色(admin=组长, agent=坐席)
# 管理后台需要 admin 角色才能访问,坐席端无限制
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="agent",
comment="角色:admin=组长, agent=坐席",
)
# 技能标签列表(JSON 数组,存储坐席的技能分类)
# 可选值:电脑/软件/外设/网络/安全/资产/其他
skill_tags: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
)
# OTP密钥(用于TOTP动态码验证,为空表示未绑定)
otp_secret: Mapped[str] = mapped_column(
String(32),
nullable=True,
default=None,
comment="OTP密钥(Base32编码)",
)
# OTP是否启用(admin角色强制启用)
otp_enabled: Mapped[bool] = mapped_column(
Integer,
nullable=False,
default=0,
comment="OTP是否启用(0=否, 1=是)",
)
def __repr__(self) -> str:
"""坐席对象的字符串表示,方便调试。"""
return (
f"<Agent(id={self.id}, name={self.name}, "
f"status={self.status}, load={self.current_load}/{self.max_load})>"
)
+100
View File
@@ -0,0 +1,100 @@
# =============================================================================
# 企微IT智能服务台 — 坐席备注模型
# =============================================================================
# 说明:对应数据库 agent_notes 表,存储坐席对会话的备注
# 用途:坐席可以记录处理过程中的关键信息,方便后续跟进
# 一个会话可以有多条备注(不同坐席或同一坐席多次记录)
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AgentNote(Base):
"""坐席备注模型 — 对应 agent_notes 表。
记录坐席在处理会话时添加的备注信息
一个会话可以有多条备注
Attributes:
id: 备注唯一标识UUID数据库自动生成
conversation_id: 所属会话ID外键关联 conversations
agent_id: 坐席ID
content: 备注内容
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "agent_notes"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 所属会话ID(外键,关联 conversations 表)
# ON DELETE CASCADE:删除会话时自动删除该会话的所有备注
conversation_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
comment="所属会话ID",
)
# 坐席ID(记录是哪个坐席写的备注)
agent_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="坐席ID",
)
# 备注内容(坐席自由输入的文本)
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="备注内容",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按会话ID查询(如获取某会话的所有备注)
Index("idx_an_conversation", "conversation_id"),
)
def __repr__(self) -> str:
"""备注对象的字符串表示,方便调试。"""
return (
f"<AgentNote(id={self.id}, conv={self.conversation_id}, "
f"agent={self.agent_id})>"
)
+104
View File
@@ -0,0 +1,104 @@
# =============================================================================
# 企微IT智能服务台 — 审批流程链接模型
# =============================================================================
# 说明:对应数据库 approval_links 表,存储审批流程的外部链接
# 分类:IT/HR/行政/财务
# 在H5用户端右侧AI助手面板中展示,方便员工快速访问审批页面
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ApprovalLink(Base):
"""审批流程链接模型 — 对应 approval_links 表。
存储公司各类审批流程的外部链接
在H5用户端AI助手面板中按分类展示
Attributes:
id: 链接唯一标识UUID数据库自动生成
category: 分类IT/HR/行政/财务
title: 审批名称
url: 审批链接
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "approval_links"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(按部门分类,方便在H5面板中折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="分类:IT/HR/行政/财务",
)
# 审批名称(展示给用户看的标题)
title: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="审批名称",
)
# 审批链接(点击后跳转到对应的审批页面)
url: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="审批链接",
)
# 排序权重(同一分类内排序,数值越小越靠前)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"IT"分类的审批链接)
Index("idx_al_category", "category"),
)
def __repr__(self) -> str:
"""链接对象的字符串表示,方便调试。"""
return f"<ApprovalLink(id={self.id}, category={self.category}, title={self.title})>"
+95
View File
@@ -0,0 +1,95 @@
# =============================================================================
# 企微IT智能服务台 — 配置变更日志模型
# =============================================================================
# 说明:对应数据库 config_change_logs 表,记录每次配置项的变更历史
# 包含变更前后的值、操作人和时间,用于配置审计和回滚
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ConfigChangeLog(Base):
"""配置变更日志模型 — 对应 config_change_logs 表。
记录每次配置项的变更历史包含变更前后的值操作人和时间
Attributes:
id: 日志唯一标识UUID
config_key: 变更的配置键
old_value: 变更前的值
new_value: 变更后的值
changed_by: 变更操作人agent_id
changed_at: 变更时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "config_change_logs"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 配置键(关联 system_configs 表的 config_key
config_key: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="配置键",
)
# 变更前的值(空字符串表示新增配置项)
old_value: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="变更前的值",
)
# 变更后的值
new_value: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="变更后的值",
)
# 变更操作人(关联 agents 表的 id)
changed_by: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="变更操作人 agent_id",
)
# 变更时间
changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="变更时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按配置键查询(如查询某配置项的所有变更历史)
Index("idx_ccl_config_key", "config_key"),
# 按变更时间查询(如查询最近的变更记录)
Index("idx_ccl_changed_at", "changed_at"),
)
def __repr__(self) -> str:
"""变更日志对象的字符串表示,方便调试。"""
return f"<ConfigChangeLog(key={self.config_key}, by={self.changed_by})>"
+292
View File
@@ -0,0 +1,292 @@
# =============================================================================
# 企微IT智能服务台 — 会话模型
# =============================================================================
# 说明:对应数据库 conversations 表,存储所有会话信息
# 核心概念:每个员工的每次咨询对应一个会话(Conversation
# 会话状态流转:ai_handling → queued → serving → resolved
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Conversation(Base):
"""会话模型 — 对应 conversations 表。
每个员工的一次完整咨询过程对应一个会话记录
包含员工信息会话状态紧急度评分标签等核心数据
Attributes:
id: 会话唯一标识UUID数据库自动生成
employee_id: 企微员工UserID关联企微通讯录
employee_name: 员工姓名冗余存储减少关联查询
department: 员工部门
position: 员工岗位
level: 员工等级用于 VIP 判断
status: 会话状态ai_handling/queued/serving/resolved
is_vip: VIP标记基于企微通讯录规则自动匹配
is_pinned: 置顶标记坐席手动操作
is_todo: 代办标记坐席手动操作
urgency_score: 紧急度评分1-5数值越大越紧急
tags: 标签集合JSONB存储举手/需介入/情绪等标记
assigned_agent_id: 分配的坐席ID
last_message_at: 最后消息时间用于会话排序
last_message_summary: 最后消息摘要会话列表预览用
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "conversations"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企业微信企业ID(US-7: 区分主企业和下游企业员工)
# 默认值为主企业 corp_id,下游企业员工使用下游企业 corp_id
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="企业微信企业ID(主企业或下游企业)",
)
# 企微员工UserIDNOT NULL,配合 corp_id 唯一标识员工)
employee_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企微员工UserID",
)
# 员工姓名(冗余存储,避免每次查询都要关联企微API)
employee_name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="员工姓名",
)
# 部门
department: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="部门",
)
# 岗位
position: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="岗位",
)
# 等级(用于 VIP 判断:总监及以上为 VIP)
level: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="等级",
)
# 会话状态(CHECK 约束:只能取四种值)
# ai_handling: AI处理中(第二步启用)
# queued: 排队中,等待坐席接入
# serving: 服务中,坐席正在处理
# resolved: 已结单
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="queued",
comment="会话状态: ai_handling/queued/serving/resolved",
)
# VIP标记(基于企微通讯录API规则自动匹配)
is_vip: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="VIP标记",
)
# 置顶标记(坐席手动操作,置顶的会话在列表中优先显示)
is_pinned: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="置顶标记",
)
# 代办标记(坐席手动操作,标记需要后续跟进的会话)
is_todo: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="代办标记",
)
# 紧急度评分(1-5,数值越大越紧急)
# 计算公式:基础分 + 情绪加成 + VIP加成 + 重复追问加成
urgency_score: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="紧急度1-5",
)
# 标签集合(JSON 格式,存储结构化标记数据,兼容所有数据库)
# 示例:{"hand_raise": true, "emotion": "angry", "need_intervene": true}
tags: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="标签集合",
)
# 分配的坐席ID(可为空,表示尚未分配坐席)
assigned_agent_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="分配的坐席ID",
)
# 协作坐席ID列表(JSON 数组,存储所有被邀请来协作的坐席ID)
# 和 assigned_agent_id 的区别:
# - assigned_agent_id:会话的「主责」坐席(接单人),只有他才能结单/转接
# - collaborating_agent_ids:被邀请来协助的坐席,可以查看和回复,但不能结单
# 设计决策:用 JSON 而非关联表,因为协作人数少(1-3人),JSON 查询足够
collaborating_agent_ids: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="协作坐席ID列表",
)
# 被邀请参与会话的非坐席人员列表(JSON 数组,存储员工ID或部门ID)
# 和 collaborating_agent_ids 的区别:
# - collaborating_agent_ids:坐席 → 坐席协作(摇人)
# - participants:坐席 → 任意员工/部门(邀请功能 P0-09~P0-11
# 格式:[ {"id": "employee_id", "name": "姓名", "department": "部门", "type": "employee"},
# {"id": "dept_id", "name": "部门名", "type": "department"} ]
# 设计决策:存储完整信息,减少企微API调用
participants: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="被邀请参与会话的人员列表(邀请功能)",
)
# AI 实质性回复计数(排除打招呼/呼叫人工的引导回复)
# 当计数 >= 3 时,前端显示「呼叫坐席」按钮
ai_substantive_reply_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="AI实质性回复计数(满3次可呼叫坐席)",
)
# 影响范围(受影响人数,0=未评估,数值越大影响范围越广)
impact_scope: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="影响范围",
)
# 阻断性标记(问题是否阻断员工正常工作流程)
is_blocking: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="阻断性标记",
)
# 情绪状态(normal: 正常, worried: 担忧, angry: 愤怒, urgent: 紧急)
emotion_state: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="normal",
comment="情绪状态",
)
# Dify 会话ID(用于多轮对话上下文保持)
# Dify 侧通过此 ID 关联同一员工的多轮对话
dify_conversation_id: Mapped[Optional[str]] = mapped_column(
String(128),
nullable=True,
default=None,
comment="Dify会话ID(多轮对话上下文)",
)
# 最后消息时间(用于会话列表按最新消息排序)
last_message_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后消息时间",
)
# 最后消息摘要(会话列表预览用,截取消息前256字符)
last_message_summary: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="最后消息摘要",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按状态查询(如查询所有排队中的会话)
Index("idx_conversations_status", "status"),
# 按员工ID查询(如查询某个员工的所有会话)
Index("idx_conversations_employee_id", "employee_id"),
# US-7: 按企业ID查询(如查询某企业所有会话)
Index("idx_conversations_corp_id", "corp_id"),
# 按坐席ID查询(如查询某个坐席正在服务的所有会话)
Index("idx_conversations_assigned_agent", "assigned_agent_id"),
# 按紧急度倒序查询(紧急度高的排前面)
Index("idx_conversations_urgency_score", "urgency_score"),
# 按最后消息时间倒序查询(最新消息的排前面)
Index("idx_conversations_last_message_at", "last_message_at"),
)
def __repr__(self) -> str:
"""会话对象的字符串表示,方便调试。"""
return (
f"<Conversation(id={self.id}, employee={self.employee_name}, "
f"status={self.status}, urgency={self.urgency_score})>"
)
+192
View File
@@ -0,0 +1,192 @@
# =============================================================================
# 企微IT智能服务台 — 员工模型
# =============================================================================
# 说明:对应数据库 employees 表,存储通过 OAuth2 认证的员工信息
# US-7 扩展:增加 corp_id 字段支持上下游互联企业场景
# 主企业(亿企赢总部): corp_id = 主企业 corp_id
# 下游企业(亿企赢): corp_id = 下游企业 corp_id
# 复合唯一键 (corp_id, employee_id) 确保跨企业员工标识唯一
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Employee(Base):
"""员工模型 — 对应 employees 表。
通过 OAuth2 认证后记录员工信息支持跨企业场景US-7
Conversation.employee_id 通过 (corp_id, employee_id) 关联
Attributes:
id: 主键UUID数据库自动生成
corp_id: 企业微信企业ID主企业或下游企业
employee_id: 员工UserID企业内唯一
name: 员工姓名
department: 部门JSON数组字符串
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
status: 激活状态1=已激活, 2=已禁用, 4=未激活
last_login_at: 最后登录时间
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "employees"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="员工记录唯一标识",
)
# 企业微信企业ID(主企业或下游企业)
# US-7: 用于区分不同企业的员工,格式如 "wwa8c87970b2011f41"
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企业微信企业ID",
)
# 员工UserID(企业内唯一)
# 注意:不同企业的 userid 可能重复,需配合 corp_id 使用
employee_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="企微员工UserID(企业内唯一)",
)
# 员工姓名
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="员工姓名",
)
# 部门(JSON数组字符串,如 "[1, 2, 3]"
department: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="部门ID列表(JSON数组)",
)
# 岗位
position: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="岗位",
)
# 手机号
mobile: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="手机号",
)
# 邮箱
email: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="邮箱",
)
# 头像URL
avatar: Mapped[str] = mapped_column(
String(512),
nullable=False,
default="",
comment="头像URL",
)
# 激活状态(企微通讯录返回: 1=已激活, 2=已禁用, 4=未激活)
status: Mapped[int] = mapped_column(
default=1,
comment="激活状态: 1=已激活, 2=已禁用, 4=未激活",
)
# IT技能等级(7级: bronze/silver/gold/platinum/diamond/star/king
it_level: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="silver",
comment="IT技能等级",
)
# 等级来源(system: 系统自动评定, manual: 坐席手动调整, assessment: 评估结果)
it_level_source: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="system",
comment="等级来源",
)
# 坐席备注(JSON 格式,存储坐席对员工的备注信息)
notes: Mapped[dict] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="坐席备注",
)
# 最后登录时间
last_login_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="最后登录时间",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引和约束定义
# --------------------------------------------------------------------------
__table_args__ = (
# 复合唯一约束:同一企业内 employee_id 唯一
UniqueConstraint("corp_id", "employee_id", name="uq_employee_corp"),
# 按 corp_id 查询(查询某企业所有员工)
Index("idx_employees_corp_id", "corp_id"),
# 按 employee_id 查询
Index("idx_employees_employee_id", "employee_id"),
)
def __repr__(self) -> str:
"""员工对象的字符串表示。"""
return (
f"<Employee(corp_id={self.corp_id}, employee_id={self.employee_id}, "
f"name={self.name})>"
)
+120
View File
@@ -0,0 +1,120 @@
# =============================================================================
# 企微IT智能服务台 — 趣味话术模型
# =============================================================================
# 说明:对应数据库 funny_phrases 表,存储各场景的趣味话术
# 场景:shake(摇人)/keyword(关键词)/waiting(等待)/connected(接入)/timeout(超时)/vip
# 话术在用户端H5中显示,给等待过程增添趣味性
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class FunnyPhrase(Base):
"""趣味话术模型 — 对应 funny_phrases 表。
按触发场景存储趣味话术在用户等待过程中显示
支持后台动态修改无需发版
Attributes:
id: 话术唯一标识UUID数据库自动生成
scene: 触发场景shake/keyword/waiting/connected/timeout/vip
content: 话术内容
tone: 语气标签亲切/稍正式/安抚/明确交接/降级安抚/正式
sort_order: 排序权重
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "funny_phrases"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 触发场景
# shake: 点击摇人按钮时
# keyword: 关键词触发转人工时
# waiting: 排队等待时(30秒无人接单)
# connected: 坐席接入时
# timeout: 等待超时时(2分钟)
# vip: VIP员工时
scene: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="触发场景: shake/keyword/waiting/connected/timeout/vip",
)
# 话术内容
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="话术内容",
)
# 语气标签(方便管理员理解话术风格)
tone: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="亲切",
comment="语气标签",
)
# 排序权重(同一场景下有多条话术时,按此排序)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 是否启用(False 的话术不会被使用)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按场景查询(如获取所有"摇人"场景的话术)
Index("idx_fp_scene", "scene"),
)
def __repr__(self) -> str:
"""话术对象的字符串表示,方便调试。"""
return f"<FunnyPhrase(id={self.id}, scene={self.scene}, content={self.content[:20]})>"
+252
View File
@@ -0,0 +1,252 @@
# =============================================================================
# 企微IT智能服务台 — 消息模型
# =============================================================================
# 说明:对应数据库 messages 表,存储会话中的所有消息
# 消息来源:员工(employee)、坐席(agent)、AI(ai)、系统(system)
# 消息类型:文本(text)、图片(image)、文件(file)、语音(voice)、系统提示(system)
# =============================================================================
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Message(Base):
"""消息模型 — 对应 messages 表。
每条消息都属于一个会话Conversation记录对话中的每一条信息
包含发送者信息消息内容消息类型等
Attributes:
id: 消息唯一标识UUID数据库自动生成
conversation_id: 所属会话ID外键关联 conversations
sender_type: 发送者类型employee/agent/ai/system
sender_id: 发送者ID
sender_name: 发送者姓名冗余存储减少关联查询
content: 消息内容文本消息为文字媒体消息为描述文字或URL
msg_type: 消息类型text/image/file/voice/system
media_id: 企微媒体文件ID图片/语音/视频消息3天有效
media_url: 本地存储的媒体文件URL下载后保存到服务器
file_name: 文件名文件消息用
file_size: 文件大小字节
extra_data: 扩展元数据JSON如图片尺寸语音格式等
ai_suggestion: 是否为AI建议坐席端展示用
status: 消息状态sending/sent/delivered/read
recallable_until: 可撤回截止时间创建时间+2分钟
is_read: 是否已读
created_at: 创建时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "messages"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 所属会话ID(外键,关联 conversations 表)
# ON DELETE CASCADE:删除会话时自动删除该会话的所有消息
conversation_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
comment="所属会话ID",
)
# 发送者类型(CHECK 约束:只能取四种值)
# employee: 员工发送的消息
# agent: 坐席发送的消息
# ai: AI生成的消息(第二步启用)
# system: 系统消息(如"坐席已接入"等通知)
sender_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="发送者类型: employee/agent/ai/system",
)
# 发送者ID
# 员工消息时为企微UserID,坐席消息时为坐席user_id
sender_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="发送者ID",
)
# 发送者姓名(冗余存储,避免每次查消息都要关联用户表)
sender_name: Mapped[str] = mapped_column(
String(128),
nullable=False,
default="",
comment="发送者姓名",
)
# 消息内容
# 文本消息时为文本内容,图片/文件消息时为媒体URL或描述文字
content: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
comment="消息内容",
)
# 消息类型(CHECK 约束)
# text: 文本消息
# image: 图片消息
# file: 文件消息
# voice: 语音消息
# system: 系统消息
msg_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="text",
comment="消息类型: text/image/file/voice/system",
)
# 引用回复:指向被回复的消息ID(M1 新增)
# 为 None 时表示普通消息,非 None 时表示对某条消息的回复
# 前端根据此字段显示引用内容(被回复消息的摘要)
reply_to_id: Mapped[Optional[str]] = mapped_column(
String(36),
nullable=True,
default=None,
comment="引用回复:被回复的消息ID",
)
# 企微媒体文件ID(图片/语音/视频消息携带)
# 注意:MediaId 仅3天有效,收到消息后应尽快下载保存到本地
# 下载接口:GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
media_id: Mapped[Optional[str]] = mapped_column(
String(256),
nullable=True,
default=None,
comment="企微媒体文件ID3天有效)",
)
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
# 格式示例:/media/2026/06/03/abc123.jpg
media_url: Mapped[Optional[str]] = mapped_column(
String(512),
nullable=True,
default=None,
comment="本地存储的媒体文件URL",
)
# 文件名(文件消息携带,或下载后自定义的文件名)
file_name: Mapped[Optional[str]] = mapped_column(
String(256),
nullable=True,
default=None,
comment="文件名",
)
# 文件大小(字节,文件消息携带)
file_size: Mapped[Optional[int]] = mapped_column(
Integer,
nullable=True,
default=None,
comment="文件大小(字节)",
)
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
# 示例:
# 图片消息: {"pic_url": "https://...", "width": 1920, "height": 1080}
# 语音消息: {"format": "amr", "duration_seconds": 15}
# 视频消息: {"thumb_media_id": "...", "duration_seconds": 30}
# 位置消息: {"location_x": 23.134, "location_y": 113.358, "label": "杭州市"}
extra_data: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON,
nullable=True,
default=None,
comment="扩展元数据(JSON",
)
# 是否为AI建议(坐席端展示用)
# True: 此消息为AI建议的回复,坐席可选择采纳/编辑/忽略
# False: 正常消息
ai_suggestion: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否为AI建议",
)
# 消息状态(新增:sending/sent/delivered/read
# sending: 发送中
# sent: 已发送
# delivered: 已送达
# read: 已读
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="sent",
comment="消息状态: sending/sent/delivered/read",
)
# 可撤回截止时间(创时间+2分钟)
# 用于判断消息是否在可撤回时间窗口内
recallable_until: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
comment="可撤回截止时间",
)
# 是否已读
# 用于统计未读消息数(坐席端显示红点)
is_read: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否已读",
)
# 坐席对 AI 建议的操作行为
# 仅当 ai_suggestion=True 时有意义
# accepted: 坐席直接采纳了AI建议
# edited: 坐席编辑后采纳了AI建议
# ignored: 坐席忽略了AI建议
suggestion_action: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
default=None,
comment="AI建议操作行为: accepted/edited/ignored",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按会话ID查询(如查询某会话的所有消息)
Index("idx_messages_conversation_id", "conversation_id"),
# 按创建时间查询(如按时间排序消息)
Index("idx_messages_created_at", "created_at"),
# 复合索引:按会话+时间查询(最常见的查询:获取某会话的消息列表)
Index("idx_messages_conversation_created", "conversation_id", "created_at"),
)
def __repr__(self) -> str:
"""消息对象的字符串表示,方便调试。"""
return (
f"<Message(id={self.id}, conv={self.conversation_id}, "
f"from={self.sender_type}, type={self.msg_type})>"
)
+145
View File
@@ -0,0 +1,145 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板模型
# =============================================================================
# 说明:对应数据库 quick_reply_templates 表,存储坐席常用回复模板
# 分类:账号/网络/软件/硬件/通用
# 支持变量替换:如 {employee_name} 会被替换为实际员工姓名
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class QuickReplyTemplate(Base):
"""快速回复模板模型 — 对应 quick_reply_templates 表。
坐席常用回复模板按分类组织支持变量替换
变量如 {employee_name} 在使用时会被替换为实际值
Attributes:
id: 模板唯一标识UUID数据库自动生成
category: 分类账号/网络/软件/硬件/通用
title: 模板标题简短描述方便坐席快速识别
content: 模板内容支持 {employee_name} 等变量
variables: 可用变量列表JSONB ["employee_name","department"]
sort_order: 排序权重数值越小越靠前
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "quick_reply_templates"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(用于按类别组织模板,在坐席端折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="通用",
comment="分类:账号/网络/软件/硬件/通用",
)
# 模板标题(简短描述,方便坐席快速识别)
title: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="模板标题",
)
# 模板内容(支持变量替换)
# 示例:"您好{employee_name},您的密码重置链接已发送"
content: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="模板内容,支持变量如 {employee_name}",
)
# 可用变量列表(JSON 格式,存储模板中可替换的变量名,兼容所有数据库)
# 示例:["employee_name", "department"]
variables: Mapped[List[str]] = mapped_column(
JSON,
nullable=False,
default=list,
comment="可用变量列表",
)
# 排序权重(数值越小越靠前,同一分类内排序)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 状态(draft=草稿/pending_review=待审核/approved=已通过/rejected=已驳回)
# 审核流转:draft → pending_review → approved / rejected
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="approved",
comment="状态:draft/pending_review/approved/rejected",
)
# 版本号(每次审核通过后 +1
version: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="版本号,每次审核通过后 +1",
)
# 提交人 agent_id(提交审核的坐席ID,可为空表示系统创建)
submitted_by: Mapped[str] = mapped_column(
String(36),
nullable=True,
default=None,
comment="提交人 agent_id",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"账号"分类的模板)
Index("idx_qr_category", "category"),
)
def __repr__(self) -> str:
"""模板对象的字符串表示,方便调试。"""
return (
f"<QuickReplyTemplate(id={self.id}, category={self.category}, "
f"title={self.title})>"
)
+91
View File
@@ -0,0 +1,91 @@
# =============================================================================
# 角色模型 — roles 表
# =============================================================================
# 说明:定义系统角色(user/agent/admin),支持 RBAC 权限控制
# =============================================================================
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Boolean, DateTime, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Role(Base):
"""角色模型 — 对应 roles 表。
预置三个角色
- user: 所有在职员工默认角色is_default=True
- agent: IT坐席通过企微标签或eHR字段映射
- admin: 管理员通过管理后台手动绑定
"""
__tablename__ = "roles"
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 角色标识:user/agent/admin(唯一)
name: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
comment="角色标识:user/agent/admin",
)
# 显示名称:用户/坐席/管理员
display_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="显示名称:用户/坐席/管理员",
)
# 角色描述
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="角色描述",
)
# 权限列表(JSON数组)
permissions: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="权限列表(JSON数组)",
)
# 是否默认角色(user=True
is_default: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="是否默认角色(所有员工自动获得)",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间(自动刷新)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
return f"<Role(id={self.id}, name={self.name}, display_name={self.display_name})>"
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# 角色映射规则模型 — role_mapping_rules 表
# =============================================================================
# 说明:定义角色自动映射规则,支持企微标签和eHR字段两种来源
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Integer, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RoleMappingRule(Base):
"""角色映射规则模型 — 对应 role_mapping_rules 表。
定义自动映射规则当用户满足条件时自动获得对应角色
- wecom_tag: 企微标签匹配如标签包含"IT坐席"
- ehr_position: eHR岗位关键词匹配如岗位包含"IT支持"
"""
__tablename__ = "role_mapping_rules"
__table_args__ = (
# 按 role_id 查询优化
Index("idx_role_mapping_rules_role_id", "role_id"),
# 按 source_type 查询优化
Index("idx_role_mapping_rules_source_type", "source_type"),
)
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 角色 ID(外键)
role_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="目标角色 ID",
)
# 来源类型:wecom_tag/ehr_position
source_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="来源类型:wecom_tag/ehr_position",
)
# 来源值:标签名/岗位关键词
source_value: Mapped[str] = mapped_column(
String(200),
nullable=False,
comment="来源值:标签名/岗位关键词",
)
# 优先级(数值越大优先级越高)
priority: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="优先级(数值越大优先级越高)",
)
# 是否启用
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
def __repr__(self) -> str:
return (
f"<RoleMappingRule(id={self.id}, role_id={self.role_id}, "
f"source_type={self.source_type}, source_value={self.source_value})>"
)
+125
View File
@@ -0,0 +1,125 @@
# =============================================================================
# 企微IT智能服务台 — 软件下载入口模型
# =============================================================================
# 说明:对应数据库 software_downloads 表,存储软件下载链接
# 分类:办公/开发/安全/工具
# 在H5用户端右侧AI助手面板中展示,方便员工下载常用软件
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SoftwareDownload(Base):
"""软件下载入口模型 — 对应 software_downloads 表。
存储公司常用软件的下载链接
在H5用户端AI助手面板中按分类展示
Attributes:
id: 下载入口唯一标识UUID数据库自动生成
category: 分类办公/开发/安全/工具
name: 软件名称
version: 版本号
platform: 平台Windows/Mac/Linux/全平台
download_url: 下载链接
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "software_downloads"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 分类(按用途分类,方便在H5面板中折叠展示)
category: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="分类:办公/开发/安全/工具",
)
# 软件名称
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="软件名称",
)
# 版本号(如 "12.1"、"最新版"
version: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="版本号",
)
# 支持平台(如 "Windows/Mac"、"全平台"
platform: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="",
comment="平台: Windows/Mac/Linux/全平台",
)
# 下载链接
download_url: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="下载链接",
)
# 排序权重(同一分类内排序,数值越小越靠前)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序权重",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
# --------------------------------------------------------------------------
# 索引定义(和架构文档 DDL 严格一致)
# --------------------------------------------------------------------------
__table_args__ = (
# 按分类查询(如获取所有"办公"分类的软件)
Index("idx_sd_category", "category"),
)
def __repr__(self) -> str:
"""下载入口对象的字符串表示,方便调试。"""
return (
f"<SoftwareDownload(id={self.id}, category={self.category}, "
f"name={self.name}, version={self.version})>"
)
+83
View File
@@ -0,0 +1,83 @@
# =============================================================================
# 企微IT智能服务台 — 系统配置模型
# =============================================================================
# 说明:对应数据库 system_configs 表,存储系统级配置项
# 包括:关键词列表、评分阈值、话术模板等
# 优势:配置存在数据库中,支持后台动态修改,无需重启服务
# =============================================================================
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
"""系统配置模型 — 对应 system_configs 表。
将可动态修改的配置项存储在数据库中
支持后台修改后立即生效无需重启服务
Attributes:
id: 配置唯一标识UUID数据库自动生成
config_key: 配置键唯一 "hand_raise_keywords"
config_value: 配置值JSON 字符串或纯文本
description: 配置说明
updated_at: 更新时间
"""
# 表名(必须和架构文档 DDL 一致)
__tablename__ = "system_configs"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUIDPython端生成(兼容PostgreSQL和SQLite
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 配置键(唯一,用于查找配置项)
# 示例:hand_raise_keywords, emotion_keywords_angry, polling_interval_seconds
config_key: Mapped[str] = mapped_column(
String(128),
unique=True,
nullable=False,
comment="配置键",
)
# 配置值(存储 JSON 字符串或纯文本)
# JSON 示例:'["转人工","人工","人工服务"]'
# 纯文本示例:'3'(表示阈值)
config_value: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="配置值(JSON字符串或纯文本)",
)
# 配置说明(方便管理员理解配置用途)
description: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="配置说明",
)
# 更新时间(配置修改时自动更新)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""配置对象的字符串表示,方便调试。"""
return f"<SystemConfig(key={self.config_key}, value={self.config_value})>"
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项模型
# =============================================================================
# 说明:对应数据库 todo_items 表,存储坐席的待办事项
# 待办类型:ticket(工单)/approval(审批)/device(设备) 等
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TodoItem(Base):
"""待办事项模型 — 对应 todo_items 表。
存储坐席需要跟进的各类待办事项包括工单审批设备处理等
Attributes:
id: 待办唯一标识UUID数据库自动生成
type: 待办类型ticket/approval/device
title: 待办标题
priority: 优先级urgent/high/normal
description: 详细描述JSON存储结构化数据
status: 状态pending/processing/resolved
assigned_agent_id: 分配的坐席ID可为空表示未分配
corp_id: 企业微信企业ID
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "todo_items"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="待办唯一标识",
)
# 待办类型
type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="ticket",
comment="待办类型: ticket/approval/device",
)
# 待办标题
title: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="待办标题",
)
# 优先级
priority: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="normal",
comment="优先级: urgent/high/normal",
)
# 详细描述(JSON 格式,存储结构化数据)
description: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="详细描述",
)
# 状态
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="状态: pending/processing/resolved",
)
# 分配的坐席ID(可为空,表示未分配)
assigned_agent_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="分配的坐席ID",
)
# 企业微信企业ID
corp_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="企业微信企业ID",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""待办事项对象的字符串表示。"""
return (
f"<TodoItem(id={self.id}, type={self.type}, "
f"title={self.title}, status={self.status})>"
)
@@ -0,0 +1,114 @@
# =============================================================================
# 企微IT智能服务台 — 排障模板模型
# =============================================================================
# 说明:对应数据库 troubleshooting_templates 表,存储常见问题的排障模板
# 包含排障步骤路径和流程图定义
# =============================================================================
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, DateTime, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TroubleshootingTemplate(Base):
"""排障模板模型 — 对应 troubleshooting_templates 表。
存储常见 IT 问题的标准化排障模板包括步骤路径和流程图
分类vpn/email/system/account
Attributes:
id: 模板唯一标识UUID数据库自动生成
name: 模板名称
category: 分类vpn/email/system/account
path_steps: 排障步骤路径JSON存储步骤序列
flowchart: 流程图定义JSON存储节点和连线
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
# 表名
__tablename__ = "troubleshooting_templates"
# --------------------------------------------------------------------------
# 字段定义
# --------------------------------------------------------------------------
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
comment="模板唯一标识",
)
# 模板名称
name: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="",
comment="模板名称",
)
# 分类
category: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="system",
comment="分类: vpn/email/system/account",
)
# 排障步骤路径(JSON 格式)
# 示例:[{"step": 1, "title": "检查VPN连接状态", "action": "..."}, ...]
path_steps: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="排障步骤路径",
)
# 流程图定义(JSON 格式)
# 示例:{"nodes": [...], "edges": [...]}
flowchart: Mapped[Dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="流程图定义",
)
# 是否启用
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用",
)
# 创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="创建时间",
)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
onupdate=datetime.now,
comment="更新时间",
)
def __repr__(self) -> str:
"""排障模板对象的字符串表示。"""
return (
f"<TroubleshootingTemplate(id={self.id}, name={self.name}, "
f"category={self.category}, is_active={self.is_active})>"
)
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# 用户角色关联模型 — user_roles 表
# =============================================================================
# 说明:用户与角色的多对多关联表,记录角色来源和分配信息
# =============================================================================
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class UserRole(Base):
"""用户角色关联模型 — 对应 user_roles 表。
记录用户拥有的角色支持以下来源
- auto: 系统自动分配所有员工默认 user 角色
- tag: 企微标签映射
- ehr: eHR 字段映射
- manual: 管理后台手动分配
"""
__tablename__ = "user_roles"
__table_args__ = (
# 同一用户同一角色只能有一条记录
UniqueConstraint("employee_id", "role_id", name="uq_user_role"),
# 按 employee_id 查询优化
Index("idx_user_roles_employee_id", "employee_id"),
# 按 role_id 查询优化
Index("idx_user_roles_role_id", "role_id"),
)
# 主键:UUID
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
# 企微 UserID
employee_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="企微 UserID",
)
# 角色 ID(外键)
role_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="角色 ID",
)
# 角色来源:auto/tag/ehr/manual
source: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="角色来源:auto/tag/ehr/manual",
)
# 分配者(手动分配时记录操作人)
assigned_by: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="分配者(手动分配时记录操作人)",
)
# 分配时间
assigned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=datetime.now,
comment="分配时间",
)
# 过期时间(可选,用于临时角色)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="过期时间(可选,用于临时角色)",
)
def __repr__(self) -> str:
return f"<UserRole(employee_id={self.employee_id}, role_id={self.role_id}, source={self.source})>"
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Schema 包初始化
# =============================================================================
# 说明:导出所有 Pydantic Schema,方便统一导入
# =============================================================================
+492
View File
@@ -0,0 +1,492 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台 Pydantic Schema
# =============================================================================
# 说明:定义管理后台专用请求/响应数据结构
# 包含:仪表盘、配置管理、坐席管理、集成配置、快速回复审核、
# 分配模式、会话监控、全局搜索等全部 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ==========================================================================
# 配置管理 Schema
# ==========================================================================
class ConfigItemResponse(BaseModel):
"""单个配置项响应 Schema。
Attributes:
key: 配置键
value: 配置值
description: 配置说明
value_type: 值类型boolean/number/string/json_array/json_object
"""
key: str = Field(..., description="配置键")
value: str = Field(..., description="配置值")
description: str = Field(default="", description="配置说明")
value_type: str = Field(default="string", description="值类型: boolean/number/string/json_array/json_object")
model_config = {"from_attributes": True}
class ConfigGroupResponse(BaseModel):
"""配置分组响应 Schema。
按功能前缀将配置项分组方便前端展示
Attributes:
name: 分组名称
key_prefix: 配置键前缀
items: 该分组下的配置项列表
"""
name: str = Field(..., description="分组名称")
key_prefix: str = Field(..., description="配置键前缀")
items: List[ConfigItemResponse] = Field(default_factory=list, description="配置项列表")
class ConfigUpdateRequest(BaseModel):
"""配置更新请求 Schema。
Attributes:
value: 新的配置值
"""
value: str = Field(..., min_length=1, description="新的配置值")
class ConfigHistoryItem(BaseModel):
"""配置变更历史条目 Schema。
Attributes:
id: 日志ID
config_key: 配置键
old_value: 变更前的值
new_value: 变更后的值
changed_by: 变更操作人 agent_id
changed_by_name: 变更操作人姓名
changed_at: 变更时间
"""
id: str = Field(..., description="日志ID")
config_key: str = Field(..., description="配置键")
old_value: str = Field(..., description="变更前的值")
new_value: str = Field(..., description="变更后的值")
changed_by: str = Field(..., description="变更操作人 agent_id")
changed_by_name: str = Field(default="", description="变更操作人姓名")
changed_at: datetime = Field(..., description="变更时间")
model_config = {"from_attributes": True}
class ConfigHistoryResponse(BaseModel):
"""配置变更历史响应 Schema。
Attributes:
items: 变更历史条目列表
"""
items: List[ConfigHistoryItem] = Field(default_factory=list, description="变更历史列表")
# ==========================================================================
# 坐席管理 Schema
# ==========================================================================
class AgentCreateRequest(BaseModel):
"""创建坐席请求 Schema。
Attributes:
user_id: 企微用户ID
name: 坐席姓名
role: 角色admin=组长, agent=坐席
skill_tags: 技能标签列表
max_load: 最大同时服务数
"""
user_id: str = Field(..., min_length=1, max_length=64, description="企微用户ID")
name: str = Field(..., min_length=1, max_length=128, description="坐席姓名")
role: str = Field(default="agent", description="角色: admin=组长, agent=坐席")
skill_tags: List[str] = Field(default_factory=list, description="技能标签列表")
max_load: int = Field(default=5, ge=1, le=50, description="最大同时服务数")
class AgentUpdateRequest(BaseModel):
"""更新坐席请求 Schema。
所有字段可选只更新传入的字段
Attributes:
role: 角色
skill_tags: 技能标签列表
max_load: 最大同时服务数
"""
role: Optional[str] = Field(None, description="角色: admin=组长, agent=坐席")
skill_tags: Optional[List[str]] = Field(None, description="技能标签列表")
max_load: Optional[int] = Field(None, ge=1, le=50, description="最大同时服务数")
class AdminAgentResponse(BaseModel):
"""管理后台坐席响应 Schema(含角色/技能标签/今日结单数)。
Attributes:
id: 坐席ID
user_id: 企微用户ID
name: 坐席姓名
status: 坐席状态
role: 角色
skill_tags: 技能标签列表
current_load: 当前服务会话数
max_load: 最大同时服务数
today_resolved: 今日结单数
created_at: 创建时间
updated_at: 更新时间
"""
id: str
user_id: str
name: str
status: str
role: str = "agent"
skill_tags: List[str] = []
current_load: int = 0
max_load: int = 5
today_resolved: int = 0
otp_secret: Optional[str] = None
otp_enabled: bool = False
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# =========================================================================
# 集成配置 Schema
# =========================================================================
class IntegrationConfig(BaseModel):
"""集成系统配置 Schema(通用,支持 url_key 和 access_key 两种模式)。
Attributes:
# url_key 模式(Dify / RAGFlow
api_url: API 地址
api_key_set: API Key 是否已设置
# access_key 模式(火绒安全)
access_key_id_set: AccessKey ID 是否已设置
access_key_secret_set: AccessKey Secret 是否已设置
base_url: 内网 Base URL
"""
# url_key 模式(Dify / RAGFlow
api_url: str = Field(default="", description="API 地址")
api_key_set: bool = Field(default=False, description="API Key 是否已设置")
# access_key 模式(火绒安全)
access_key_id_set: bool = Field(default=False, description="AccessKey ID 是否已设置")
access_key_secret_set: bool = Field(default=False, description="AccessKey Secret 是否已设置")
base_url: Optional[str] = Field(default=None, description="内网 Base URL")
# account_password 模式(联软LV7000
api_account_set: bool = Field(default=False, description="API账号是否已设置")
api_password_set: bool = Field(default=False, description="API密码是否已设置")
class IntegrationResponse(BaseModel):
"""集成系统响应 Schema。
Attributes:
id: 集成系统ID dify/ragflow/huorong
name: 集成系统名称
status: 连接状态connected/partial/disconnected/pending
configurable: 是否可配置
config_type: 配置类型url_key/access_key前端据此显示不同表单
config: 配置信息不可配置时为 None
"""
id: str = Field(..., description="集成系统ID")
name: str = Field(..., description="集成系统名称")
status: str = Field(default="disconnected", description="连接状态: connected/partial/disconnected/pending")
configurable: bool = Field(default=False, description="是否可配置")
config_type: Optional[str] = Field(default=None, description="配置类型: url_key/access_key/account_password")
config: Optional[IntegrationConfig] = Field(default=None, description="配置信息")
class IntegrationUpdateRequest(BaseModel):
"""集成系统配置更新请求 Schema。
支持三种模式
- url_key 模式Dify / RAGFlow传入 api_url + api_key
- access_key 模式火绒安全传入 access_key_id + access_key_secret + base_url
- account_password 模式联软LV7000传入 api_account + api_password + base_url + validate_key(可选)
Attributes:
# url_key 模式
api_url: API 地址可选火绒/联软模式不需要
api_key: API Key可选火绒/联软模式不需要
# access_key 模式(火绒)
access_key_id: AccessKey ID可选
access_key_secret: AccessKey Secret可选
base_url: 内网 Base URL可选
# account_password 模式(联软)
api_account: API账号可选联软模式
api_password: API密码可选联软模式
validate_key: 验证密钥可选联软模式
"""
# url_key 模式(Dify / RAGFlow
api_url: Optional[str] = Field(default=None, description="API 地址(Dify/RAGFlow 模式)")
api_key: Optional[str] = Field(default=None, description="API KeyDify/RAGFlow 模式)")
# access_key 模式(火绒安全)
access_key_id: Optional[str] = Field(default=None, description="AccessKey ID(火绒模式)")
access_key_secret: Optional[str] = Field(default=None, description="AccessKey Secret(火绒模式)")
base_url: Optional[str] = Field(default=None, description="内网 Base URL(火绒/联软模式)")
# account_password 模式(联软LV7000
api_account: Optional[str] = Field(default=None, description="API账号(联软模式)")
api_password: Optional[str] = Field(default=None, description="API密码(联软模式)")
validate_key: Optional[str] = Field(default=None, description="验证密钥(联软模式,可选)")
# ==========================================================================
# 快速回复审核 Schema
# ==========================================================================
class QuickReplyReviewRequest(BaseModel):
"""快速回复审核请求 Schema。
Attributes:
action: 审核动作approve=通过, reject=驳回
reason: 审核原因/意见驳回时建议填写
"""
action: str = Field(..., description="审核动作: approve/reject")
reason: str = Field(default="", description="审核原因/意见")
class AdminQuickReplyResponse(BaseModel):
"""管理后台快速回复响应 Schema(含审核信息)。
Attributes:
id: 模板ID
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
status: 状态
version: 版本号
submitted_by: 提交人 agent_id
submitted_by_name: 提交人姓名
sort_order: 排序权重
created_at: 创建时间
updated_at: 更新时间
"""
id: str
category: str
title: str
content: str
variables: List[str]
status: str = "approved"
version: int = 1
submitted_by: Optional[str] = None
submitted_by_name: str = ""
sort_order: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ==========================================================================
# 分配模式 Schema
# ==========================================================================
class AssignmentModeItem(BaseModel):
"""分配模式条目 Schema。
Attributes:
id: 模式ID
name: 模式名称
enabled: 是否启用
locked: 是否锁定阶段一锁定部分模式
unlock_at: 解锁阶段说明
"""
id: str = Field(..., description="模式ID")
name: str = Field(..., description="模式名称")
enabled: bool = Field(default=False, description="是否启用")
locked: bool = Field(default=True, description="是否锁定")
unlock_at: str = Field(default="", description="解锁阶段说明")
class AssignmentModeResponse(BaseModel):
"""分配模式响应 Schema。
Attributes:
current_mode: 当前启用的分配模式
modes: 所有分配模式列表
"""
current_mode: str = Field(default="manual", description="当前分配模式")
modes: List[AssignmentModeItem] = Field(default_factory=list, description="分配模式列表")
class AssignmentModeUpdateRequest(BaseModel):
"""分配模式更新请求 Schema。
Attributes:
mode: 要切换的分配模式ID
"""
mode: str = Field(..., description="分配模式ID")
# ==========================================================================
# 仪表盘 Schema
# ==========================================================================
class SystemAlertItem(BaseModel):
"""单条系统告警 Schema。
与前端 SystemAlert 接口对齐支持结构化告警展示
Attributes:
type: 告警类型 quick_reply_pending / agent_offline / system_error
content: 告警内容描述
submitter: 提交人姓名仅快速回复待审核类告警有值
time: 告警发生时间ISO 8601 格式
severity: 严重程度info/warning/critical
"""
type: str = Field(..., description="告警类型")
content: str = Field(..., description="告警内容")
submitter: Optional[str] = Field(default=None, description="提交人")
time: str = Field(..., description="告警时间")
severity: str = Field(default="info", description="严重程度: info/warning/critical")
class IntegrationHealthItem(BaseModel):
"""集成系统健康状态条目 Schema。
Attributes:
system: 系统名称
status: 连接状态
"""
system: str = Field(..., description="系统名称")
status: str = Field(default="disconnected", description="连接状态")
class DashboardOverviewResponse(BaseModel):
"""仪表盘总览响应 Schema。
Attributes:
online_agents: 在线坐席数
today_conversations: 今日会话数
avg_response_time: 平均响应时间阶段一占位
ai_hit_rate: AI 命中率阶段一占位
pending_reviews: 待审核快速回复数
system_alerts: 系统告警列表
integrations_health: 集成系统健康状态
"""
online_agents: int = Field(default=0, description="在线坐席数")
today_conversations: int = Field(default=0, description="今日会话数")
avg_response_time: str = Field(default="", description="平均响应时间(阶段一占位)")
ai_hit_rate: str = Field(default="", description="AI命中率(阶段一占位)")
pending_reviews: int = Field(default=0, description="待审核快速回复数")
system_alerts: List[SystemAlertItem] = Field(default_factory=list, description="系统告警列表")
integrations_health: List[IntegrationHealthItem] = Field(default_factory=list, description="集成系统健康状态")
# ==========================================================================
# 会话监控 Schema
# ==========================================================================
class SessionStats(BaseModel):
"""会话统计 Schema。
Attributes:
in_progress: 服务中会话数
queued: 排队中会话数
resolved_today: 今日已结单数
alerts: 告警数
"""
in_progress: int = Field(default=0, description="服务中会话数")
queued: int = Field(default=0, description="排队中会话数")
resolved_today: int = Field(default=0, description="今日已结单数")
alerts: int = Field(default=0, description="告警数")
class SessionItem(BaseModel):
"""会话条目 Schema(监控列表用)。
Attributes:
id: 会话ID
employee_name: 员工姓名
status: 会话状态
assigned_agent_name: 负责坐席姓名
urgency_score: 紧急度评分
created_at: 创建时间
last_message_summary: 最后消息摘要
"""
id: str = Field(..., description="会话ID")
employee_name: str = Field(default="", description="员工姓名")
status: str = Field(default="queued", description="会话状态")
assigned_agent_name: str = Field(default="", description="负责坐席姓名")
urgency_score: int = Field(default=1, description="紧急度评分")
created_at: datetime = Field(..., description="创建时间")
last_message_summary: str = Field(default="", description="最后消息摘要")
class MonitorSessionsResponse(BaseModel):
"""会话监控响应 Schema。
Attributes:
stats: 会话统计
items: 会话列表
"""
stats: SessionStats = Field(..., description="会话统计")
items: List[SessionItem] = Field(default_factory=list, description="会话列表")
# ==========================================================================
# 全局搜索 Schema
# ==========================================================================
class SearchItem(BaseModel):
"""搜索结果条目 Schema。
Attributes:
type: 结果类型config/agent/quick_reply
id: 对象ID
name: 对象名称/标题
route: 前端路由路径
"""
type: str = Field(..., description="结果类型: config/agent/quick_reply")
id: str = Field(..., description="对象ID")
name: str = Field(..., description="对象名称/标题")
route: str = Field(..., description="前端路由路径")
class SearchResponse(BaseModel):
"""搜索结果响应 Schema。
Attributes:
items: 搜索结果列表
"""
items: List[SearchItem] = Field(default_factory=list, description="搜索结果列表")
+111
View File
@@ -0,0 +1,111 @@
# =============================================================================
# 企微IT智能服务台 — 坐席 Pydantic Schema
# =============================================================================
# 说明:定义坐席相关的请求/响应数据结构
# 包含:登录、状态更新、响应三种 Schema
# =============================================================================
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 坐席状态合法值
# --------------------------------------------------------------------------
VALID_AGENT_STATUSES = {"online", "offline", "busy"}
# --------------------------------------------------------------------------
# 坐席登录 Schema
# --------------------------------------------------------------------------
class AgentLogin(BaseModel):
"""坐席登录请求 Schema。
第一步使用简单的用户名密码登录
user_id 对应企微通讯录中的 UserID
admin 角色需要 OTP 二次验证
Attributes:
user_id: 企微用户ID
name: 坐席姓名
otp_code: OTP 动态码admin 角色必填
"""
user_id: str = Field(..., min_length=1, max_length=64, description="企微用户ID")
name: str = Field(..., min_length=1, max_length=128, description="坐席姓名")
otp_code: Optional[str] = Field(None, min_length=6, max_length=6, description="OTP动态码(6位数字)")
# --------------------------------------------------------------------------
# 坐席状态更新 Schema
# --------------------------------------------------------------------------
class AgentStatusUpdate(BaseModel):
"""坐席状态更新请求 Schema。
坐席上线离线设为忙碌时使用
Attributes:
status: 新的坐席状态
"""
status: str = Field(..., description="坐席状态: online/offline/busy")
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
"""校验坐席状态值是否合法。"""
if v not in VALID_AGENT_STATUSES:
raise ValueError(f"无效的坐席状态: {v},合法值为: {VALID_AGENT_STATUSES}")
return v
# --------------------------------------------------------------------------
# 坐席响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class AgentResponse(BaseModel):
"""坐席响应 Schema。
返回给前端的坐席数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 坐席ID
user_id: 企微用户ID
name: 坐席姓名
status: 坐席状态
role: 角色admin=组长, agent=坐席
skill_tags: 技能标签列表
current_load: 当前服务会话数
max_load: 最大同时服务数
created_at: 创建时间
updated_at: 更新时间
"""
id: str
user_id: str
name: str
status: str
role: str = "agent"
skill_tags: List[str] = []
current_load: int
max_load: int
otp_enabled: bool = False # OTP 是否已启用
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 坐席列表响应 Schema
# --------------------------------------------------------------------------
class AgentListResponse(BaseModel):
"""坐席列表响应 Schema。
Attributes:
items: 坐席列表
"""
items: List[AgentResponse]
+319
View File
@@ -0,0 +1,319 @@
# =============================================================================
# 企微IT智能服务台 — 会话 Pydantic Schema
# =============================================================================
# 说明:定义会话相关的请求/响应数据结构
# 包含:创建、更新、响应三种 Schema
# tags 字段使用 JSONB 结构,定义了详细的子结构
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# tags JSONB 字段的子结构定义
# --------------------------------------------------------------------------
class ConversationTags(BaseModel):
"""会话标签集合 — 对应 conversations.tags JSONB 字段。
记录会话的各种标记状态用于坐席端展示和排序
Attributes:
hand_raise: 举手标记员工说"转人工"或点击摇人按钮
need_intervene: 需介入标记追问超过N轮
emotion: 情绪标记neutral/worried/angry/urgent
emotion_keywords: 触发情绪标记的关键词列表
repeat_count: 追问轮次计数
"""
# 举手标记(员工明确要求转人工)
hand_raise: bool = Field(default=False, description="举手标记")
# 需介入标记(同一问题追问超过阈值)
need_intervene: bool = Field(default=False, description="需介入标记")
# 情绪标记(neutral: 正常, worried: 担忧, angry: 愤怒, urgent: 紧急)
emotion: str = Field(
default="neutral",
description="情绪标记: neutral/worried/angry/urgent",
)
# 触发情绪标记的关键词列表
emotion_keywords: List[str] = Field(
default_factory=list,
description="触发情绪标记的关键词",
)
# 追问轮次计数(同一会话中员工连续追问的次数)
repeat_count: int = Field(default=0, description="追问轮次计数")
# --------------------------------------------------------------------------
# 会话状态枚举值校验
# --------------------------------------------------------------------------
VALID_STATUSES = {"ai_handling", "queued", "serving", "resolved"}
# --------------------------------------------------------------------------
# 创建会话 Schema(从企微消息创建会话时使用)
# --------------------------------------------------------------------------
class ConversationCreate(BaseModel):
"""创建会话请求 Schema。
从企微消息回调创建新会话时使用
只需要员工ID和姓名其他信息后续补充
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
employee_name: str = Field(default="", max_length=128, description="员工姓名")
department: str = Field(default="", max_length=256, description="部门")
position: str = Field(default="", max_length=128, description="岗位")
level: str = Field(default="", max_length=64, description="等级")
# --------------------------------------------------------------------------
# 更新会话 Schema(坐席修改会话信息时使用)
# --------------------------------------------------------------------------
class ConversationUpdate(BaseModel):
"""更新会话请求 Schema。
坐席更新会话信息时使用所有字段可选只更新传入的字段
Attributes:
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
is_vip: VIP标记
tags: 标签集合
urgency_score: 紧急度评分
assigned_agent_id: 分配的坐席ID
last_message_summary: 最后消息摘要
"""
employee_name: Optional[str] = Field(None, max_length=128, description="员工姓名")
department: Optional[str] = Field(None, max_length=256, description="部门")
position: Optional[str] = Field(None, max_length=128, description="岗位")
level: Optional[str] = Field(None, max_length=64, description="等级")
is_vip: Optional[bool] = Field(None, description="VIP标记")
tags: Optional[ConversationTags] = Field(None, description="标签集合")
urgency_score: Optional[int] = Field(None, ge=1, le=5, description="紧急度1-5")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
last_message_summary: Optional[str] = Field(None, max_length=256, description="最后消息摘要")
# --------------------------------------------------------------------------
# 更新会话状态 Schema
# --------------------------------------------------------------------------
class ConversationStatusUpdate(BaseModel):
"""更新会话状态请求 Schema。
Attributes:
status: 新的会话状态
"""
status: str = Field(..., description="会话状态: ai_handling/queued/serving/resolved")
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
"""校验会话状态值是否合法。"""
if v not in VALID_STATUSES:
raise ValueError(f"无效的会话状态: {v},合法值为: {VALID_STATUSES}")
return v
# --------------------------------------------------------------------------
# 坐席接单 Schema
# --------------------------------------------------------------------------
class ConversationAssign(BaseModel):
"""坐席接单请求 Schema。
Attributes:
agent_id: 接单的坐席ID
"""
agent_id: str = Field(..., min_length=1, max_length=64, description="坐席ID")
# --------------------------------------------------------------------------
# 摇人(邀请协作)Schema
# --------------------------------------------------------------------------
class ConversationInvite(BaseModel):
"""摇人邀请请求 Schema。
Attributes:
agent_id: 被邀请的坐席ID
"""
agent_id: str = Field(..., min_length=1, max_length=64, description="被邀请的坐席ID")
# --------------------------------------------------------------------------
# 邀请员工/部门加入会话 SchemaP0-09~P0-11 邀请功能)
# --------------------------------------------------------------------------
class ParticipantInfo(BaseModel):
"""被邀请人信息。
Attributes:
id: 企微员工UserID 或部门ID
name: 姓名 部门名称
department: 部门仅员工类型有
type: 类型 employee个人 department部门
avatar: 头像URL从企微通讯录或employees表获取
joined: 是否已加入邀请后点击加入前为 False
joined_at: 加入时间ISO 格式字符串
"""
id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID或部门ID")
name: str = Field(..., min_length=1, max_length=128, description="姓名或部门名称")
department: str = Field(default="", max_length=256, description="部门(仅员工类型)")
type: str = Field(default="employee", description="类型: employee/department")
avatar: str = Field(default="", max_length=512, description="头像URL")
joined: Optional[bool] = Field(default=None, description="是否已加入")
joined_at: Optional[str] = Field(default=None, description="加入时间(ISO格式)")
class InviteParticipantRequest(BaseModel):
"""邀请员工/部门加入会话请求 Schema。
Attributes:
participants: 被邀请人列表
history_mode: 历史消息共享模式 recent10最近10条/ all全部/ none不共享
"""
participants: List[ParticipantInfo] = Field(
..., min_length=1, max_length=20, description="被邀请人列表"
)
history_mode: str = Field(
default="recent10",
description="历史消息共享模式: recent10/all/none",
)
@field_validator("history_mode")
@classmethod
def validate_history_mode(cls, v: str) -> str:
"""校验历史共享模式。"""
valid_modes = {"recent10", "all", "none"}
if v not in valid_modes:
raise ValueError(f"无效的历史共享模式: {v},合法值为: {valid_modes}")
return v
@field_validator("participants")
@classmethod
def validate_participants_unique(cls, v: List[ParticipantInfo]) -> List[ParticipantInfo]:
"""校验参与者ID不重复。"""
ids = [p.id for p in v]
if len(ids) != len(set(ids)):
raise ValueError("参与者ID不能重复")
return v
class JoinConversationRequest(BaseModel):
"""被邀请人加入会话请求 Schema。
Attributes:
employee_id: 被邀请人的企微UserID
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
# --------------------------------------------------------------------------
# 会话响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class ConversationResponse(BaseModel):
"""会话响应 Schema。
返回给前端坐席端/H5端的会话数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 会话ID
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门
position: 岗位
level: 等级
status: 会话状态
is_vip: VIP标记
is_pinned: 置顶标记
is_todo: 代办标记
urgency_score: 紧急度评分
tags: 标签集合
assigned_agent_id: 分配的坐席ID
collaborating_agent_ids: 协作坐席ID列表
last_message_at: 最后消息时间
last_message_summary: 最后消息摘要
created_at: 创建时间
updated_at: 更新时间
"""
id: str
employee_id: str
employee_name: str
department: str
position: str
level: str
status: str
is_vip: bool
is_pinned: bool
is_todo: bool
urgency_score: int
tags: Dict[str, Any]
assigned_agent_id: Optional[str] = None
collaborating_agent_ids: List[str] = Field(default_factory=list, description="协作坐席ID列表")
# 被邀请参与会话的人员列表(邀请功能 P0-09~P0-11
participants: List[ParticipantInfo] = Field(default_factory=list, description="被邀请参与会话的人员列表")
last_message_at: Optional[datetime] = None
last_message_summary: str
created_at: datetime
updated_at: datetime
# ----- 坐席会话全局可见扩展字段 -----
# 是否为当前坐席的会话
is_mine: bool = Field(default=False, description="是否为当前坐席的会话")
# 分配的坐席姓名(其他坐席会话显示用)
assigned_agent_name: Optional[str] = Field(default=None, description="分配的坐席姓名")
# 是否可以接手(其他坐席已接单的会话为 True)
can_grab: bool = Field(default=False, description="是否可以接手")
# ----- 多坐席协作扩展字段 -----
# 协作坐席姓名映射(agent_id → name
collaborating_agent_names: Dict[str, str] = Field(
default_factory=dict, description="协作坐席姓名映射"
)
# 当前坐席是否为协作坐席(非主责)
is_collaborator: bool = Field(default=False, description="是否为协作坐席")
# ----- v5.3 新增:影响范围 / 阻断性 / 情绪状态 -----
# 影响范围(受影响人数,0=未评估)
impact_scope: int = Field(default=0, description="影响范围")
# 阻断性标记(问题是否阻断员工正常工作流程)
is_blocking: bool = Field(default=False, description="阻断性标记")
# 情绪状态(normal/worried/angry/urgent
emotion_state: str = Field(default="normal", description="情绪状态")
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 会话列表响应 Schema(包含分页信息)
# --------------------------------------------------------------------------
class ConversationListResponse(BaseModel):
"""会话列表响应 Schema。
包含会话列表和总数用于分页查询
Attributes:
items: 会话列表
total: 总数
"""
items: List[ConversationResponse]
total: int
+118
View File
@@ -0,0 +1,118 @@
# =============================================================================
# 企微IT智能服务台 — 员工 Pydantic Schema
# =============================================================================
# 说明:定义员工相关的请求/响应数据结构
# 包含:IT等级更新请求、员工响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# IT 等级合法值
# --------------------------------------------------------------------------
VALID_IT_LEVELS = {"bronze", "silver", "gold", "platinum", "diamond", "star", "king"}
# --------------------------------------------------------------------------
# 等级来源合法值
# --------------------------------------------------------------------------
VALID_LEVEL_SOURCES = {"system", "manual", "assessment"}
# --------------------------------------------------------------------------
# IT 等级更新请求 Schema
# --------------------------------------------------------------------------
class ItLevelUpdateRequest(BaseModel):
"""IT技能等级更新请求 Schema。
坐席手动调整员工IT技能等级时使用
Attributes:
it_level: 新的IT技能等级
source: 等级来源默认 manual
"""
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
@field_validator("it_level")
@classmethod
def validate_it_level(cls, v: str) -> str:
"""校验IT等级值是否合法。"""
if v not in VALID_IT_LEVELS:
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
return v
@field_validator("source")
@classmethod
def validate_source(cls, v: str) -> str:
"""校验等级来源值是否合法。"""
if v not in VALID_LEVEL_SOURCES:
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
return v
# --------------------------------------------------------------------------
# 员工响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class EmployeeResponse(BaseModel):
"""员工响应 Schema。
返回给前端的员工数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 员工记录唯一标识
corp_id: 企业微信企业ID
employee_id: 企微员工UserID
name: 员工姓名
department: 部门
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
status: 激活状态
it_level: IT技能等级
it_level_source: 等级来源
notes: 坐席备注
last_login_at: 最后登录时间
created_at: 创建时间
updated_at: 更新时间
"""
id: str
corp_id: str
employee_id: str
name: str
department: str
position: str
mobile: str
email: str
avatar: str
status: int
it_level: str = "silver"
it_level_source: str = "system"
notes: Dict[str, Any] = Field(default_factory=dict, description="坐席备注")
last_login_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 员工列表响应 Schema
# --------------------------------------------------------------------------
class EmployeeListResponse(BaseModel):
"""员工列表响应 Schema。
Attributes:
items: 员工列表
total: 总数
"""
items: List[EmployeeResponse]
total: int
+209
View File
@@ -0,0 +1,209 @@
# =============================================================================
# 企微IT智能服务台 — H5 用户端 Pydantic Schema
# =============================================================================
# 说明:定义H5用户端专用的请求/响应数据结构
# 包含:摇人请求、OAuth回调、审批链接、软件下载、员工信息等
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------
# 摇人请求 Schema
# --------------------------------------------------------------------------
class ShakeRequest(BaseModel):
"""摇人请求 Schema。
用户点击H5页面摇人按钮时发送的请求
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
"""
employee_id: str = Field(..., min_length=1, max_length=64, description="企微员工UserID")
employee_name: str = Field(default="", max_length=128, description="员工姓名")
# --------------------------------------------------------------------------
# 摇人响应 Schema
# --------------------------------------------------------------------------
class ShakeResponse(BaseModel):
"""摇人响应 Schema。
摇人成功后返回会话信息和趣味话术
Attributes:
conversation: 会话信息包含ID状态标签
funny_phrase: 趣味话术内容
"""
conversation: Dict[str, Any] = Field(..., description="会话信息")
funny_phrase: str = Field(..., description="趣味话术")
# --------------------------------------------------------------------------
# OAuth2 回调请求 Schema
# --------------------------------------------------------------------------
class OAuthCallbackRequest(BaseModel):
"""OAuth2 回调请求 Schema。
H5页面通过企微OAuth2授权后将code传给后端换取员工身份
Attributes:
code: 企微OAuth2授权码
"""
code: str = Field(..., min_length=1, description="企微OAuth2授权码")
# --------------------------------------------------------------------------
# OAuth2 回调响应 Schema
# --------------------------------------------------------------------------
class OAuthCallbackResponse(BaseModel):
"""OAuth2 回调响应 Schema。
用授权码换取到的员工身份信息和访问令牌
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
token: 访问令牌用于后续API请求的Bearer Token
department: 部门名称
position: 岗位
avatar: 头像URL
"""
employee_id: str = Field(..., description="企微员工UserID")
employee_name: str = Field(default="", description="员工姓名")
token: str = Field(..., description="访问令牌")
department: str = Field(default="", description="部门名称")
position: str = Field(default="", description="岗位")
avatar: str = Field(default="", description="头像URL")
# --------------------------------------------------------------------------
# OAuth2 授权URL响应 Schema
# --------------------------------------------------------------------------
class OAuthAuthorizeResponse(BaseModel):
"""OAuth2 授权URL响应 Schema。
返回企微OAuth2授权链接前端跳转到此URL进行授权
Attributes:
authorize_url: 企微OAuth2授权URL
"""
authorize_url: str = Field(..., description="企微OAuth2授权URL")
# --------------------------------------------------------------------------
# 员工信息 Schema
# --------------------------------------------------------------------------
class EmployeeInfo(BaseModel):
"""员工信息 Schema。
从企微通讯录获取的员工详细信息
Attributes:
employee_id: 企微员工UserID
employee_name: 员工姓名
department: 部门名称逗号分隔
position: 岗位
mobile: 手机号
email: 邮箱
avatar: 头像URL
is_vip: 是否VIP员工
"""
employee_id: str = Field(..., description="企微员工UserID")
employee_name: str = Field(default="", description="员工姓名")
department: str = Field(default="", description="部门名称")
position: str = Field(default="", description="岗位")
mobile: str = Field(default="", description="手机号")
email: str = Field(default="", description="邮箱")
avatar: str = Field(default="", description="头像URL")
is_vip: bool = Field(default=False, description="是否VIP员工")
# --------------------------------------------------------------------------
# 审批链接响应 Schema
# --------------------------------------------------------------------------
class ApprovalLinkResponse(BaseModel):
"""审批链接响应 Schema。
H5用户端AI助手面板中的审批流程链接
Attributes:
id: 链接ID
category: 分类
title: 审批名称
url: 审批链接
sort_order: 排序权重
"""
id: str
category: str
title: str
url: str
sort_order: int
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 软件下载响应 Schema
# --------------------------------------------------------------------------
class SoftwareDownloadResponse(BaseModel):
"""软件下载响应 Schema。
H5用户端AI助手面板中的软件下载入口
Attributes:
id: 下载入口ID
category: 分类
name: 软件名称
version: 版本号
platform: 平台
download_url: 下载链接
sort_order: 排序权重
"""
id: str
category: str
name: str
version: str
platform: str
download_url: str
sort_order: int
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 审批链接列表响应 Schema
# --------------------------------------------------------------------------
class ApprovalLinkListResponse(BaseModel):
"""审批链接列表响应 Schema。
Attributes:
items: 审批链接列表
"""
items: List[ApprovalLinkResponse]
# --------------------------------------------------------------------------
# 软件下载列表响应 Schema
# --------------------------------------------------------------------------
class SoftwareDownloadListResponse(BaseModel):
"""软件下载列表响应 Schema。
Attributes:
items: 软件下载列表
"""
items: List[SoftwareDownloadResponse]
+145
View File
@@ -0,0 +1,145 @@
# =============================================================================
# 企微IT智能服务台 — 消息 Pydantic Schema
# =============================================================================
# 说明:定义消息相关的请求/响应数据结构
# 支持消息类型:文本(text)/图片(image)/文件(file)/系统(system)
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 消息类型和发送者类型的合法值
# --------------------------------------------------------------------------
VALID_MSG_TYPES = {"text", "image", "file", "system"}
VALID_SENDER_TYPES = {"employee", "agent", "ai", "system"}
# --------------------------------------------------------------------------
# 创建消息 Schema(坐席发送消息时使用)
# --------------------------------------------------------------------------
class MessageCreate(BaseModel):
"""创建消息请求 Schema。
坐席发送消息时使用
支持文本消息和文件/图片消息
Attributes:
content: 消息内容文本消息为正文文件消息为文件URL或描述
msg_type: 消息类型默认 text支持 image/file
media_url: 媒体文件URL图片/文件消息时使用
file_name: 文件名文件消息时使用
file_size: 文件大小字节文件消息时使用
"""
content: str = Field(..., min_length=1, description="消息内容")
# 支持文本、图片、文件类型
msg_type: str = Field(default="text", description="消息类型: text/image/file")
# M1 新增:文件上传相关字段
media_url: Optional[str] = Field(None, description="媒体文件URL(图片/文件消息时使用)")
file_name: Optional[str] = Field(None, description="文件名")
file_size: Optional[int] = Field(None, description="文件大小(字节)")
# M1 新增:引用回复
reply_to_id: Optional[str] = Field(None, description="引用回复:被回复的消息ID")
@field_validator("msg_type")
@classmethod
def validate_msg_type(cls, v: str) -> str:
"""校验消息类型是否合法。"""
if v not in VALID_MSG_TYPES:
raise ValueError(f"无效的消息类型: {v},合法值为: {VALID_MSG_TYPES}")
return v
# --------------------------------------------------------------------------
# 企微回调消息 Schema(从企微接收到的消息)
# --------------------------------------------------------------------------
class WecomInboundMessage(BaseModel):
"""企微回调消息 Schema。
解析企微回调 XML 后得到的结构化消息
Attributes:
from_user_id: 发送者企微UserID
content: 消息内容
msg_type: 消息类型text/image等
create_time: 消息创建时间戳
agent_id: 应用AgentID
"""
from_user_id: str = Field(..., description="发送者企微UserID")
content: str = Field(default="", description="消息内容")
msg_type: str = Field(default="text", description="消息类型")
create_time: Optional[int] = Field(None, description="消息创建时间戳")
agent_id: Optional[str] = Field(None, description="应用AgentID")
# --------------------------------------------------------------------------
# 消息响应 Schema(返回给前端的数据结构)
# --------------------------------------------------------------------------
class MessageResponse(BaseModel):
"""消息响应 Schema。
返回给前端的消息数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 消息ID
conversation_id: 所属会话ID
sender_type: 发送者类型
sender_id: 发送者ID
sender_name: 发送者姓名
content: 消息内容
msg_type: 消息类型
media_url: 媒体文件URL
file_name: 文件名
file_size: 文件大小
extra_data: 扩展元数据
ai_suggestion: 是否为AI建议
is_read: 是否已读
created_at: 创建时间
"""
id: str
conversation_id: str
sender_type: str
sender_id: str
sender_name: str
content: str
msg_type: str
# M1 新增:媒体/文件相关字段
media_url: Optional[str] = None
file_name: Optional[str] = None
file_size: Optional[int] = None
extra_data: Optional[Dict[str, Any]] = None
# M1 新增:引用回复
reply_to_id: Optional[str] = None
ai_suggestion: bool
is_read: bool
created_at: datetime
# M2 新增:消息状态和可撤回时间
status: str = "sent"
recallable_until: Optional[datetime] = None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 消息列表响应 Schema
# --------------------------------------------------------------------------
class MessageListResponse(BaseModel):
"""消息列表响应 Schema。
包含消息列表和是否还有更多消息的标志
用于向上加载历史消息
Attributes:
items: 消息列表
has_more: 是否还有更多历史消息
"""
items: List[MessageResponse]
has_more: bool
+106
View File
@@ -0,0 +1,106 @@
# =============================================================================
# 企微IT智能服务台 — 快速回复模板 Pydantic Schema
# =============================================================================
# 说明:定义快速回复模板的请求/响应数据结构
# 支持 CRUD 操作:创建、读取、更新、删除
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------
# 创建快速回复模板 Schema
# --------------------------------------------------------------------------
class QuickReplyCreate(BaseModel):
"""创建快速回复模板请求 Schema。
Attributes:
category: 分类账号/网络/软件/硬件/通用
title: 模板标题
content: 模板内容支持 {employee_name} 等变量
variables: 可用变量列表
sort_order: 排序权重
"""
category: str = Field(default="通用", max_length=64, description="分类")
title: str = Field(..., min_length=1, max_length=128, description="模板标题")
content: str = Field(..., min_length=1, description="模板内容")
variables: List[str] = Field(default_factory=list, description="可用变量列表")
sort_order: int = Field(default=0, description="排序权重")
# --------------------------------------------------------------------------
# 更新快速回复模板 Schema
# --------------------------------------------------------------------------
class QuickReplyUpdate(BaseModel):
"""更新快速回复模板请求 Schema。
所有字段可选只更新传入的字段
Attributes:
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
sort_order: 排序权重
"""
category: Optional[str] = Field(None, max_length=64, description="分类")
title: Optional[str] = Field(None, max_length=128, description="模板标题")
content: Optional[str] = Field(None, description="模板内容")
variables: Optional[List[str]] = Field(None, description="可用变量列表")
sort_order: Optional[int] = Field(None, description="排序权重")
# --------------------------------------------------------------------------
# 快速回复模板响应 Schema
# --------------------------------------------------------------------------
class QuickReplyResponse(BaseModel):
"""快速回复模板响应 Schema。
返回给前端的快速回复模板数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 模板ID
category: 分类
title: 模板标题
content: 模板内容
variables: 可用变量列表
sort_order: 排序权重
status: 状态draft/pending_review/approved/rejected
version: 版本号
submitted_by: 提交人 agent_id
created_at: 创建时间
updated_at: 更新时间
"""
id: str
category: str
title: str
content: str
variables: List[str]
sort_order: int
status: str = "approved"
version: int = 1
submitted_by: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 快速回复模板列表响应 Schema
# --------------------------------------------------------------------------
class QuickReplyListResponse(BaseModel):
"""快速回复模板列表响应 Schema。
Attributes:
items: 模板列表
"""
items: List[QuickReplyResponse]
+239
View File
@@ -0,0 +1,239 @@
# =============================================================================
# 角色 Pydantic Schema
# =============================================================================
# 说明:定义角色相关的请求/响应数据结构
# 包含:角色响应、角色分配、角色映射规则等 Schema
# =============================================================================
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 角色响应 Schema
# --------------------------------------------------------------------------
class RoleResponse(BaseModel):
"""角色响应 Schema。
Attributes:
id: 角色ID
name: 角色标识user/agent/admin
display_name: 显示名称用户/坐席/管理员
description: 角色描述
permissions: 权限列表
is_default: 是否默认角色
user_count: 拥有该角色的用户数可选
created_at: 创建时间
updated_at: 更新时间
"""
id: str
name: str
display_name: str
description: Optional[str] = None
permissions: List[str] = []
is_default: bool = False
user_count: Optional[int] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 用户角色响应 Schema
# --------------------------------------------------------------------------
class UserRoleResponse(BaseModel):
"""用户角色响应 Schema。
Attributes:
id: 记录ID
employee_id: 企微 UserID
role_id: 角色 ID
role_name: 角色标识
role_display_name: 角色显示名称
source: 角色来源auto/tag/ehr/manual
assigned_by: 分配者
assigned_at: 分配时间
expires_at: 过期时间
"""
id: str
employee_id: str
role_id: str
role_name: str
role_display_name: str
source: str
assigned_by: Optional[str] = None
assigned_at: datetime
expires_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 角色分配请求 Schema
# --------------------------------------------------------------------------
class RoleAssignRequest(BaseModel):
"""角色分配请求 Schema。
Attributes:
employee_id: 企微 UserID
role_name: 角色标识user/agent/admin
reason: 分配原因可选
"""
employee_id: str = Field(..., min_length=1, max_length=100, description="企微 UserID")
role_name: str = Field(..., min_length=1, max_length=50, description="角色标识")
reason: Optional[str] = Field(None, max_length=500, description="分配原因")
# --------------------------------------------------------------------------
# 角色撤销请求 Schema
# --------------------------------------------------------------------------
class RoleRevokeRequest(BaseModel):
"""角色撤销请求 Schema。
Attributes:
employee_id: 企微 UserID
role_name: 角色标识user/agent/admin
reason: 撤销原因可选
"""
employee_id: str = Field(..., min_length=1, max_length=100, description="企微 UserID")
role_name: str = Field(..., min_length=1, max_length=50, description="角色标识")
reason: Optional[str] = Field(None, max_length=500, description="撤销原因")
# --------------------------------------------------------------------------
# 角色映射规则响应 Schema
# --------------------------------------------------------------------------
class RoleMappingRuleResponse(BaseModel):
"""角色映射规则响应 Schema。
Attributes:
id: 规则ID
role_id: 目标角色 ID
role_name: 目标角色标识
source_type: 来源类型wecom_tag/ehr_position
source_value: 来源值标签名/岗位关键词
priority: 优先级
is_active: 是否启用
created_at: 创建时间
"""
id: str
role_id: str
role_name: str
source_type: str
source_value: str
priority: int = 0
is_active: bool = True
created_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 角色映射规则创建/更新请求 Schema
# --------------------------------------------------------------------------
class RoleMappingRuleRequest(BaseModel):
"""角色映射规则创建/更新请求 Schema。
Attributes:
role_name: 目标角色标识user/agent/admin
source_type: 来源类型wecom_tag/ehr_position
source_value: 来源值标签名/岗位关键词
priority: 优先级数值越大优先级越高
is_active: 是否启用
"""
role_name: str = Field(..., min_length=1, max_length=50, description="目标角色标识")
source_type: str = Field(..., min_length=1, max_length=50, description="来源类型")
source_value: str = Field(..., min_length=1, max_length=200, description="来源值")
priority: int = Field(0, ge=0, le=100, description="优先级(0-100")
is_active: bool = Field(True, description="是否启用")
@field_validator("source_type")
@classmethod
def validate_source_type(cls, v: str) -> str:
"""校验来源类型是否合法。"""
allowed_types = {"wecom_tag", "ehr_position"}
if v not in allowed_types:
raise ValueError(f"无效的来源类型: {v},合法值为: {allowed_types}")
return v
@field_validator("role_name")
@classmethod
def validate_role_name(cls, v: str) -> str:
"""校验角色标识是否合法。"""
allowed_roles = {"user", "agent", "admin"}
if v not in allowed_roles:
raise ValueError(f"无效的角色标识: {v},合法值为: {allowed_roles}")
return v
@field_validator("source_value")
@classmethod
def validate_source_value(cls, v: str) -> str:
"""校验来源值是否包含恶意内容。"""
# 过滤特殊字符
forbidden_chars = {"<", ">", ";", "'", '"', "\\", "/", "(", ")"}
for char in v:
if char in forbidden_chars:
raise ValueError(f"来源值包含非法字符: {char}")
return v
# --------------------------------------------------------------------------
# Portal 用户信息响应 Schema
# --------------------------------------------------------------------------
class PortalUserInfo(BaseModel):
"""Portal 用户信息响应 Schema。
用于路由选择页展示用户信息和角色列表
Attributes:
employee_id: 企微 UserID
name: 姓名
department: 部门
avatar: 头像URL
roles: 角色列表
current_role: 当前选择的角色
"""
employee_id: str
name: str
department: Optional[str] = None
avatar: Optional[str] = None
roles: List[RoleResponse] = []
current_role: str = "user"
# --------------------------------------------------------------------------
# 角色切换请求 Schema
# --------------------------------------------------------------------------
class SwitchRoleRequest(BaseModel):
"""角色切换请求 Schema。
Attributes:
new_role: 目标角色标识
"""
new_role: str = Field(..., min_length=1, max_length=50, description="目标角色标识")
# --------------------------------------------------------------------------
# 角色切换响应 Schema
# --------------------------------------------------------------------------
class SwitchRoleResponse(BaseModel):
"""角色切换响应 Schema。
Attributes:
current_role: 切换后的角色标识
redirect_url: 重定向URL
"""
current_role: str
redirect_url: str
+158
View File
@@ -0,0 +1,158 @@
# =============================================================================
# 企微IT智能服务台 — 待办事项 Pydantic Schema
# =============================================================================
# 说明:定义待办事项的 CRUD 数据结构
# 包含:创建、更新、响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 待办类型和优先级的合法值
# --------------------------------------------------------------------------
VALID_TODO_TYPES = {"ticket", "approval", "device"}
VALID_TODO_PRIORITIES = {"urgent", "high", "normal"}
VALID_TODO_STATUSES = {"pending", "processing", "resolved"}
# --------------------------------------------------------------------------
# 创建待办事项 Schema
# --------------------------------------------------------------------------
class TodoItemCreate(BaseModel):
"""创建待办事项请求 Schema。
Attributes:
type: 待办类型ticket/approval/device
title: 待办标题
priority: 优先级urgent/high/normal
description: 详细描述JSON
assigned_agent_id: 分配的坐席ID可选
corp_id: 企业微信企业ID
"""
type: str = Field(default="ticket", description="待办类型: ticket/approval/device")
title: str = Field(..., min_length=1, max_length=256, description="待办标题")
priority: str = Field(default="normal", description="优先级: urgent/high/normal")
description: Dict[str, Any] = Field(default_factory=dict, description="详细描述")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
corp_id: str = Field(default="", max_length=64, description="企业微信企业ID")
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
"""校验待办类型是否合法。"""
if v not in VALID_TODO_TYPES:
raise ValueError(f"无效的待办类型: {v},合法值为: {VALID_TODO_TYPES}")
return v
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
"""校验优先级是否合法。"""
if v not in VALID_TODO_PRIORITIES:
raise ValueError(f"无效的优先级: {v},合法值为: {VALID_TODO_PRIORITIES}")
return v
# --------------------------------------------------------------------------
# 更新待办事项 Schema
# --------------------------------------------------------------------------
class TodoItemUpdate(BaseModel):
"""更新待办事项请求 Schema。
所有字段可选只更新传入的字段
Attributes:
type: 待办类型
title: 待办标题
priority: 优先级
description: 详细描述
status: 状态
assigned_agent_id: 分配的坐席ID
"""
type: Optional[str] = Field(None, description="待办类型: ticket/approval/device")
title: Optional[str] = Field(None, max_length=256, description="待办标题")
priority: Optional[str] = Field(None, description="优先级: urgent/high/normal")
description: Optional[Dict[str, Any]] = Field(None, description="详细描述")
status: Optional[str] = Field(None, description="状态: pending/processing/resolved")
assigned_agent_id: Optional[str] = Field(None, max_length=64, description="分配的坐席ID")
@field_validator("type")
@classmethod
def validate_type(cls, v: Optional[str]) -> Optional[str]:
"""校验待办类型是否合法。"""
if v is not None and v not in VALID_TODO_TYPES:
raise ValueError(f"无效的待办类型: {v},合法值为: {VALID_TODO_TYPES}")
return v
@field_validator("priority")
@classmethod
def validate_priority(cls, v: Optional[str]) -> Optional[str]:
"""校验优先级是否合法。"""
if v is not None and v not in VALID_TODO_PRIORITIES:
raise ValueError(f"无效的优先级: {v},合法值为: {VALID_TODO_PRIORITIES}")
return v
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""校验状态是否合法。"""
if v is not None and v not in VALID_TODO_STATUSES:
raise ValueError(f"无效的状态: {v},合法值为: {VALID_TODO_STATUSES}")
return v
# --------------------------------------------------------------------------
# 待办事项响应 Schema
# --------------------------------------------------------------------------
class TodoItemResponse(BaseModel):
"""待办事项响应 Schema。
返回给前端的待办事项数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 待办唯一标识
type: 待办类型
title: 待办标题
priority: 优先级
description: 详细描述
status: 状态
assigned_agent_id: 分配的坐席ID
corp_id: 企业微信企业ID
created_at: 创建时间
updated_at: 更新时间
"""
id: str
type: str
title: str
priority: str
description: Dict[str, Any]
status: str
assigned_agent_id: Optional[str] = None
corp_id: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 待办事项列表响应 Schema
# --------------------------------------------------------------------------
class TodoItemListResponse(BaseModel):
"""待办事项列表响应 Schema。
Attributes:
items: 待办事项列表
total: 总数
"""
items: List[TodoItemResponse]
total: int
@@ -0,0 +1,128 @@
# =============================================================================
# 企微IT智能服务台 — 排障模板 Pydantic Schema
# =============================================================================
# 说明:定义排障模板的 CRUD 数据结构
# 包含:创建、更新、响应 Schema
# =============================================================================
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# 排障模板分类合法值
# --------------------------------------------------------------------------
VALID_TEMPLATE_CATEGORIES = {"vpn", "email", "system", "account"}
# --------------------------------------------------------------------------
# 创建排障模板 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateCreate(BaseModel):
"""创建排障模板请求 Schema。
Attributes:
name: 模板名称
category: 分类vpn/email/system/account
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
"""
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
category: str = Field(default="system", description="分类: vpn/email/system/account")
path_steps: List[Dict[str, Any]] = Field(
default_factory=list, description="排障步骤路径"
)
flowchart: Dict[str, Any] = Field(
default_factory=dict, description="流程图定义"
)
is_active: bool = Field(default=True, description="是否启用")
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
"""校验分类是否合法。"""
if v not in VALID_TEMPLATE_CATEGORIES:
raise ValueError(f"无效的分类: {v},合法值为: {VALID_TEMPLATE_CATEGORIES}")
return v
# --------------------------------------------------------------------------
# 更新排障模板 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateUpdate(BaseModel):
"""更新排障模板请求 Schema。
所有字段可选只更新传入的字段
Attributes:
name: 模板名称
category: 分类
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
"""
name: Optional[str] = Field(None, max_length=256, description="模板名称")
category: Optional[str] = Field(None, description="分类: vpn/email/system/account")
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
is_active: Optional[bool] = Field(None, description="是否启用")
@field_validator("category")
@classmethod
def validate_category(cls, v: Optional[str]) -> Optional[str]:
"""校验分类是否合法。"""
if v is not None and v not in VALID_TEMPLATE_CATEGORIES:
raise ValueError(f"无效的分类: {v},合法值为: {VALID_TEMPLATE_CATEGORIES}")
return v
# --------------------------------------------------------------------------
# 排障模板响应 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateResponse(BaseModel):
"""排障模板响应 Schema。
返回给前端的排障模板数据结构
使用 from_attributes=True 支持从 SQLAlchemy 模型直接转换
Attributes:
id: 模板唯一标识
name: 模板名称
category: 分类
path_steps: 排障步骤路径
flowchart: 流程图定义
is_active: 是否启用
created_at: 创建时间
updated_at: 更新时间
"""
id: str
name: str
category: str
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------
# 排障模板列表响应 Schema
# --------------------------------------------------------------------------
class TroubleshootingTemplateListResponse(BaseModel):
"""排障模板列表响应 Schema。
Attributes:
items: 排障模板列表
total: 总数
"""
items: List[TroubleshootingTemplateResponse]
total: int

Some files were not shown because too many files have changed in this diff Show More