chore: initial baseline with P0-safety .gitignore
This commit is contained in:
+114
@@ -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/
|
||||||
@@ -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个模型文件全部兼容SQLite:UUID→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短路缺失
|
||||||
|
- **第一步开发完整交付**,可进入部署阶段
|
||||||
@@ -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
|
||||||
@@ -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,申请新 VM(4C8G/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 kB,ChatView 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 SPA(alias + try_files fallback)
|
||||||
|
- `location /itagent/` → Agent SPA(alias + 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 128,nullable),用于 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验证
|
||||||
@@ -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.py(AIHandler),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 个测试通过,确认功能正常
|
||||||
@@ -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:18080(G端)
|
||||||
|
- **容器**: 4/4 全部 Up(backend 标记 unhealthy,功能正常)
|
||||||
|
- **前端**: /itdesk/ ✅ /itagent/ ✅
|
||||||
|
- **API**: /api/health ✅
|
||||||
|
- **数据平台**: / 代理到 10.80.0.130:8080(对方 nginx 未配业务,返回默认页)
|
||||||
|
- **待办**: 配置企微回调 URL + 验证
|
||||||
|
|
||||||
|
## Bug 5: API 路由 404 — 双重 `/api` 前缀
|
||||||
|
- **现象**: 所有 API 端点返回 404(curl `/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_id,messages 表 + 6 列
|
||||||
|
- ✅ Python 3.12 venv 搭建,全部依赖安装(含补装的 aiosqlite)
|
||||||
|
- ✅ Docker Redis 本地容器启动(localhost:6379 无密码)
|
||||||
|
- ✅ 后端 FastAPI 启动(localhost:8000,6 核心服务就绪)
|
||||||
|
- ✅ 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 条导入)
|
||||||
@@ -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等级chip(PRD要求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`(35KB,180条)
|
||||||
|
- **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 MB(95% 精简)
|
||||||
|
- 保留:~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 生效**
|
||||||
|
|
||||||
@@ -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/`(115MB,npm install 重建)
|
||||||
|
- 移除 `backend/venv/`(14MB,pip 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-03,WebSocket 功能)与 `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.md(v5.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.md(v0.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.md(v0.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-agent:1739 modules, 4.6s, 构建成功
|
||||||
|
- H5员工端 frontend-h5:414 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/
|
||||||
|
- 后端API:http://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 Compose(5容器: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 Hostname(itdesk.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 的自定义滑轨 CSS(40x22px track + 18x18px thumb + translateX(18px) 深色状态)
|
||||||
|
|
||||||
|
### 任务2:H5员工端深浅色切换功能开发(Task #24)
|
||||||
|
|
||||||
|
**新增文件**:
|
||||||
|
1. `frontend-h5/src/composables/useTheme.ts` — 主题切换 composable(applyTheme + getInitialTheme + 系统偏好检测)
|
||||||
|
2. `frontend-h5/src/stores/theme.ts` — 主题 Pinia Store(currentTheme + 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/btn,SVG 动画颜色保留)
|
||||||
|
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 无新增错误
|
||||||
|
|
||||||
@@ -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_id,ALTER 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`
|
||||||
|
- ⏳ 待部署到 NAS(scp 上传 + `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.md(1552行),对比 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 镜像)
|
||||||
|
```
|
||||||
@@ -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`:
|
||||||
|
- 移除摇铃按钮及相关 CSS(bell-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.zip(127KB),待上传 NAS
|
||||||
|
- 部署后需清除浏览器 LocalStorage 或换无痕窗口测试
|
||||||
@@ -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` ✅ 成功
|
||||||
@@ -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 ✅
|
||||||
@@ -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 存入 Redis(8小时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/min,Mock登录5/min,OAuth回调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-11(WS Token头传递)
|
||||||
|
- **中风险**: M-6~M-12(Token迁移、缓存、速率限制、异常处理、日志脱敏、密码强度等)
|
||||||
|
- **低风险**: L-5~L-9(CSP/HSTS、CORS、API认证、Nginx配置、前端配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2:Portal 前端应用(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%**
|
||||||
@@ -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 kB,gzip: 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 store(inviteParticipant/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 OK(Werkzeug) |
|
||||||
|
| 千问模型 | 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/CF;Dify应急→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)** |
|
||||||
@@ -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 全部修完再合入,**不要在评审发现的问题未修前再叠加新功能**。
|
||||||
@@ -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)
|
||||||
@@ -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` 为 undefined,CSS 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`
|
||||||
@@ -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个占位页
|
||||||
|
- 登录:复用坐席端 API(POST /agents/login),额外校验 role === 'admin'
|
||||||
|
- API 拦截器:admin_token 独立存储,业务码1002自动跳转登录
|
||||||
|
- base 路径:/itadmin/
|
||||||
|
|
||||||
|
### 代码审查结论
|
||||||
|
- 后端和前端代码质量高,注释详细,架构清晰
|
||||||
|
- 无阻塞性问题
|
||||||
|
|
||||||
|
### 待办
|
||||||
|
- Task #4 管理后台测试验证(pending)
|
||||||
|
- H5端登录Bug仍OPEN
|
||||||
@@ -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,图片/文件需更多处理时间)
|
||||||
|
- 文件上传API:60s(不变)
|
||||||
|
- 后端坐席发消息:非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推送更实时
|
||||||
|
|
||||||
|
## 统一入口 Portal(2026-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 / admin,DB 表 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 监听 443,HTTP 自动 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_admin;P0:仪表盘/功能开关/坐席管理
|
||||||
|
- P1:分配模式/快速回复审核/集成配置/会话监控
|
||||||
|
- **P2 已实现(2026-06-13)**:会话审计/坐席绩效/系统日志
|
||||||
|
- **集成三种配置模式**:url_key(Dify/RAGFlow) / access_key(火绒) / account_password(联软)
|
||||||
|
- **集成管理**:6个系统定义(dify/ragflow可配置,huorong access_key,lianruan 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`
|
||||||
|
- 两个Agent:Agent1(员工端自动回复) + 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/WT;B端智能体→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专用参与者API(2026-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. 管理缺乏数据支撑 → 阶段四
|
||||||
@@ -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 - 合并文档,反映当前实际完成进度*
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add media fields to messages table
|
||||||
|
|
||||||
|
为消息表添加媒体文件相关字段,支持图片、语音、文件等非文本消息。
|
||||||
|
|
||||||
|
新增字段:
|
||||||
|
- media_id: 企微媒体文件ID(3天有效)
|
||||||
|
- 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='企微媒体文件ID(3天有效)'))
|
||||||
|
# 本地存储的媒体文件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')
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 应用包初始化
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:将 app/ 目录标记为 Python 包
|
||||||
|
# =============================================================================
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — API 包初始化
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:将 api/ 目录标记为 Python 包
|
||||||
|
# =============================================================================
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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="映射规则删除成功")
|
||||||
@@ -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="删除成功")
|
||||||
@@ -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 — 获取坐席列表(用于转接选择)
|
||||||
|
# 坐席认证使用 JWT,token 存 Redis(TTL 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 存 Redis,TTL 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 可能是 bytes(Redis 返回)或 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 存入 Redis(TTL 8小时)。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 查找坐席记录(按 user_id),不存在则自动创建
|
||||||
|
2. 生成随机 token
|
||||||
|
3. token 存 Redis(key: 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, # 复用 AgentLogin,otp_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)}")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 邀请功能 API(P0-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)
|
||||||
@@ -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_id,value 为 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
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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/")
|
||||||
|
|
||||||
|
|
||||||
@@ -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="删除成功")
|
||||||
@@ -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=["角色管理"])
|
||||||
@@ -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}",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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} 不存在")
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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: 企业微信回调EncodingAESKey(43位)
|
||||||
|
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"
|
||||||
|
# 回调EncodingAESKey(43位字符串,用于消息加解密)
|
||||||
|
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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 外部系统集成模块包
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:各外部系统的 API 客户端、数据模型、异常定义等
|
||||||
|
# 当前已实现:火绒终端安全
|
||||||
|
#
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 火绒终端安全集成模块包
|
||||||
|
# =============================================================================
|
||||||
@@ -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-MD5(RFC2616)。
|
||||||
|
|
||||||
|
算法步骤:
|
||||||
|
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 MD5(base64编码)
|
||||||
|
- 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-MD5(RFC2616: 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: 终端唯一ID(type=0时必填)
|
||||||
|
group_id: 分组ID(type=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)
|
||||||
@@ -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="分组ID(int或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:全量组织架构同步
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 Key(Bearer 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)
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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(主企业或下游企业)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 企微员工UserID(NOT 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})>"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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]})>"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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="企微媒体文件ID(3天有效)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 本地存储的媒体文件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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
|
)
|
||||||
@@ -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})>"
|
||||||
@@ -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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 字段定义
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 主键:UUID,Python端生成(兼容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})>"
|
||||||
@@ -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})>"
|
||||||
|
)
|
||||||
@@ -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})>"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — Schema 包初始化
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:导出所有 Pydantic Schema,方便统一导入
|
||||||
|
# =============================================================================
|
||||||
@@ -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 Key(Dify/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="搜索结果列表")
|
||||||
@@ -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]
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 邀请员工/部门加入会话 Schema(P0-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
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user