Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfc08b2ade | |||
| 3d152fc8eb | |||
| db70a3a6a8 | |||
| d480aa4c1d | |||
| 7ffc6c8e23 | |||
| adc1933038 | |||
| 4c7a8d0278 | |||
| e4e2de47bb | |||
| 584c975e7f | |||
| 018c87e10b | |||
| 12d89dfb35 | |||
| 5e53146a9a | |||
| ead5f83bee | |||
| f5374fce9b | |||
| 50cb946e9a | |||
| bacd34c34d | |||
| ba068d3642 | |||
| 9bb080d5f2 | |||
| 400ce3ddcb | |||
| 6f0fbbb066 | |||
| d56a9a6079 | |||
| fab75760e0 | |||
| 242c1967ff | |||
| d6644d4d10 | |||
| ca7c6d937a | |||
| ab90db3d3d | |||
| 4b20b5c5f2 | |||
| 6594431ee8 | |||
| 64ff1bf7d5 | |||
| 8bd4ab0366 | |||
| 6277db3951 | |||
| fc22de7f4d | |||
| 78f60c6857 | |||
| 2e6ac0f0ab | |||
| 627f4aa924 | |||
| e47f750b9e | |||
| ffbe01e04d | |||
| e6c85d572e | |||
| 8e748d1ea0 | |||
| 1255e95a73 | |||
| c33abb6ac0 | |||
| a9b97deacd | |||
| e96fbb2475 | |||
| bf872da8bb | |||
| f564d0e42a | |||
| c1ac9b936c | |||
| c3899594d0 | |||
| 8c609e72ba | |||
| 8bfd0cfdc3 | |||
| eee2bcc071 | |||
| cec5607c45 | |||
| caf9b7ed85 | |||
| 68ce1dbab9 | |||
| 60e67b0681 | |||
| 10b37a6acc | |||
| 8c93cc9c9d | |||
| 364e688382 | |||
| 93ba41ed79 | |||
| 64d6812ec3 | |||
| eb28a0f2ef | |||
| 7eb7621d02 | |||
| 1c4b5bf347 |
@@ -0,0 +1,68 @@
|
||||
# =============================================================================
|
||||
# 根目录 .dockerignore
|
||||
# 用途: 优化 docker build 体积 + 速度 + 安全
|
||||
# =============================================================================
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.git-blame-ignore-revs
|
||||
|
||||
# 文档(只 README 入)
|
||||
docs/
|
||||
*.md
|
||||
!backend/README.md
|
||||
README.md
|
||||
|
||||
# 测试
|
||||
tests/
|
||||
**/test_*.py
|
||||
**/*_test.py
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
coverage/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# 开发工具
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 构建产物
|
||||
frontend-*/dist/
|
||||
frontend-*/node_modules/
|
||||
|
||||
# 部署包 / 备份
|
||||
deploy-*.tar
|
||||
deploy-*.tar.gz
|
||||
*.log
|
||||
*.log.err
|
||||
build_logs/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# 环境变量(敏感)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Docker(自身)
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
deploy-nas/
|
||||
deploy-server/
|
||||
|
||||
# workbuddy(不需入镜像)
|
||||
.workbuddy/
|
||||
@@ -0,0 +1,54 @@
|
||||
# 🐛 Bug 报告
|
||||
|
||||
## 概要 (Summary)
|
||||
<!-- 简要描述这个 Bug -->
|
||||
|
||||
## 复现步骤 (Steps to Reproduce)
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 期望行为 (Expected Behavior)
|
||||
<!-- 期望的正确行为 -->
|
||||
|
||||
## 实际行为 (Actual Behavior)
|
||||
<!-- 实际发生的错误行为 -->
|
||||
|
||||
## 环境信息 (Environment)
|
||||
- **服务**: [前端 admin/agent/h5/portal / 后端 / 数据库 / Redis]
|
||||
- **环境**: [本地开发 / NAS 预生产 / 公司生产]
|
||||
- **浏览器**: [Chrome 120 / Safari 17 / 企业微信 X.X / 微信 X.X]
|
||||
- **设备**: [Windows 11 / macOS 14 / iOS 17 / Android 14]
|
||||
- **版本**: [如 backend v0.5.0 / frontend-admin v0.5.0]
|
||||
|
||||
## 截图/日志 (Screenshots / Logs)
|
||||
<!-- 如果有截图或日志,粘贴在这里 -->
|
||||
|
||||
## 严重度 (Severity)
|
||||
- [ ] 🔴 P0 - 生产环境阻塞(立即修)
|
||||
- [ ] 🟠 P1 - 主要功能不可用(本周修)
|
||||
- [ ] 🟡 P2 - 一般问题(下周修)
|
||||
- [ ] 🟢 P3 - 体验改进(下季度)
|
||||
|
||||
## 影响范围 (Impact)
|
||||
- [ ] 全部用户
|
||||
- [ ] 部分用户(请说明哪些)
|
||||
- [ ] 特定场景(请说明)
|
||||
|
||||
## 紧急程度 (Urgency)
|
||||
<!-- 是否影响业务运营?是否需要立即响应? -->
|
||||
|
||||
## 关联 (Related)
|
||||
<!-- 相关 Issue / PR / 文档 -->
|
||||
|
||||
## 验收标准 (Acceptance Criteria)
|
||||
- [ ] Bug 复现步骤明确
|
||||
- [ ] 已尝试排查根因
|
||||
- [ ] 已提供日志或截图
|
||||
- [ ] 已与相关方沟通
|
||||
|
||||
---
|
||||
|
||||
**Reporter**: @your-username
|
||||
**Date**: YYYY-MM-DD
|
||||
**Component**: [backend / frontend-X / infra / docs]
|
||||
@@ -0,0 +1,70 @@
|
||||
# ✨ 功能请求
|
||||
|
||||
## 概要 (Summary)
|
||||
<!-- 简短描述这个功能 -->
|
||||
|
||||
## 业务背景 (Business Context)
|
||||
### 痛点
|
||||
<!-- 当前存在什么问题? -->
|
||||
|
||||
### 期望价值
|
||||
<!-- 这个功能能带来什么价值? -->
|
||||
|
||||
### 相关方
|
||||
<!-- 谁会用到?产品经理/坐席/员工/管理员? -->
|
||||
|
||||
## 详细方案 (Detailed Proposal)
|
||||
### 用户故事
|
||||
```
|
||||
作为 [角色]
|
||||
我想要 [功能]
|
||||
以便于 [价值]
|
||||
```
|
||||
|
||||
### 交互流程
|
||||
<!-- 描述关键交互步骤 -->
|
||||
1. 用户操作
|
||||
2. 系统响应
|
||||
3. ...
|
||||
|
||||
### 数据模型(如有)
|
||||
<!-- 涉及表 / 字段变更 -->
|
||||
|
||||
### API 设计(如有)
|
||||
<!-- 端点 / 请求 / 响应 -->
|
||||
|
||||
### UI 草图(如有)
|
||||
<!-- 链接 Figma / 截图 / ASCII -->
|
||||
|
||||
## 替代方案 (Alternatives)
|
||||
<!-- 考虑过其他方案吗?优劣? -->
|
||||
|
||||
## 验收标准 (Acceptance Criteria)
|
||||
- [ ] 功能满足用户故事
|
||||
- [ ] 通过单元测试(覆盖率 > 80%)
|
||||
- [ ] 通过集成测试
|
||||
- [ ] 通过 E2E 测试(关键路径)
|
||||
- [ ] UI 适配桌面 + 移动
|
||||
- [ ] 错误处理完善
|
||||
- [ ] 日志/监控接入
|
||||
- [ ] 文档更新(API + 用户)
|
||||
|
||||
## 优先级 (Priority)
|
||||
- [ ] 🔴 P0 - 阻塞业务
|
||||
- [ ] 🟠 P1 - 重要功能
|
||||
- [ ] 🟡 P2 - 增强功能
|
||||
- [ ] 🟢 P3 - 锦上添花
|
||||
|
||||
## 关联 (Related)
|
||||
- 相关 Issue / PR
|
||||
- 相关文档
|
||||
- 依赖项
|
||||
|
||||
## 估算 (Estimation)
|
||||
<!-- 时间 / 工作量 -->
|
||||
|
||||
---
|
||||
|
||||
**Reporter**: @your-username
|
||||
**Date**: YYYY-MM-DD
|
||||
**Component**: [backend / frontend-X / docs / infra]
|
||||
@@ -0,0 +1,121 @@
|
||||
# Pull Request 模板
|
||||
|
||||
> **提交前必读**:
|
||||
> - [ ] PR 标题用 [Conventional Commits](https://www.conventionalcommits.org/)(如 `feat:` / `fix:` / `docs:`)
|
||||
> - [ ] 已关联 Issue(用 `Closes #N` / `Refs #N`)
|
||||
> - [ ] 已通过 pre-commit-check
|
||||
> - [ ] 已更新相关文档
|
||||
> - [ ] 已自测通过
|
||||
|
||||
---
|
||||
|
||||
## 📋 概要 (Summary)
|
||||
<!-- 简短描述这个 PR 做了什么 -->
|
||||
|
||||
## 🎯 关联 (Related)
|
||||
<!-- 关联的 Issue / 需求 / 文档 -->
|
||||
|
||||
- Closes #
|
||||
- Refs #
|
||||
|
||||
## 🏷️ 类型 (Type of Change)
|
||||
<!-- 请勾选 -->
|
||||
|
||||
- [ ] 🐛 Bug 修复
|
||||
- [ ] ✨ 新功能
|
||||
- [ ] 📈 性能优化
|
||||
- [ ] 🔐 安全修复
|
||||
- [ ] 🏗️ 基础设施(部署/工具)
|
||||
- [ ] 📚 文档
|
||||
- [ ] 🧹 重构
|
||||
- [ ] 🧪 测试
|
||||
|
||||
## 🛠️ 改动 (Changes)
|
||||
<!-- 详细描述改动内容 -->
|
||||
|
||||
### 后端
|
||||
- [ ] 改 models(alembic 迁移?)
|
||||
- [ ] 改 API 端点
|
||||
- [ ] 改 service / utils
|
||||
- [ ] 改配置
|
||||
|
||||
### 前端
|
||||
- [ ] admin
|
||||
- [ ] agent
|
||||
- [ ] h5
|
||||
- [ ] portal
|
||||
|
||||
### 基础设施
|
||||
- [ ] Dockerfile
|
||||
- [ ] nginx
|
||||
- [ ] 脚本
|
||||
- [ ] CI/CD
|
||||
|
||||
### 文档
|
||||
- [ ] README
|
||||
- [ ] docs/
|
||||
- [ ] 注释
|
||||
|
||||
## 🧪 测试 (Testing)
|
||||
<!-- 怎么测试的? -->
|
||||
|
||||
### 单元测试
|
||||
- [ ] 加新测试
|
||||
- [ ] 现有测试通过
|
||||
|
||||
### 集成测试
|
||||
- [ ] 后端:`pytest backend/tests/`
|
||||
- [ ] 前端:`npm run test`(如有)
|
||||
|
||||
### 手动测试
|
||||
<!-- 手动测试步骤 -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### 回归测试
|
||||
<!-- 是否影响其他模块? -->
|
||||
|
||||
## 📸 截图/录屏 (Screenshots / Recordings)
|
||||
<!-- UI 改动必有 -->
|
||||
|
||||
## ⚠️ 风险与回滚 (Risks & Rollback)
|
||||
<!-- 风险评估,如何回滚 -->
|
||||
|
||||
### 风险
|
||||
<!-- 列出潜在风险 -->
|
||||
|
||||
### 回滚方案
|
||||
<!-- 如何回滚 -->
|
||||
|
||||
## ✅ 验收清单 (Acceptance Checklist)
|
||||
- [ ] 代码风格一致
|
||||
- [ ] 注释充分
|
||||
- [ ] 类型注解完整(Python)
|
||||
- [ ] 无 console.log
|
||||
- [ ] 无未使用的 import
|
||||
- [ ] 无硬编码(走 config)
|
||||
- [ ] 无 token / 凭据
|
||||
- [ ] 错误处理完善
|
||||
- [ ] 日志记录
|
||||
- [ ] 性能考虑
|
||||
- [ ] 安全考虑
|
||||
|
||||
## 📚 文档 (Documentation)
|
||||
- [ ] API 文档更新
|
||||
- [ ] 用户文档更新
|
||||
- [ ] 部署文档更新
|
||||
- [ ] CHANGELOG.md 更新
|
||||
|
||||
## 🔗 关联资源 (References)
|
||||
- 相关 PR
|
||||
- 相关 Issue
|
||||
- 相关文档
|
||||
- 外部资源
|
||||
|
||||
---
|
||||
|
||||
**Author**: @your-username
|
||||
**Reviewer**: @reviewer-username
|
||||
**Date**: YYYY-MM-DD
|
||||
@@ -0,0 +1,203 @@
|
||||
# =============================================================================
|
||||
# Gitea 内置依赖更新(替代 Dependabot)
|
||||
# =============================================================================
|
||||
# 功能: 自动检查依赖更新,提 PR 到仓
|
||||
# 频率: weekly
|
||||
# 注: Gitea 1.19+ 支持此功能
|
||||
# =============================================================================
|
||||
|
||||
version: 2
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 通用配置
|
||||
# -----------------------------------------------------------------------------
|
||||
# 限制单批 PR 数(防刷屏)
|
||||
# 0 = 不限,实际建议 5-10
|
||||
# 标签:让 reviewer 一眼看出"依赖更新"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "auto-update"
|
||||
|
||||
# 自动合并 patch 级别更新
|
||||
# minor / patch 都不自动,等 reviewer 评
|
||||
# 如要开启,加: auto-merge: true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python 后端
|
||||
# -----------------------------------------------------------------------------
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
- "backend"
|
||||
# 忽略大版本(等人工)
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 前端 admin
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend-admin"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
- "admin"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 前端 agent
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend-agent"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
- "agent"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 前端 h5
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend-h5"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
- "h5"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 前端 portal
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend-portal"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
- "portal"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Docker 基础镜像
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "backend"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/frontend-admin"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "frontend"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/frontend-agent"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "frontend"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/frontend-h5"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "frontend"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/frontend-portal"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "frontend"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GitHub Actions / Gitea Actions(如有)
|
||||
# -----------------------------------------------------------------------------
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
+97
@@ -106,6 +106,10 @@ it_smart_desk.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Base64 编码凭据(部署脚本用,含 admin token / 证书)
|
||||
# 2026-06-22: gen_admin_token.b64 含生产 admin token,不能入仓
|
||||
*.b64
|
||||
|
||||
# pytest / 临时
|
||||
.pytest_cache/
|
||||
/tmp/
|
||||
@@ -136,3 +140,96 @@ wecom-it-desk-server-deploy.zip
|
||||
.workbuddy/logs/
|
||||
.workbuddy/*.log
|
||||
.workbuddy/*.log.err
|
||||
# workbuddy 记忆目录(个人上下文,不 入仓)
|
||||
.workbuddy/memory/
|
||||
|
||||
# =============================================================================
|
||||
# 工作树清理 (2026-07-09): 产物 / 临时 / 上传 / 调试 dump 不入仓
|
||||
# 说明: 仅停止版本跟踪, 文件保留在磁盘 (git rm --cached), 全部可逆
|
||||
# =============================================================================
|
||||
|
||||
# 部署/构建产物 zip (体积大, 含 dist)
|
||||
*.zip
|
||||
*-dist/
|
||||
|
||||
# 压缩包 / 备份 dump
|
||||
*.tar.xz
|
||||
*.dump
|
||||
|
||||
# 后端运行时上传 (员工上传的 pdf/png, 非源码)
|
||||
backend/media/files/
|
||||
backend/media/images/
|
||||
|
||||
# 调试 dump
|
||||
backend/*_dump.txt
|
||||
backend/all_routes.txt
|
||||
backend/auto_routes.txt
|
||||
backend/route_dump*.txt
|
||||
backend/api_router_dump.txt
|
||||
|
||||
# 根目录 scratch 脚本 (一次性修复/检查/测试)
|
||||
check_*.py
|
||||
fix_*.py
|
||||
extract_and_migrate.py
|
||||
update_password.py
|
||||
upload_*.py
|
||||
_ctrt_transform.py
|
||||
encoded_knowledge_suggestion.txt
|
||||
deploy_to_container.py
|
||||
test_login*.py
|
||||
test_login*.json
|
||||
test_login*.sh
|
||||
test_redis*.py
|
||||
test_redis*.sh
|
||||
test_send.sh
|
||||
test_redis_conn.py
|
||||
keep_alive.ps1
|
||||
start_backend.sh
|
||||
start_dev_services.bat
|
||||
start_dev_services.ps1
|
||||
otp-bind.sh
|
||||
|
||||
# 部署 scratch
|
||||
deploy-*.bat
|
||||
deploy-staging/
|
||||
deploy-scripts/
|
||||
deploy-temp/
|
||||
chunks/
|
||||
dify/
|
||||
ragflow/
|
||||
neo4j5*
|
||||
|
||||
# nginx 实验配置 (nginx/nginx.dev.conf 如需则解除忽略)
|
||||
nginx*.conf
|
||||
nginx.conf.bak
|
||||
itdesk-nginx-block.conf
|
||||
|
||||
# 截图 / 录屏
|
||||
login_*.png
|
||||
login_shot.mjs
|
||||
docs/06-测试质量/e2e-screenshots/
|
||||
|
||||
# base64 头 dump
|
||||
*_b64_head.txt
|
||||
|
||||
# scratch SQL (一次性)
|
||||
disable_mfa.sql
|
||||
check_sxn.sql
|
||||
reset_pass.sql
|
||||
reset_pwd.sql
|
||||
|
||||
# 杂项
|
||||
-w
|
||||
|
||||
# 补充忽略 (2026-07-09 收尾): 残余 scratch / 构建缓存
|
||||
ops-tools/
|
||||
dist-new/
|
||||
_tmp_*
|
||||
*.timestamp-*.mjs
|
||||
fix_redis.sh
|
||||
/test/
|
||||
backend/scripts/create_test_agent.py
|
||||
|
||||
# 补充忽略 (2026-07-09 WIP 提交): 新增构建产物
|
||||
dist-deploy/
|
||||
dist-v2/
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"timestamp": "2026-07-03T08:44:12.328961",
|
||||
"tasks": {
|
||||
"A-T1": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T2": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T3": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T4": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T5": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T6": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T7": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T8": {
|
||||
"status": "✅已完成",
|
||||
"owner": "QA",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T9": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T10": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T11": {
|
||||
"status": "✅已完成",
|
||||
"owner": "项目经理",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T12": {
|
||||
"status": "✅已完成",
|
||||
"owner": "QA",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T13": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"A-T14": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"A-T15": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"A-T16": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "QA+工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"A-T17": {
|
||||
"status": "🔴阻塞",
|
||||
"owner": "运维",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"A-T18": {
|
||||
"status": "🔴阻塞",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"A-T19": {
|
||||
"status": "🔴阻塞",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T1": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T2": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T3": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T4": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T5": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T6": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T7": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"B-T8": {
|
||||
"status": "🟢已完成",
|
||||
"owner": "QA",
|
||||
"actual_end": "✅ 33/33 PASS"
|
||||
},
|
||||
"B-T9": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "QA",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T10": {
|
||||
"status": "⏳等待中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T11": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "项目经理",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T12": {
|
||||
"status": "⏳等待中",
|
||||
"owner": "QA",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T13": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T14": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T15": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T16": {
|
||||
"status": "🔵进行中",
|
||||
"owner": "QA+工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"B-T17": {
|
||||
"status": "🟢已修复",
|
||||
"owner": "工程师",
|
||||
"actual_end": "-"
|
||||
},
|
||||
"C-T1": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T2": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T3": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T4": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T5": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T6": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T7": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T8": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T9": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T10": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T11": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T12": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T13": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T14": {
|
||||
"status": "✅已完成",
|
||||
"owner": "项目经理",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T15": {
|
||||
"status": "✅已完成",
|
||||
"owner": "QA",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T16": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T17": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T18": {
|
||||
"status": "✅已完成",
|
||||
"owner": "工程师",
|
||||
"actual_end": "07-02"
|
||||
},
|
||||
"C-T19": {
|
||||
"status": "✅已完成",
|
||||
"owner": "QA+工程师",
|
||||
"actual_end": "07-02"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"total": 55,
|
||||
"not_started": 0,
|
||||
"ready": 2,
|
||||
"in_progress": 9,
|
||||
"completed": 39,
|
||||
"blocked": 3,
|
||||
"delayed": 0,
|
||||
"waiting": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# 早班巡检自动化 - 执行记录
|
||||
|
||||
## 2026-07-04 09:30 执行结果
|
||||
|
||||
**数据来源**:`.taskboard-cache/任务执行状态看板_cache.json`(缓存时间 2026-07-03T08:44:12)
|
||||
**⚠️ 原始看板文件缺失**:`docs/小组任务书/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析
|
||||
|
||||
### 关键发现
|
||||
1. **看板源文件丢失**:`docs/小组任务书/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失
|
||||
2. **B-T10双重可激活信号**:①依赖B-T8已完成(33/33 PASS) ②REVIEW_B_T10.md显示代码评审已于07-03通过(IS_PASS: YES),但缓存中仍为⏳等待中
|
||||
3. **3个阻塞已逾期2天**:BLOCK-17(企微SSO)、BLOCK-19(扫码登录超时)、BLOCK-20(管理后台Network Error) 均 due 07-02,现已逾期2天
|
||||
4. **BLOCK-18状态矛盾持续**:B-T17标记🟢已修复,但BLOCK-18阻塞表仍为🔵排查中(07-03已发现,至今未修正)
|
||||
5. **B-T12依赖未知**:缓存无依赖关系数据,无法判断是否可激活
|
||||
6. **数据一致性问题持续**:B-T8(🟢已完成)和B-T17(🟢已修复)使用🟢图标但非"可立即启动"语义
|
||||
|
||||
### 全局进度(仅计✅已完成)
|
||||
- A组:13/19 (68%)
|
||||
- B组:7/17 (41%) — 若计入🟢已完成/已修复则9/17 (53%)
|
||||
- C组:19/19 (100%)
|
||||
- 整体:39/55 (71%) — 若计入🟢则41/55 (75%)
|
||||
|
||||
### PM行动项
|
||||
1. **恢复看板源文件** — `docs/小组任务书/任务执行状态看板.md` 缺失,需重建
|
||||
2. 通知B组激活B-T10(依赖已完成 + 评审已通过)
|
||||
3. 优先解决3个逾期阻塞(BLOCK-17/19/20),已逾期2天
|
||||
4. 确认BLOCK-18/B-T17真实状态并校正看板
|
||||
5. 核实B-T12依赖状态
|
||||
6. 核实A-T14~T16是否实质停滞
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 09:30 执行结果
|
||||
|
||||
**看板最后更新**:2026-07-02 21:30
|
||||
|
||||
### 关键发现
|
||||
1. **B-T10依赖已解除**:B-T8单元测试33/33 PASS,B-T10(代码评审+PR)应激活为🟢 — 需PM通知B组
|
||||
2. **3个阻塞问题已逾期1天**:BLOCK-17(企微SSO)、BLOCK-19(扫码登录超时)、BLOCK-20(管理后台Network Error) 均 due 07-02
|
||||
3. **BLOCK-18状态矛盾**:B-T17任务行标记🟢已修复,但阻塞表仍为🔵排查中
|
||||
4. **无即将到期任务**(07-03/07-04),但有3个已逾期
|
||||
5. **6处数据一致性问题**:概览表数据与任务清单不符,B-T11~T16状态逻辑矛盾
|
||||
|
||||
### 全局进度
|
||||
- A组:13/19 (68%) — 3个阻塞逾期
|
||||
- B组:9/17 (53%) — B-T10待激活
|
||||
- C组:19/19 (100%)
|
||||
- 整体:41/55 (75%)
|
||||
|
||||
### PM行动项
|
||||
1. 通知B组激活B-T10
|
||||
2. 优先解决3个逾期阻塞(BLOCK-17/19/20)
|
||||
3. 确认BLOCK-18/B-T17真实状态
|
||||
4. 校正看板数据(概览表+快速检索区)
|
||||
5. 核实A-T14~T16是否实质停滞
|
||||
@@ -0,0 +1,175 @@
|
||||
# IT服务台-看板变更即时监听 执行记录
|
||||
|
||||
## 2026-07-03 08:44
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行误报(B-T8/B-T17实际已"已完成",脚本将🟢误判为"可立即启动")
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 07:53
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行误报(B-T8/B-T17实际已"🟢已完成",脚本将🟢误判为"可立即启动")
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 06:57
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行误报(B-T8/B-T17实际已"已完成",脚本逻辑问题)
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 06:01
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行误报(B-T8/B-T17实际已"已完成",脚本逻辑问题)
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 05:06
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动激活误报(B-T8/B-T17实际已"已完成",脚本逻辑问题)
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 04:10
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行:B-T8, B-T17
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 03:15
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行误报(B-T8/B-T17实际已"已完成")
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 01:23
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行:B-T8, B-T17
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 00:27
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 脚本自动开始功能误报(B-T8/B-T17实际已"已完成",但被误判为"可立即启动")
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-02 23:31
|
||||
|
||||
### 执行结果
|
||||
- 无新变更检测到
|
||||
- 自动开始执行:B-T8, B-T17
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-02 21:40
|
||||
|
||||
### 执行结果
|
||||
- 检测到1项变更:B-T17 (H5消息发送500排查) 从阻塞变为已修复
|
||||
- 自动激活并开始执行:B-T8, B-T17
|
||||
|
||||
### 状态统计
|
||||
- 总任务:55
|
||||
- 已完成:39
|
||||
- 进行中:9
|
||||
- 可立即启动:2
|
||||
- 等待中:2
|
||||
- 阻塞:3
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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短路缺失
|
||||
- **第一步开发完整交付**,可进入部署阶段
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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
|
||||
@@ -1,358 +0,0 @@
|
||||
# 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验证
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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 个测试通过,确认功能正常
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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 条导入)
|
||||
@@ -1,209 +0,0 @@
|
||||
# 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 生效**
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
# 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 无新增错误
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# 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 镜像)
|
||||
```
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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 或换无痕窗口测试
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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` ✅ 成功
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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 ✅
|
||||
@@ -1,272 +0,0 @@
|
||||
# 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%**
|
||||
@@ -1,413 +0,0 @@
|
||||
# 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)** |
|
||||
@@ -1,271 +0,0 @@
|
||||
# workbuddy 今夜收尾任务(用户睡前贴给你,2026-06-14)
|
||||
|
||||
**触发日期**: 2026-06-14 睡前
|
||||
**关联工程**: wecom_it_smart_desk (Gitea 仓)
|
||||
**workbuddy token**: 已配 `.workbuddy/config.json` 的 `gitea.token`
|
||||
|
||||
---
|
||||
|
||||
## ▶▶▶ 任务清单(4 项)起
|
||||
|
||||
### T-1. 把 5 个 Claude 产物 commit + push Gitea
|
||||
|
||||
**前置读**:
|
||||
- `.workbuddy/memory/2026-06-14-批量任务.md`(总体任务)
|
||||
- `CONTRIBUTING.md`(commit 规范 + PR 流程)
|
||||
- `scripts/pre-commit-check.sh`(推送前 4 件套预检)
|
||||
|
||||
**5 个未提交产物**(`git status` 应显示):
|
||||
```
|
||||
M .gitignore
|
||||
M docs/风险跟踪表.md
|
||||
?? .workbuddy/memory/2026-06-14-批量任务.md
|
||||
?? docs/路线图/
|
||||
?? scripts/backup-gitea.sh
|
||||
?? scripts/pre-commit-check.sh
|
||||
```
|
||||
|
||||
**操作步骤**:
|
||||
|
||||
1. **cd 到仓根目录**:
|
||||
```bash
|
||||
cd D:\资料\03-项目开发\wecom_it_smart_desk
|
||||
```
|
||||
|
||||
2. **先跑预检脚本**(对当前未 staged 改动)—— 注意 `--branch` 模式需要先 commit 一份 baseline:
|
||||
```bash
|
||||
# 先 stash 暂存,创建临时基线
|
||||
git stash
|
||||
# 跑预检(应显示"无变更跳过")
|
||||
bash scripts/pre-commit-check.sh
|
||||
git stash pop
|
||||
```
|
||||
|
||||
3. **精确 add**(避免误入):
|
||||
```bash
|
||||
git add .gitignore
|
||||
git add docs/风险跟踪表.md
|
||||
git add docs/路线图/
|
||||
git add scripts/backup-gitea.sh
|
||||
git add scripts/pre-commit-check.sh
|
||||
git add .workbuddy/memory/2026-06-14-批量任务.md
|
||||
```
|
||||
|
||||
4. **验证 .workbuddy/config.json 没被 add**:
|
||||
```bash
|
||||
git status -s
|
||||
# 不应出现 .workbuddy/config.json
|
||||
# 如出现,git reset HEAD .workbuddy/config.json
|
||||
```
|
||||
|
||||
5. **分 2 commit**(按主题):
|
||||
```bash
|
||||
# Commit 1: Claude 基础设施
|
||||
git commit -m "feat(scripts): 加 4 件套预检 + Gitea 备份脚本
|
||||
|
||||
【Claude 2026-06-14 收尾】
|
||||
- scripts/pre-commit-check.sh: 推送前 4 件套自检(鉴权/依赖/alembic/配置)
|
||||
- scripts/backup-gitea.sh: Gitea 套件/容器通用备份(保留 7 天 + 恢复模式)
|
||||
- 防止 P0 漏洞再发(本次 Gitea 卸载清空事件教训)
|
||||
|
||||
Refs: #27 #28"
|
||||
```
|
||||
|
||||
6. **注意**:5 产物分 2 commit 也可,1 commit 也行。**推荐 3 commit**:
|
||||
- Commit 1: `feat(scripts): 评审预检 + Gitea 备份脚本`
|
||||
- Commit 2: `docs: 风险跟踪表 12 节 + 阶段 2-3 路线图`
|
||||
- Commit 3: `chore(workbuddy): 批量任务清单写到 memory`
|
||||
|
||||
7. **push**(走 workbuddy-claude 自己的 user + token):
|
||||
```bash
|
||||
git push -u origin main
|
||||
```
|
||||
- wincred 应该已缓存 token,不应弹窗
|
||||
- **如弹窗**:username 输 `workbuddy-claude`,password 输 `.workbuddy/config.json` 的 `gitea.token` 字段值
|
||||
|
||||
8. **验证推成功**:
|
||||
- Gitea 仓页 `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk` 看到 commit 数从 11 → 14
|
||||
|
||||
**验收**:
|
||||
- 3 commit 全部在 main
|
||||
- 评审报告 1 份(留给你 T-3 写)
|
||||
- 风险跟踪表 12 节在 main
|
||||
|
||||
---
|
||||
|
||||
### T-2. 更新 `.workbuddy/memory/MEMORY.md` 索引
|
||||
|
||||
**前置读**: `.workbuddy/memory/MEMORY.md`(现有索引格式)
|
||||
|
||||
**目标**: 把以下 3 个新文件加进索引(在 2026-06-14 那块下):
|
||||
- `2026-06-14-批量任务.md`(W-1~W-5 任务)
|
||||
- `2026-06-14-今夜-收尾任务.md`(T-1~T-4,即本文件)
|
||||
- **新增**:T-3 跑完会生成 `2026-06-14-评审-Gitea重建.md`,也加索引
|
||||
|
||||
**操作步骤**:
|
||||
1. Read `.workbuddy/memory/MEMORY.md`
|
||||
2. 在 2026-06-14 那节加:
|
||||
```markdown
|
||||
## 2026-06-14
|
||||
- [批量任务清单](2026-06-14-批量任务.md) — W-1~W-5 workbuddy 任务
|
||||
- [今夜收尾任务](2026-06-14-今夜-收尾任务.md) — T-1~T-4 Claude+workbuddy 协作
|
||||
- [评审 Gitea 重建](2026-06-14-评审-Gitea重建.md) — 卸载清空事件复盘
|
||||
```
|
||||
3. **add + commit + push**(同 T-1 流程,小改动可跟 T-1 一起 commit)
|
||||
|
||||
**验收**:
|
||||
- MEMORY.md 索引包含新文件
|
||||
- 用户查 memory 时能找到
|
||||
|
||||
---
|
||||
|
||||
### T-3. 跑 pre-commit-check.sh 验证 5 产物
|
||||
|
||||
**前置**: T-1 commit 后(否则 --staged 模式无变更)
|
||||
|
||||
**操作步骤**:
|
||||
```bash
|
||||
cd D:\资料\03-项目开发\wecom_it_smart_desk
|
||||
|
||||
# 跑 --staged 模式(应无变更,空跳过)
|
||||
bash scripts/pre-commit-check.sh
|
||||
|
||||
# 跑 --branch 模式(检查 main vs HEAD)
|
||||
bash scripts/pre-commit-check.sh --branch
|
||||
|
||||
# 跑 --strict 模式(任何 warn 失败)
|
||||
bash scripts/pre-commit-check.sh --branch --strict 2>&1 | tee /tmp/precommit-result.log
|
||||
```
|
||||
|
||||
**输出规范**:
|
||||
- 写 `docs/评审报告/workbuddy-2026-06-14-预检验证.md`:
|
||||
```markdown
|
||||
# pre-commit-check.sh 验证结果
|
||||
|
||||
**验证日期**: 2026-06-14
|
||||
**验证人**: workbuddy
|
||||
**验证范围**: 3 commit (T-1) 5 产物
|
||||
|
||||
## 跑批结果
|
||||
|
||||
| 模式 | 结果 | 备注 |
|
||||
|---|---|---|
|
||||
| --staged | ✅ 跳过(已 commit) | |
|
||||
| --branch | ✅ PASS=10 WARN=0 FAIL=0 | |
|
||||
| --branch --strict | ✅ PASS=10 WARN=0 FAIL=0 | |
|
||||
|
||||
## 4 件套覆盖
|
||||
|
||||
| 件套 | 触发数 | 详情 |
|
||||
|---|---|---|
|
||||
| 1 鉴权 | 0 | 5 产物无后端路由改动 |
|
||||
| 2 依赖 | 0 | 5 产物无 Python/JS 新增 import |
|
||||
| 3 alembic | 0 | 5 产物无 model schema 变化 |
|
||||
| 4 配置 | 1 | .gitignore 改 → 提示 .env.example 同步(已知) |
|
||||
```
|
||||
|
||||
**验收**:
|
||||
- 脚本无 ERROR 退出
|
||||
- 验证报告写完
|
||||
- 报告 add + commit + push(可跟 T-1 / T-2 一起)
|
||||
|
||||
---
|
||||
|
||||
### T-4. 起草 Gitea 重建评审报告(workbuddy 视角)
|
||||
|
||||
**前置读**:
|
||||
- `.workbuddy/memory/2026-06-14.md`(今天 workbuddy 视角的记录)
|
||||
- `docs/风险跟踪表.md` 第十二节(Claude 视角的复盘)
|
||||
|
||||
**目标**: 写 `docs/评审报告/workbuddy-2026-06-14-Gitea重建.md` —— workbuddy 视角的自评
|
||||
|
||||
**操作步骤**:
|
||||
|
||||
1. **新建文件** `docs/评审报告/workbuddy-2026-06-14-Gitea重建.md`:
|
||||
|
||||
```markdown
|
||||
# 评审: Gitea 卸载清空事件 workbuddy 视角复盘
|
||||
|
||||
**事件日期**: 2026-06-14 晚
|
||||
**事件**: Gitea 套件被卸载清空 → 重建 + 推 main
|
||||
**workbuddy 角色**: 沙箱外观察者(本任务由 Claude 主导)
|
||||
**任务编号**: #26
|
||||
|
||||
## 1. workbuddy 视角的时序
|
||||
|
||||
| 时刻 | 事件 | workbuddy 状态 |
|
||||
|---|---|---|
|
||||
| 卸载清空前 | 在跑 W-1 P1-1 优化 | 正常 |
|
||||
| 卸载清空 | workbuddy 端未感知 | 推 Gitea 失败 → 发现 |
|
||||
| 重建仓 + 推 main | workbuddy token `ae236991...` 失效 | 推失败 |
|
||||
| 创 workbuddy-claude user + 新 token | 收到新 token 通知 | 可继续 |
|
||||
|
||||
## 2. 反思教训(防 workbuddy 再犯)
|
||||
|
||||
1. **workbuddy-claude 旧 token 失效未主动清理** —— 反思:`config.json` 应加 token 有效期字段
|
||||
2. **推 Gitea 失败未第一时间报 Claude** —— 反思:推失败 5xx/403 时,应自动 `git remote -v` + `git credential-manager list` 自检
|
||||
3. **没主动提议自动备份** —— 反思:workbuddy 启动时应读 config.json 的 backup 字段,有则自跑
|
||||
|
||||
## 3. workbuddy 自查项(给下一轮推送用)
|
||||
|
||||
- [ ] config.json `gitea.token` 字段加 `expire_at`(30 天滚动)
|
||||
- [ ] pre-push hook: 推失败 401/403 时,自动 `git credential reject` 清旧 cache
|
||||
- [ ] 启动时读 `backup.path` 自动跑备份(P0 防御)
|
||||
- [ ] 推 main 前看 `docs/风险跟踪表.md` 最新状态(同步 Claude)
|
||||
|
||||
## 4. 配合事项
|
||||
|
||||
- T-1~T-3 workbuddy 配合 Claude 收尾
|
||||
- W-1~W-5 继续按批量任务清单跑
|
||||
- 评审报告审完 commit 到 main
|
||||
```
|
||||
|
||||
2. **add + commit + push**(可跟 T-1 一起)
|
||||
|
||||
**验收**:
|
||||
- 文件存在
|
||||
- 4 节都有内容
|
||||
- 跟 Claude 视角的 `docs/风险跟踪表.md` 第十二节 互为补充
|
||||
|
||||
---
|
||||
|
||||
## ▼▼▼ 任务清单止
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流
|
||||
|
||||
1. **T-1 优先**(commit + push)—— 让仓基线完整
|
||||
2. **T-2 + T-3 + T-4 并行**(独立小任务)—— workbuddy 可串行或并行(看客户端能力)
|
||||
3. **跑批前必读**:
|
||||
- `CONTRIBUTING.md`(commit 规范)
|
||||
- `scripts/pre-commit-check.sh` 顶部注释(用法)
|
||||
- `docs/风险跟踪表.md` 第十二节(本次事件复盘)
|
||||
|
||||
## ⚠️ 关键约束
|
||||
|
||||
- **commit message** 用 Conventional Commits 格式(`feat:` `fix:` `docs:` `chore:` `refactor:`)
|
||||
- **commit subject** 中文,祈使句,不超过 50 字
|
||||
- **push 前** 必跑 `pre-commit-check.sh`
|
||||
- **.workbuddy/config.json** 绝对不入仓(已在 .gitignore)
|
||||
- **.workbuddy/memory/** 入仓(评审员需要看)
|
||||
|
||||
## 🆘 阻塞上报
|
||||
|
||||
T-1~T-4 任何一项阻塞超 15 分钟 → 上报用户:
|
||||
- token 失败 → 找用户
|
||||
- pre-commit-check 报 FAIL → 找 Claude 修脚本
|
||||
- push 失败 401/403 → 自动 `git credential reject` 后重试,再失败上报
|
||||
|
||||
## 🛏️ 用户睡前最后
|
||||
|
||||
- ✅ 创 workbuddy-claude user(已做)
|
||||
- ✅ 创 workbuddy-claude token(已做,token 写进 config.json)
|
||||
- ✅ token 配进 config.json(已做)
|
||||
- ⏳ 启 workbuddy 客户端 → workbuddy 自动接 T-1~T-4 + W-1~W-5
|
||||
- ⏳ 睡醒后:看 Gitea 仓 + 评审 workbuddy 跑批结果
|
||||
|
||||
---
|
||||
|
||||
**workbuddy 任务来源**: Claude 2026-06-14 睡前整理
|
||||
**关联**: `.workbuddy/memory/2026-06-14-批量任务.md`(W-1~W-5)
|
||||
@@ -1,150 +0,0 @@
|
||||
# workbuddy 任务 — 修消息优化推送遗留 P1-1~4
|
||||
|
||||
**触发日期**: 2026-06-14
|
||||
**来源**: 之前评审报告 `docs/评审报告/workbuddy-2026-06-14-消息优化.md` 9.3 节遗留 4 P1
|
||||
**Gitea 仓(公网 Funnel)**: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk`
|
||||
**Gitea 仓(内网 LAN)**: `http://100.85.152.112:8418/simon/wecom_it_smart_desk`
|
||||
**当前 main HEAD**: `3c1d563`
|
||||
**workbuddy token**: 见 `.workbuddy/config.json` 的 `gitea.token` 字段
|
||||
|
||||
---
|
||||
|
||||
## ▶▶▶ 任务清单(按推荐度,4 项)起
|
||||
|
||||
### P1-1. upload 路径在容器本地(改 volume mount)
|
||||
|
||||
**问题**: 消息图片/文件上传路径(在容器内)会在容器重建时丢失。当前 docker-compose.yml 应该是 backend 容器内路径,**没挂载到 host** 或 NAS。
|
||||
|
||||
**修复**:
|
||||
1. 编辑 `docker-compose.yml` 的 backend 服务:
|
||||
```yaml
|
||||
backend:
|
||||
volumes:
|
||||
# 新增
|
||||
- backend-uploads:/app/uploads
|
||||
volumes:
|
||||
backend-uploads:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume1/docker/wecom-it-desk/uploads
|
||||
```
|
||||
2. `backend/app/api/messages.py` `upload_image` / `upload_message_file` 端点保存路径用 `UPLOAD_DIR` 配置项(从 `app.config` 读),不用硬编码
|
||||
3. 加 `UPLOAD_DIR=/app/uploads` 到 `.env.example`
|
||||
4. `nginx.conf` `/uploads/` 路径反代到 backend,或加 `location /uploads/ { root /volume1/...; }` 静态服务
|
||||
5. `scripts/deploy.sh` 创建 `/volume1/docker/wecom-it-desk/uploads/` 目录(部署时)
|
||||
|
||||
**验收**:
|
||||
- 容器重建后上传文件**不丢**
|
||||
- `df -h` 看 host 上 `/volume1/.../uploads` 体积能涨
|
||||
|
||||
### P1-2. 消息状态字段走 Alembic 迁移
|
||||
|
||||
**问题**: `backend/app/models/message.py` 之前加了 `status` 字段(已发/已送达/已读/撤回/删除等),但 **alembic 迁移未生成**。
|
||||
|
||||
**修复**:
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "add message status and recallable_until"
|
||||
# 检查生成的迁移脚本
|
||||
# 字段:
|
||||
# - status: String(20), default="sent", nullable=False
|
||||
# - recallable_until: DateTime, nullable=True
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**手动 SQL 不行**(评审报告已点出,部署步骤 6 引号未转义是历史错误)
|
||||
|
||||
**验收**:
|
||||
- `alembic upgrade head` 不报错
|
||||
- 生产数据库 `messages` 表有 `status` + `recallable_until` 字段
|
||||
|
||||
### P1-3. backend healthcheck 改用 Python 一行
|
||||
|
||||
**问题**: `docker-compose.yml` backend 用了 `curl http://localhost:8000/` 当 healthcheck,但**精简 backend 镜像没装 curl**(参考 [[backend-healthcheck-curl-pitfall]]),导致 `unhealthy` 但业务正常。
|
||||
|
||||
**修复**: 编辑 `docker-compose.yml`:
|
||||
```yaml
|
||||
backend:
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.connect(('localhost', 8000))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
或更稳(用 HTTP 检测):
|
||||
```yaml
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/system/health').read()"]
|
||||
# 需要 backend 有 /api/v1/system/health 端点(可能需要新增)
|
||||
```
|
||||
|
||||
**验收**:
|
||||
- `docker ps` 显示 backend `healthy`(不再 `unhealthy`)
|
||||
- 业务正常
|
||||
|
||||
### P1-4. ws_manager 实现消息状态广播
|
||||
|
||||
**问题**: 文档承诺了"消息状态广播"(撤回/已读/删除等事件推送),但 `ws_manager.py` 实际**没实现**。
|
||||
|
||||
**修复**: 在 `backend/app/services/ws_manager.py` 加方法:
|
||||
```python
|
||||
async def broadcast_message_status(
|
||||
self,
|
||||
conv_id: str,
|
||||
msg_id: str,
|
||||
status: str,
|
||||
extra: dict = None,
|
||||
) -> int:
|
||||
"""向会话所有参与方广播消息状态变更。
|
||||
|
||||
Args:
|
||||
conv_id: 会话ID
|
||||
msg_id: 消息ID
|
||||
status: 新状态(sent / delivered / read / recalled / deleted)
|
||||
extra: 额外数据(可选,如 recall_by / recall_at)
|
||||
|
||||
Returns:
|
||||
推送到客户端数量
|
||||
"""
|
||||
# 1. 查会话所有参与方(agent_id + employee_id)
|
||||
# 2. 找每个参与方的 WebSocket 连接
|
||||
# 3. 发 JSON 消息 {"type": "message_status", "msg_id": ..., "status": ..., "extra": ...}
|
||||
# 4. 返回推送数
|
||||
...
|
||||
```
|
||||
|
||||
调用方:`messages.py` `recall_message` / `delete_message` / `mark_read` 在改 DB 状态后,**调 `await ws_manager.broadcast_message_status(...)`**。
|
||||
|
||||
**验收**:
|
||||
- 端到端测试:坐席 A 撤回消息 → 坐席 B + H5 员工实时收到 `message_status` 推送
|
||||
- 前端(`useWebSocket.ts`)处理 `message_status` 类型消息(更新 UI)
|
||||
|
||||
## ▼▼▼ 任务清单止
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流(等 workbuddy 修完 4 项后)
|
||||
|
||||
1. workbuddy 修完 → 提交 commit 到 Gitea
|
||||
2. 通知 Claude 评审
|
||||
3. Claude 评审(对照 4 项 + 跑相关测试)
|
||||
4. 合并到 main
|
||||
5. 关 #23
|
||||
|
||||
## 🔴 评审历史(防 workbuddy 再犯)
|
||||
|
||||
参考评审报告 `docs/评审报告/workbuddy-2026-06-14-消息优化.md` 9.5 节:
|
||||
|
||||
- **P0 比例 46% (6/13) 过高** —— 后续推送需**强制走评审流程**
|
||||
- pre-commit 检查建议(Claude 可生成脚本):新增端点无 `Depends(...)` 鉴权 → 拒绝推送
|
||||
- 4 P1 一旦 P0 修完就推,**不要在评审未消化前叠加新功能**
|
||||
|
||||
## 关联
|
||||
|
||||
- 评审主报告: `docs/评审报告/workbuddy-2026-06-14-消息优化.md`
|
||||
- 风险跟踪表: 第九节(P1-1~4 状态追踪) + 即将加第十一节
|
||||
- Claude 记忆: `review-messages-2026-06-14.md`
|
||||
- Gitea 仓: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk` (公网 Funnel)
|
||||
@@ -1,140 +0,0 @@
|
||||
# workbuddy 任务 — 修 P0 安全评审遗留 5 项
|
||||
|
||||
**触发日期**: 2026-06-14
|
||||
**来源**: Claude 评审(主报告: `docs/评审报告/workbuddy-2026-06-14-P0安全.md`)
|
||||
**Gitea 仓(公网 Funnel URL)**: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk`
|
||||
**Gitea 仓(内网 LAN,快)**: `http://100.85.152.112:8418/simon/wecom_it_smart_desk`
|
||||
**当前 HEAD**: `9b6f477` (含 workbuddy 任务清单)
|
||||
**workbuddy token**: 见 `.workbuddy/config.json` 的 `gitea.token`(用户已配)
|
||||
|
||||
---
|
||||
|
||||
## ▶▶▶ 任务清单(按严重度,5 项)起
|
||||
|
||||
### 🔴 1. [P0] 修 ws.ts:用 Sec-WebSocket-Protocol 携带 token
|
||||
|
||||
**文件**: `frontend-agent/src/composables/useWebSocket.ts:106-110`
|
||||
|
||||
**问题**: 当前代码:
|
||||
```ts
|
||||
ws = new WebSocket(wsUrl, [], {
|
||||
headers: { Authorization: `Bearer ${agentStore.token}` }
|
||||
})
|
||||
```
|
||||
浏览器原生 WebSocket API 第 3 参数 options 没有 `headers` 字段。**Chromium / Firefox / Safari 全部忽略**。token 实际**未发送**。
|
||||
|
||||
**修复**:
|
||||
1. 前端改成:
|
||||
```ts
|
||||
ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
|
||||
```
|
||||
2. 服务端 `backend/app/api/ws.py` 改:
|
||||
```python
|
||||
# 优先从 subprotocol 取
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:]
|
||||
else:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
else:
|
||||
token = request.query_params.get("token", "")
|
||||
```
|
||||
3. H5 端 `h5_websocket_endpoint` 同改
|
||||
|
||||
### 🔴 2. [P0] 加 nginx access_log 关闭
|
||||
|
||||
**文件**:
|
||||
- `nginx.conf`(根目录)
|
||||
- `deploy-server/nginx.conf`
|
||||
|
||||
**修复**: 找 `location /api/` 段,在前后加:
|
||||
```nginx
|
||||
location /ws/ {
|
||||
access_log off;
|
||||
}
|
||||
```
|
||||
|
||||
### 🟡 3. [P1] 修 model `Mapped[str]` 类型 bug
|
||||
|
||||
**文件**: `backend/app/models/agent.py:142-148`
|
||||
|
||||
**问题**: `Mapped[str]` + `nullable=True` + `default=None` 严格模式下 `None` 赋值会报错。
|
||||
|
||||
**修复**:
|
||||
```python
|
||||
from typing import Optional
|
||||
...
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="本地密码哈希(bcrypt)",
|
||||
)
|
||||
```
|
||||
|
||||
### 🟡 4. [P1] 修降级放行必须 password 验证
|
||||
|
||||
**文件**: `backend/app/api/agents.py` `agent_login` 函数(企微 API 不可达分支)
|
||||
|
||||
**问题**: 走 "已注册坐席降级放行" 路径时,**不强制 password 验证**。P0-#5 加的 password 字段被绕过。
|
||||
|
||||
**修复**: 在降级放行分支检测:
|
||||
```python
|
||||
# 已有 agent 且 password_hash 存在 → 必须走 password 验证
|
||||
if agent and agent.password_hash:
|
||||
if not body.password:
|
||||
raise AppException(1011, "请输入本地密码")
|
||||
if not bcrypt.verify(body.password, agent.password_hash):
|
||||
raise AppException(1011, "本地密码错误")
|
||||
# 通过后放行
|
||||
```
|
||||
|
||||
### 🟡 5. [P1] requirements.txt 缺 passlib 依赖
|
||||
|
||||
**文件**: `backend/requirements.txt`
|
||||
|
||||
**问题**: `agents.py` 用了 `from passlib.hash import bcrypt`,但 requirements.txt **没加**。生产部署会 ImportError。
|
||||
|
||||
**修复**: 加一行:
|
||||
```
|
||||
passlib[bcrypt]==1.7.4
|
||||
```
|
||||
或(推荐,passlib 2024 停维护):
|
||||
```
|
||||
bcrypt==4.1.2
|
||||
```
|
||||
后者需同步改 `agents.py`:
|
||||
```python
|
||||
import bcrypt
|
||||
# 哈希
|
||||
bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
||||
# 验证
|
||||
bcrypt.checkpw(password.encode('utf-8'), agent.password_hash.encode('utf-8'))
|
||||
```
|
||||
|
||||
## ▼▼▼ 任务清单止
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流(等 workbuddy 修完 5 项后)
|
||||
|
||||
1. workbuddy 修完 → 提交 commit 到 Gitea
|
||||
2. 通知 Claude 评审
|
||||
3. Claude 评审(2/5 改成 5/5 完成)
|
||||
4. 合并 + 推 main
|
||||
5. 关 #18
|
||||
|
||||
## 🔴 token 状态(用户已配)
|
||||
|
||||
- 用户给 Claude 的 token: `255eeaf88b...`(已撤销请用户)
|
||||
- workbuddy 自己的 token: `workbuddy-claude`(在 `.workbuddy/config.json`)
|
||||
- 推 Gitea 走 HTTPS(SSH 2222 不可达 + .ssh 权限锁)
|
||||
|
||||
## 关联
|
||||
|
||||
- 评审主报告: `docs/评审报告/workbuddy-2026-06-14-P0安全.md`
|
||||
- 风险跟踪表: 第十节(5 项遗留追踪)
|
||||
- Claude 记忆: `review-p0-security-2026-06-14.md`
|
||||
- Gitea 仓: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk` (公网 Funnel)
|
||||
@@ -1,209 +0,0 @@
|
||||
# workbuddy 批量任务清单 — 2026-06-14 睡前启动
|
||||
|
||||
**生成日期**: 2026-06-14
|
||||
**生成人**: Claude
|
||||
**启动条件**:
|
||||
1. 用户在 Gitea 创 `workbuddy-claude` user account
|
||||
2. 用户创 `workbuddy-claude` 的 access token(权限 `repository` + `issue` + `user`)
|
||||
3. 用户把 token 配到 `.workbuddy/config.json` 的 `gitea.token` 字段
|
||||
4. workbuddy 客户端启动时读这份 memory → 按顺序接任务
|
||||
|
||||
---
|
||||
|
||||
## ▶▶▶ 任务清单(5 项,按优先级)起
|
||||
|
||||
### W-1. P1-1 优化: named volume → host bind mount
|
||||
|
||||
**任务编号**: #25
|
||||
**阻塞原因**: 当前 `docker-compose.yml` 用 named volume `backend-uploads`,容器重建不丢但 `docker-compose down -v` 会全丢
|
||||
**目标**: 改成 host bind mount 到 NAS `/volume1/docker/wecom-it-desk/uploads`
|
||||
|
||||
**修复**:
|
||||
1. 编辑 `docker-compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
backend-uploads:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume1/docker/wecom-it-desk/uploads
|
||||
```
|
||||
2. `scripts/deploy.sh` 部署时建 host 目录:
|
||||
```bash
|
||||
sudo mkdir -p /volume1/docker/wecom-it-desk/uploads
|
||||
sudo chown -R 1000:1000 /volume1/docker/wecom-it-desk/uploads
|
||||
```
|
||||
3. 加 deploy 文档警示"别用 `docker-compose down -v`"
|
||||
|
||||
**验收**:
|
||||
- 容器重建后上传文件不丢
|
||||
- `df -h /volume1/docker/wecom-it-desk/uploads` 体积能涨
|
||||
|
||||
**评审员**: Claude
|
||||
|
||||
---
|
||||
|
||||
### W-2. P0 二次评审 5 遗留修完
|
||||
|
||||
**任务编号**: #18 遗留
|
||||
**关联**: `docs/评审报告/workbuddy-2026-06-14-P0安全.md` 11.x 节(5 项遗留)
|
||||
|
||||
**5 项遗留**:
|
||||
1. **浏览器 WS API 不支持 header** —— 用 `Sec-WebSocket-Protocol: bearer.<token>` 方案
|
||||
2. **nginx access_log 没关** —— `location /ws/ { access_log off; }` 已修,验证部署版也有
|
||||
3. **类型 bug** —— `ws.py` 某处类型断言错误
|
||||
4. **降级放行** —— `agents.py` 缺 password 时,`existing_agent.password_hash` 已存在 → 必须 verify password,不能放行
|
||||
5. **缺依赖** —— `requirements.txt` 缺 `bcrypt` / `pyotp`(已加,验证)
|
||||
|
||||
**修复**: 逐项对照评审报告修复,**每项单独 commit**
|
||||
|
||||
**验收**:
|
||||
- 全部 5 项 commit 推 Gitea
|
||||
- 评审员 Claude 二次评审通过
|
||||
- 风险跟踪表 第九节 / 第十节 状态从 🟡 改 ✅
|
||||
|
||||
**评审员**: Claude
|
||||
|
||||
---
|
||||
|
||||
### W-3. pytest 基础配置 + 跑 pre-commit-check.sh
|
||||
|
||||
**任务编号**: README 已知问题 #2
|
||||
**关联**: `scripts/pre-commit-check.sh`(本次新增,C-1 任务)
|
||||
|
||||
**修复**:
|
||||
1. `backend/pytest.ini`(或 `pyproject.toml` [tool.pytest.ini_options]):
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -v --tb=short
|
||||
```
|
||||
2. `backend/tests/conftest.py`:
|
||||
- 异步 client fixture
|
||||
- 测试 DB(用 sqlite:///:memory:)
|
||||
- mock WECOM 凭据
|
||||
3. `backend/tests/test_agents.py`:
|
||||
- 鉴权测试(mock_login 关闭 / 开启)
|
||||
- password_hash 验证
|
||||
4. `backend/tests/test_messages.py`:
|
||||
- 5 个端点鉴权测试(P0-2~6)
|
||||
5. `backend/tests/test_ws.py`:
|
||||
- WS token 鉴权(Authorization header / subprotocol / query 三种)
|
||||
6. `scripts/pre-commit-check.sh` 加进 `scripts/deploy.sh` 流程(可选)
|
||||
|
||||
**验收**:
|
||||
- `cd backend && pytest` 跑过
|
||||
- CI 跑预检脚本
|
||||
- 评审员 Claude 看测试覆盖度
|
||||
|
||||
**评审员**: Claude
|
||||
|
||||
---
|
||||
|
||||
### W-4. Dify API 集成预研(POC)
|
||||
|
||||
**任务编号**: 阶段 3 启动前置(关联 `docs/路线图/阶段2-3-任务.md` §3.3)
|
||||
**关联**: `docs/现有系统交接文档内容.txt` + `docs/ExternalSystemAdapter设计文档.md`
|
||||
|
||||
**预研目标**:
|
||||
1. 查 Dify 工作流 API 文档(看是否需要新 app,还是共用)
|
||||
2. POC 三个端点:
|
||||
- `POST /v1/chat-messages` 流式对话
|
||||
- `POST /v1/workflows/run` 工作流触发
|
||||
- `POST /v1/datasets/{id}/retrieve` 知识库检索
|
||||
3. 在 `backend/app/services/dify_client.py` 写 Dify 客户端
|
||||
4. `backend/app/api/ai_wingman.py` 三个端点接 Dify 客户端
|
||||
5. 写 `docs/集成验证/Dify_POC_报告.md`
|
||||
|
||||
**验收**:
|
||||
- 三个端点跑通(返回 Dify 响应)
|
||||
- 文档含 API 限流 / 错误降级 / 配额申请
|
||||
- 评审员 Claude 看方案可行性
|
||||
|
||||
**评审员**: Claude
|
||||
|
||||
---
|
||||
|
||||
### W-5. nginx 配置审计(全局 access_log 检查)
|
||||
|
||||
**任务编号**: 新增(M-2 风险项 衍生)
|
||||
**关联**: `docs/风险跟踪表.md` 第十二节 M-2
|
||||
|
||||
**审计目标**:
|
||||
1. 扫描所有 `nginx.conf` / `deploy-server/nginx.conf` / `*/nginx.conf`
|
||||
2. 找敏感路径(WS / token / OAuth callback)是否都 `access_log off`
|
||||
3. 找未配 access_log off 但应配的路径
|
||||
4. 写 `docs/审计报告/nginx_access_log_审计.md`
|
||||
|
||||
**修复**: 缺的补 `access_log off;`
|
||||
|
||||
**验收**:
|
||||
- 审计报告列出所有敏感路径的 access_log 状态
|
||||
- 缺的已补 commit
|
||||
- 评审员 Claude 抽查 3 处
|
||||
|
||||
**评审员**: Claude
|
||||
|
||||
---
|
||||
|
||||
## ▼▼▼ 任务清单止
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流(workbuddy 启动后)
|
||||
|
||||
1. **读这份 memory** → 看 5 任务
|
||||
2. **按 W-1 → W-2 → W-3 → W-4 → W-5 顺序**(W-3 W-4 W-5 可并行)
|
||||
3. **每完成一项**:
|
||||
- 提交 commit(走 `scripts/pre-commit-check.sh`)
|
||||
- 推 Gitea 远端 `feature/xxx` 分支
|
||||
- 通知 Claude 评审
|
||||
- Claude 评审通过 → 用户合并 PR
|
||||
4. **状态同步**:
|
||||
- `docs/风险跟踪表.md` 更新状态
|
||||
- `.workbuddy/memory/{日期}-{主题}.md` 留评审记录
|
||||
|
||||
## ⚠️ 关键约束(读 README + CONTRIBUTING.md)
|
||||
|
||||
- **鉴权**: 新增/修改端点必须有 `Depends(get_current_agent)` 或 `_get_current_employee`
|
||||
- **依赖**: 新增第三方 import 必须同步 `requirements.txt` / `package.json`
|
||||
- **alembic**: model schema 变化必须生成迁移脚本
|
||||
- **配置**: nginx / docker / conf 改动 plan 写完必须做完
|
||||
- **评审报告**: 每次推送生成 `docs/评审报告/workbuddy-{日期}-{主题}.md`
|
||||
- **5 项遗留**: 上一轮评审遗留未修完,不许推新功能
|
||||
|
||||
## 🔗 关联文档
|
||||
|
||||
- 评审主报告: `docs/评审报告/`
|
||||
- 风险跟踪表: `docs/风险跟踪表.md` 第九/十/十一/十二节
|
||||
- 路线图 2-3 阶段: `docs/路线图/阶段2-3-任务.md`
|
||||
- 推送预检脚本: `scripts/pre-commit-check.sh`
|
||||
- 推送流程: `CONTRIBUTING.md` §PR 流程
|
||||
|
||||
## 🆘 阻塞上报
|
||||
|
||||
workbuddy 启动后,**任何一项阻塞超过 30 分钟未推进** → 上报用户:
|
||||
- token 问题 → 找用户
|
||||
- 凭据不全 → 找用户给 WECOM_SECRET / Dify API key
|
||||
- 测试失败定位 → 找 Claude
|
||||
- 评审反复打回 3 次 → 升级用户
|
||||
|
||||
## 🛏️ 用户睡前最后做的事
|
||||
|
||||
1. **Gitea Web** → 站点管理 → 用户 → **创建新用户**:
|
||||
- 用户名: `workbuddy-claude`
|
||||
- 邮箱: (用户填)
|
||||
- 密码: (临时,首次登录改)
|
||||
- 权限: 普通用户(非管理员)
|
||||
2. **用 simon token 创 workbuddy-claude 的 access token**:
|
||||
- 登录 workbuddy-claude 账号 → 头像 → 设置 → 应用 → 创建
|
||||
- 令牌名: `claude-push`
|
||||
- 权限: `repository` (读/写) + `issue` (读/写) + `user` (读)
|
||||
3. **把 workbuddy-claude token 粘给 Claude**:
|
||||
- Claude 写进 `.workbuddy/config.json` 的 `gitea.token` 字段
|
||||
- 同时配 Gitea Web 的 deploy key(ssh,可选)
|
||||
4. (可选)改 `docs/风险跟踪表.md` 第十二节 §12.4 待办 #5 → `block_admin_merge` 改 `true`
|
||||
|
||||
完成上述 3 步 → workbuddy 客户端启动 → 自动接 5 任务
|
||||
@@ -1,64 +0,0 @@
|
||||
# workbuddy 评审反馈 — 2026-06-14 P0 安全止血
|
||||
|
||||
**推送内容**: WS token 鉴权改造 + 坐席本地密码 + secret 管理规划文档
|
||||
**评审日期**: 2026-06-14
|
||||
**评审人**: Claude
|
||||
**主报告**: `D:\资料\03-项目开发\wecom_it_smart_desk\docs\评审报告\workbuddy-2026-06-14-P0安全.md`
|
||||
**commit**: 3735dc0 (本地 main,未推 Gitea)
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 给 workbuddy 的关键反馈(高优先级)
|
||||
|
||||
1. **🔴 浏览器 WebSocket API 不支持自定义 header** — 误用 Node.js `ws` 库的 options.headers
|
||||
2. **🔴 nginx access_log 没关** — 即使前端修好,token 仍经 access_log 泄露
|
||||
3. **🟡 Mapped[str] + nullable=True 类型不一致** — 改 Optional[str]
|
||||
4. **🟡 企微降级放行仍能绕过 password 验证** — P0-#5 被反削弱
|
||||
5. **🟡 requirements.txt 缺 passlib** — 部署会 ImportError
|
||||
|
||||
## 🔴 遗留 5 项(下一轮必修)
|
||||
|
||||
| # | 严重度 | 文件 | 修复要点 |
|
||||
|---|---|---|---|
|
||||
| 1 | 🔴 P0 | `frontend-agent/.../useWebSocket.ts:106-110` | 改 `new WebSocket(wsUrl, [\`bearer.${token}\`])` + 服务端从 `sec-websocket-protocol` 取 |
|
||||
| 2 | 🔴 P0 | `nginx.conf` + `deploy-server/nginx.conf` | 加 `location /ws/ { access_log off; }` |
|
||||
| 3 | 🟡 P1 | `backend/app/models/agent.py:142-148` | `Mapped[str]` → `Mapped[Optional[str]]` |
|
||||
| 4 | 🟡 P1 | `backend/app/api/agents.py` 降级放行 | 检测 `agent.password_hash` 存在 → 强制 password |
|
||||
| 5 | 🟡 P1 | `backend/requirements.txt` | 加 `passlib[bcrypt]==1.7.4` 或改用原生 `bcrypt==4.1.2` |
|
||||
|
||||
## 🟢 评审验收
|
||||
|
||||
- ✅ ws.py 服务端:header 优先 + query 降级,**逻辑正确**
|
||||
- ✅ model 字段定义:`password_hash` String(128) nullable,**结构 OK**(类型注解除外)
|
||||
- ✅ schema:`AgentLogin.password` + `AgentPasswordUpdate`,**OK**
|
||||
- ✅ 改密端点 `POST /agents/password`:走 `Depends(get_current_agent)`,**OK**
|
||||
- ✅ alembic 008:down_revision='007_role_system' 正确,**OK**
|
||||
- ✅ docs/安全/secret-管理.md:**作为规划文档 OK**
|
||||
|
||||
## 📊 完成度
|
||||
|
||||
| 任务 | 完成 |
|
||||
|---|---|
|
||||
| P0-#1 WECOM_SECRET 集中化 | 🟡 仅规划文档 |
|
||||
| P0-#2 SSL 私钥在仓 | 🟢 之前已修(8-A 阶段) |
|
||||
| P0-#3 Mock login | 🟢 之前已修 |
|
||||
| P0-#4 WS token URL/日志 | 🟡 半成品(服务端 OK,前端 + nginx 待关) |
|
||||
| P0-#5 坐席本地密码 | 🟡 半成品(模型/Schema/端点 OK,类型 + 降级 + 依赖) |
|
||||
|
||||
**整体**: 2/5 P0 真正完成,3 项遗留待下一轮。
|
||||
|
||||
## 🔁 流程建议
|
||||
|
||||
- 推送前自检清单:
|
||||
- [ ] 浏览器 WebSocket API 边界(不要用 `ws` 库的 options.headers)
|
||||
- [ ] nginx/conf 改动 plan 写了就必须做
|
||||
- [ ] Mapped[T] + nullable=True 必须用 Optional
|
||||
- [ ] 改代码必须同步 requirements.txt
|
||||
- [ ] 加新鉴权必须 review 已有降级路径是否被绕过
|
||||
- **强烈建议**: workbuddy 推送前先回答"我的改动在浏览器侧能跑吗?"(不要假设 Node.js API = 浏览器 API)
|
||||
|
||||
## 🔗 推 Gitea 状态
|
||||
|
||||
- **本地 commit 3735dc0**: ✅ 已存
|
||||
- **推 Gitea**: 🔴 卡 #8(MariaDB 套件未装)
|
||||
- **下次**: Gitea 起来后 `git push -u origin main` 推 → workbuddy 拿 Gitea URL 二次评审
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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 全部修完再合入,**不要在评审发现的问题未修前再叠加新功能**。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,165 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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
|
||||
+109
-195
@@ -1,209 +1,123 @@
|
||||
# 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
|
||||
- **AI交互原则**:小段多回合交互,禁止一次性大段回复
|
||||
- **文档管理**:统一保存 `docs/` 目录,按类型分子目录
|
||||
- **资源申请流程**:所有资源申请→`docs/资源申请清单.md`
|
||||
- **原型已锁定**:坐席v5.3 + H5 v1.1
|
||||
- **UI偏好**:企微浅色扁平风格,accent=#07C160
|
||||
- **术语统一**:"人工"=用户呼叫坐席;"摇人"=坐席呼叫坐席
|
||||
- **双企微应用**:正式(itsupport.servyou.com.cn) + 测试(已下线)
|
||||
- **统一入口架构**:`/itportal/` 角色选择 → user/agent/admin
|
||||
- **OTP双因素认证**:admin角色访问时验证
|
||||
|
||||
## 技术架构
|
||||
- **坐席端**:Vue 3 + TS + Vite + Element Plus + Pinia
|
||||
- **H5用户端**:Vue 3 + Vant 4 + TS
|
||||
- **管理后台**:Vue 3 + TS + Element Plus + Tailwind + Pinia (`frontend-admin/`)
|
||||
- **前端**:坐席(Vue3+Element Plus) / H5(Vue3+Vant4) / 管理后台(Vue3+Element+Tailwind)
|
||||
- **后端**: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个服务)
|
||||
- **本地开发**:Python 3.12 venv + SQLite
|
||||
- **字段映射**:后端`id`/`sender_type` → H5前端`message_id`/`message_type`,映射层在 `frontend-h5/src/api/conversation.ts` 的 `mapMessage()`
|
||||
- **WS广播**:H5发消息后通过 `ws_manager.broadcast()` 实时推送给坐席
|
||||
- **API超时**:默认20s,消息发送30s,文件上传60s
|
||||
|
||||
## 部署
|
||||
- **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个分组
|
||||
- **NAS测试**:~~itdesk.amanzac.com~~ (已下线)
|
||||
- **正式服务器**:itsupport.servyou.com.cn (10.90.5.110)
|
||||
- **堡垒机**:sxn@10.212.189.210:2222 (OTP)
|
||||
- **文件上传**:只能通过堡垒机手动上传到 `/tmp/`
|
||||
|
||||
## 外部系统集成
|
||||
- **北森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(静态数据);火绒=安全源不参与映射
|
||||
- **火绒企业版**:HMAC-SHA1认证,核心接口 `_leak`(高危漏洞) / `_virus_events`(病毒事件)
|
||||
- **联软LV7000**:三层认证,核心价值 `strusername` 字段=员工→终端映射
|
||||
- **Dify**:生产 `http://yw-dify.dc.servyou-it.com/dify2openai/`
|
||||
- **RAGFlow**:生产 `http://10.80.0.85:8080/` / API `:9380`
|
||||
- **aTrust**:HMAC-SHA256,待获取API密钥
|
||||
- **映射策略**:联软(主) > 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` 无认证,保留给坐席端使用
|
||||
## 管理后台
|
||||
- 路由前缀 `/api/admin/`;权限 require_admin
|
||||
- 已实现:仪表盘/功能开关/坐席管理/分配模式/快速回复审核/集成配置/会话监控/会话审计/坐席绩效/系统日志/角色管理
|
||||
- 集成三种配置模式:url_key / access_key / account_password
|
||||
|
||||
## 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超时优化
|
||||
- 双通道:企微消息(必达) + WebSocket(即时)
|
||||
- WS端点:`/ws/h5/{employee_id}?token=xxx`
|
||||
- 降级策略:WS断连→3秒轮询
|
||||
- **本地消息缓存 (v0.7.4+)**:
|
||||
- 登录后优先加载本地缓存消息,立即显示历史记录
|
||||
- 同时异步从后端获取最新消息,合并去重后更新缓存
|
||||
- 缓存key:`h5_messages_cache`,有效期7天,最多100条/会话
|
||||
- 发送消息和轮询时自动更新缓存
|
||||
- 登出时清除缓存
|
||||
|
||||
## 痛点清单
|
||||
1. 员工入口体验差 → 阶段二
|
||||
2. 坐席能力不稳定 → 阶段三
|
||||
3. 知识无法积累传承 → 阶段四
|
||||
4. 管理缺乏数据支撑 → 阶段四
|
||||
## 近期问题修复 (2026-07)
|
||||
- **OAuth重定向计数残留**:页面刷新后`oauth_redirect_count`未重置,导致误报"登录状态异常" → 在`employee.ts` store初始化时检测有效token后自动清除计数
|
||||
- **API响应解析错误**:Axios拦截器返回`{code:0, data:{}, message}`包装格式,但部分API直接访问`response.xxx`而非`response.data.xxx` → 修正`conversation.ts`中`sendMessage`函数的响应映射
|
||||
- **数据库缺失列**:`messages`表缺少`is_recalled`列 → `ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_recalled BOOLEAN DEFAULT FALSE;`
|
||||
- **数据库列类型错误**:`messages.id`列为uuid类型但代码传入varchar → `ALTER TABLE messages ALTER COLUMN id TYPE character varying(36);`
|
||||
- **Nginx部署目录**:构建产物上传到`/opt/wecom-it-desk/frontend-h5/`但nginx挂载在`/opt/wecom-it-desk/html/itdesk/` → 部署时需复制文件到正确目录
|
||||
|
||||
## 五阶段演进
|
||||
1. MVP:转人工+H5+坐席+邀请+管理后台
|
||||
2. 完整流程:WS+排队+满意度+OAuth2
|
||||
3. AI Wingman+排查流程图
|
||||
4. 知识库+数据看板
|
||||
5. 自动化闭环
|
||||
|
||||
## 堡垒机运维 (jumpserver-ops)
|
||||
|
||||
**脚本位置**:`C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py`
|
||||
|
||||
### ⚠️ 服务器操作规则(重要)
|
||||
|
||||
**在对服务器进行任何操作时,优先使用 jumpserver-ops 自动完成,而非让用户手动操作。**
|
||||
|
||||
| 操作类型 | 自动执行方式 |
|
||||
|----------|-------------|
|
||||
| 远程命令 | `python jms_ops.py exec -c "命令"` |
|
||||
| 文件上传 | `python jms_ops.py upload 本地文件 /tmp/远程路径` |
|
||||
| 文件下载 | `python jms_ops.py download /tmp/远程文件 ./本地路径` |
|
||||
|
||||
### 使用方式
|
||||
|
||||
```bash
|
||||
# 第一次执行(自动登录并缓存会话)
|
||||
python jms_ops.py exec -c "hostname"
|
||||
|
||||
# 连续测试:使用 --reuse 复用会话(30分钟内有效,2-3秒执行)
|
||||
python jms_ops.py exec -c "uptime" --reuse
|
||||
python jms_ops.py exec -c "docker ps" -c "curl -s http://localhost/api/health" --reuse
|
||||
|
||||
# 文件上传(自动根据大小选择方式)
|
||||
# - ≤10MB: base64 编码传输(快速)
|
||||
# - >10MB: elFinder Web UI(浏览器自动化)
|
||||
python jms_ops.py upload local_file.txt /tmp/remote_file.txt
|
||||
|
||||
# 文件下载
|
||||
python jms_ops.py download /tmp/remote_file.txt local_file.txt
|
||||
|
||||
# 批量命令
|
||||
python jms_ops.py batch -f commands.txt
|
||||
|
||||
# 文件传输
|
||||
python jms_ops.py upload local.conf /tmp/remote.conf
|
||||
python jms_ops.py download /remote/path ./local.conf
|
||||
```
|
||||
|
||||
### 性能
|
||||
|
||||
| 场景 | 首次执行 | --reuse 复用 |
|
||||
|------|----------|--------------|
|
||||
| 单命令 | ~13s | ~2s |
|
||||
| 3 条命令 | ~13s | ~3s |
|
||||
|
||||
### 关键参数
|
||||
- `--reuse`:复用上次会话(减少登录次数,30分钟有效)
|
||||
- `--parallel`:并行模式(每命令独立 token+会话)
|
||||
- `--cmd-timeout`:每命令超时秒数(默认 15s)
|
||||
|
||||
## 文档关联修复 (2026-07-05)
|
||||
- **起因**:2026-07-04 docs/ 重组为数字编号子目录(01-项目总览~11-历史归档),但 mkdocs.yml nav / 文档间交叉引用 / 巡检自动化路径未同步,全面断链
|
||||
- **修复**:mkdocs.yml nav 9处断链重写(移除2个归档项,纳入5份新文档)+ 11处交叉引用修复 + 索引版本号修正(v1.0→v1.3) + 巡检自动化适配
|
||||
- **关键发现**:巡检 automation-1782986180887 原依赖的"小组任务书/任务执行状态看板.md"及A/B/C三组体系(认证加固16/消息系统16/AI数据19)从未创建,每日巡检必然失败;已适配为基于 01-项目状态看板.md 的状态巡检(P0/P1/等决策/进行中)
|
||||
- **修复报告**:docs/01-项目总览/文档关联修复报告-20260705.md
|
||||
- **保留未改**:目录树展示(历史快照)、归档文档内旧路径、历史任务标题
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 看板变更监听系统 (TaskBoard Monitor)
|
||||
|
||||
## 功能概述
|
||||
|
||||
通用型任务看板变更监听系统,可监控 Markdown 格式任务看板的状态变化,自动触发通知和激活逻辑。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 多小组并行开发项目
|
||||
- 任务看板状态变更需要即时通知
|
||||
- 依赖触发:当某任务完成时自动激活下游任务
|
||||
- 阻塞解除:当阻塞问题解决时自动通知相关小组
|
||||
|
||||
## 核心能力
|
||||
|
||||
1. **状态解析**:从 Markdown 看板中提取任务状态
|
||||
2. **变更检测**:对比上一次状态,检测新增变化
|
||||
3. **触发动作**:根据配置执行相应动作(通知、记录日志等)
|
||||
4. **可配置**:支持自定义看板路径、触发规则、通知方式
|
||||
5. **跨项目复用**:只需指定看板路径即可复用
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 基础监控
|
||||
|
||||
```
|
||||
TaskBoard Monitor: 检查 docs/小组任务书/任务执行状态看板.md
|
||||
```
|
||||
|
||||
### 带触发条件的监控
|
||||
|
||||
```
|
||||
TaskBoard Monitor:
|
||||
看板路径: docs/任务看板.md
|
||||
触发条件: 任何任务状态变为"✅已完成"
|
||||
动作: 输出变更报告
|
||||
```
|
||||
|
||||
### 完整配置
|
||||
|
||||
```
|
||||
TaskBoard Monitor:
|
||||
看板路径: docs/任务看板.md
|
||||
状态字段: 编号|任务|状态|Owner
|
||||
触发规则:
|
||||
- 当状态变为"✅已完成" → 记录完成时间,输出完成报告
|
||||
- 当状态变为"🟢可立即启动" → 检查依赖是否满足,输出激活建议
|
||||
- 当状态变为"🔴阻塞" → 记录阻塞原因
|
||||
输出: 变更报告 + 动作建议
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
系统会输出:
|
||||
1. **变更摘要**:本次检测到的所有变化
|
||||
2. **触发动作**:每个变化对应的建议动作
|
||||
3. **统计信息**:各状态任务数量
|
||||
|
||||
## 技术实现
|
||||
|
||||
- 读取 Markdown 看板文件
|
||||
- 使用正则表达式解析任务表格
|
||||
- 维护状态缓存(.taskboard-cache.json)
|
||||
- 支持自定义触发规则
|
||||
- 状态图标:✅已完成、🟢可立即启动、🔵进行中、⏳等待中、🔴阻塞、⚪未启动、🟡延期
|
||||
|
||||
## 复用方法
|
||||
|
||||
### 1. 复制 Skill 到其他项目
|
||||
|
||||
```bash
|
||||
# 复制整个目录
|
||||
cp -r .workbuddy/skills/taskboard-monitor /目标项目/.workbuddy/skills/
|
||||
```
|
||||
|
||||
### 2. 在新项目中使用
|
||||
|
||||
```bash
|
||||
python .workbuddy/skills/taskboard-monitor/taskboard_monitor.py "docs/你的任务看板.md"
|
||||
```
|
||||
|
||||
### 3. 自定义看板格式
|
||||
|
||||
看板需满足以下格式:
|
||||
- 包含表头:`| 编号 | 任务 | 状态 | Owner | ...`
|
||||
- 任务编号格式:`X-Tn`(如 A-T1, B-T2, C-T3)
|
||||
- 状态列包含图标:✅🟢🔵⏳🔴⚪🟡
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
看板变更监听系统 (TaskBoard Monitor)
|
||||
通用型任务看板变更检测工具
|
||||
|
||||
功能:
|
||||
1. 读取 Markdown 格式的任务看板
|
||||
2. 解析任务表格,提取任务状态
|
||||
3. 与上一次状态对比,检测变更
|
||||
4. 输出变更报告和建议动作
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
NOT_STARTED = "⚪" # 未启动
|
||||
READY = "🟢" # 可立即启动
|
||||
IN_PROGRESS = "🔵" # 进行中
|
||||
COMPLETED = "✅" # 已完成
|
||||
BLOCKED = "🔴" # 阻塞
|
||||
DELAYED = "🟡" # 延期
|
||||
WAITING = "⏳" # 等待中
|
||||
UNKNOWN = "❓" # 未知
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""单个任务"""
|
||||
id: str # 任务编号,如 "A-T1"
|
||||
name: str # 任务名称
|
||||
status: str # 状态图标
|
||||
owner: str # 负责人
|
||||
start_date: str # 开始日期
|
||||
end_date: str # 预计完成
|
||||
actual_end: str # 实际完成
|
||||
notes: str # 备注
|
||||
|
||||
@property
|
||||
def status_enum(self) -> TaskStatus:
|
||||
"""获取状态枚举"""
|
||||
for s in TaskStatus:
|
||||
if s.value in self.status:
|
||||
return s
|
||||
return TaskStatus.UNKNOWN
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskChange:
|
||||
"""任务变更记录"""
|
||||
task_id: str
|
||||
task_name: str
|
||||
old_status: str
|
||||
new_status: str
|
||||
change_type: str # "completed", "ready", "blocked", "waiting", "in_progress"
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorResult:
|
||||
"""监控结果"""
|
||||
board_path: str
|
||||
total_tasks: int
|
||||
changes: List[TaskChange]
|
||||
stats: Dict[str, int]
|
||||
actions: List[str]
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
|
||||
class TaskBoardMonitor:
|
||||
"""任务看板监听器"""
|
||||
|
||||
def __init__(self, board_path: str, cache_dir: str = ".taskboard-cache"):
|
||||
"""
|
||||
初始化监听器
|
||||
|
||||
Args:
|
||||
board_path: 看板文件路径(Markdown格式)
|
||||
cache_dir: 缓存目录,用于存储上一次的状态
|
||||
"""
|
||||
self.board_path = Path(board_path)
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.cache_file = self.cache_dir / f"{self.board_path.stem}_cache.json"
|
||||
|
||||
def read_board(self) -> str:
|
||||
"""读取看板文件内容"""
|
||||
if not self.board_path.exists():
|
||||
raise FileNotFoundError(f"看板文件不存在: {self.board_path}")
|
||||
return self.board_path.read_text(encoding="utf-8")
|
||||
|
||||
def parse_tasks(self, content: str) -> List[Task]:
|
||||
"""解析看板内容,提取所有任务列表"""
|
||||
tasks = []
|
||||
|
||||
# 按行扫描,直接处理
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# 检测任务表格区域的表头
|
||||
if '| 编号 | 任务 | 状态 |' in line:
|
||||
# 继续扫描后续行,直到下一个非任务表区域
|
||||
for j in range(i + 1, min(i + 30, len(lines))):
|
||||
row = lines[j]
|
||||
|
||||
# 跳过分隔行(如 |------|------|...|)
|
||||
if re.match(r'^\|[\s\-]+\|', row):
|
||||
continue
|
||||
|
||||
if '|' not in row:
|
||||
continue
|
||||
|
||||
parts = [p.strip() for p in row.split('|') if p.strip()]
|
||||
|
||||
# 遇到完整的下一个表头或里程碑,停止这个任务表
|
||||
if len(parts) >= 2:
|
||||
# 里程碑行
|
||||
if '里程碑' in parts[0]:
|
||||
break
|
||||
# 完整的表头行(包含"任务"和"状态")
|
||||
if '任务' in row and '状态' in row:
|
||||
break
|
||||
|
||||
# 匹配任务编号(如 A-T1, B-T1, C-T1)
|
||||
if len(parts) >= 2 and re.match(r'^[A-Z]-T\d+$', parts[0]):
|
||||
task = Task(
|
||||
id=parts[0],
|
||||
name=parts[1] if len(parts) > 1 else "",
|
||||
status=parts[2] if len(parts) > 2 else "",
|
||||
owner=parts[3] if len(parts) > 3 else "",
|
||||
start_date=parts[4] if len(parts) > 4 else "",
|
||||
end_date=parts[5] if len(parts) > 5 else "",
|
||||
actual_end=parts[6] if len(parts) > 6 else "",
|
||||
notes=parts[7] if len(parts) > 7 else ""
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
return tasks
|
||||
|
||||
return tasks
|
||||
|
||||
def load_cache(self) -> Dict[str, Any]:
|
||||
"""加载上一次的缓存状态"""
|
||||
if not self.cache_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_cache(self, tasks: List[Task], stats: Dict[str, int]):
|
||||
"""保存当前状态到缓存"""
|
||||
cache_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tasks": {
|
||||
t.id: {
|
||||
"status": t.status,
|
||||
"owner": t.owner,
|
||||
"actual_end": t.actual_end
|
||||
}
|
||||
for t in tasks
|
||||
},
|
||||
"stats": stats
|
||||
}
|
||||
self.cache_file.write_text(json.dumps(cache_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def detect_changes(self, tasks: List[Task], old_cache: Dict[str, Any]) -> List[TaskChange]:
|
||||
"""检测任务变更"""
|
||||
changes = []
|
||||
old_tasks = old_cache.get("tasks", {})
|
||||
|
||||
for task in tasks:
|
||||
old_task = old_tasks.get(task.id, {})
|
||||
|
||||
if not old_task:
|
||||
# 新任务
|
||||
continue
|
||||
|
||||
old_status = old_task.get("status", "")
|
||||
new_status = task.status
|
||||
|
||||
if old_status != new_status:
|
||||
# 状态发生变化
|
||||
change_type = self._get_change_type(old_status, new_status)
|
||||
change = TaskChange(
|
||||
task_id=task.id,
|
||||
task_name=task.name,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
change_type=change_type
|
||||
)
|
||||
changes.append(change)
|
||||
|
||||
return changes
|
||||
|
||||
def _get_change_type(self, old_status: str, new_status: str) -> str:
|
||||
"""判断变更类型"""
|
||||
if TaskStatus.COMPLETED.value in new_status:
|
||||
return "completed"
|
||||
elif TaskStatus.READY.value in new_status:
|
||||
return "ready"
|
||||
elif TaskStatus.BLOCKED.value in new_status:
|
||||
return "blocked"
|
||||
elif TaskStatus.WAITING.value in new_status:
|
||||
return "waiting"
|
||||
elif TaskStatus.IN_PROGRESS.value in new_status:
|
||||
return "in_progress"
|
||||
elif TaskStatus.DELAYED.value in new_status:
|
||||
return "delayed"
|
||||
return "changed"
|
||||
|
||||
def parse_dependencies(self, tasks: List[Task]) -> Dict[str, List[str]]:
|
||||
"""
|
||||
解析任务依赖关系
|
||||
|
||||
从任务的备注/阻塞字段中提取依赖任务编号
|
||||
支持格式:
|
||||
- "依赖A-T8"
|
||||
- "依赖A-T8, B-T1"
|
||||
- "A-T1~T6完成后"
|
||||
- "A-T8通过后"
|
||||
"""
|
||||
deps = {}
|
||||
|
||||
for task in tasks:
|
||||
task_deps = []
|
||||
|
||||
# 查找备注字段中的依赖
|
||||
notes = task.notes
|
||||
|
||||
# 模式1: 依赖X-Tn (如 "依赖A-T8")
|
||||
import re
|
||||
dep_pattern1 = r'依赖([A-Z]-T\d+)'
|
||||
task_deps.extend(re.findall(dep_pattern1, notes))
|
||||
|
||||
# 模式2: X-Tn~X-Tn完成后 (如 "A-T1~T6完成后")
|
||||
dep_pattern2 = r'([A-Z]-T\d+)~\1'
|
||||
matches = re.findall(dep_pattern2, notes)
|
||||
for match in matches:
|
||||
task_deps.append(match)
|
||||
|
||||
# 模式3: X-Tn通过后 / X-Tn完成后 (如 "A-T8通过后")
|
||||
dep_pattern3 = r'([A-Z]-T\d+)通过后|([A-Z]-T\d+)完成后'
|
||||
for match in re.finditer(dep_pattern3, notes):
|
||||
if match.group(1):
|
||||
task_deps.append(match.group(1))
|
||||
elif match.group(2):
|
||||
task_deps.append(match.group(2))
|
||||
|
||||
if task_deps:
|
||||
deps[task.id] = list(set(task_deps))
|
||||
|
||||
return deps
|
||||
|
||||
def check_and_activate_tasks(self, tasks: List[Task]) -> List[str]:
|
||||
"""
|
||||
检查依赖是否满足,自动激活等待中的任务
|
||||
|
||||
Returns:
|
||||
激活的任务列表
|
||||
"""
|
||||
# 解析依赖关系
|
||||
deps = self.parse_dependencies(tasks)
|
||||
|
||||
# 构建已完成任务集合
|
||||
completed = {t.id for t in tasks if TaskStatus.COMPLETED.value in t.status}
|
||||
|
||||
# 检查每个等待中的任务
|
||||
activated = []
|
||||
for task in tasks:
|
||||
if TaskStatus.WAITING.value not in task.status:
|
||||
continue
|
||||
|
||||
task_deps = deps.get(task.id, [])
|
||||
|
||||
# 如果没有依赖,或者所有依赖都已完成
|
||||
if not task_deps or all(dep in completed for dep in task_deps):
|
||||
activated.append(task.id)
|
||||
|
||||
return activated
|
||||
|
||||
def auto_start_ready_tasks(self, tasks: List[Task]) -> List[str]:
|
||||
"""
|
||||
自动开始可立即启动的任务(🟢 → 🔵)
|
||||
实现全自动流转:可立即启动的任务自动开始执行
|
||||
|
||||
Returns:
|
||||
自动开始的任务列表
|
||||
"""
|
||||
started = []
|
||||
for task in tasks:
|
||||
# 只处理"可立即启动"状态的任务
|
||||
if TaskStatus.READY.value in task.status:
|
||||
started.append(task.id)
|
||||
|
||||
if started:
|
||||
try:
|
||||
content = self.read_board()
|
||||
lines = content.split('\n')
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
for task_id in started:
|
||||
if f"| {task_id} |" in line and "🟢可立即启动" in line:
|
||||
line = line.replace("🟢可立即启动", "🔵进行中")
|
||||
print(f" 🚀 自动开始执行: {task_id}")
|
||||
new_lines.append(line)
|
||||
content = '\n'.join(new_lines)
|
||||
self.board_path.write_text(content, encoding='utf-8')
|
||||
except Exception as e:
|
||||
print(f" ❌ 自动开始任务失败: {e}")
|
||||
|
||||
return started
|
||||
|
||||
def auto_update_board(self, activated_tasks: List[str]) -> bool:
|
||||
"""
|
||||
自动更新看板,将激活的任务状态从等待中改为可立即启动
|
||||
|
||||
Args:
|
||||
activated_tasks: 要激活的任务ID列表
|
||||
|
||||
Returns:
|
||||
是否成功更新
|
||||
"""
|
||||
if not activated_tasks:
|
||||
return False
|
||||
|
||||
try:
|
||||
content = self.read_board()
|
||||
|
||||
for task_id in activated_tasks:
|
||||
# 替换等待中状态为可立即启动
|
||||
# 格式: | task_id | ... | ⏳等待中 | ... → | task_id | ... | 🟢可立即启动 | ...
|
||||
old_pattern = f"| {task_id} |"
|
||||
# 需要找到包含 task_id 和 ⏳等待中 的行
|
||||
lines = content.split('\n')
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if f"| {task_id} |" in line and "⏳等待中" in line:
|
||||
line = line.replace("⏳等待中", "🟢可立即启动")
|
||||
print(f" 🔄 自动激活: {task_id}")
|
||||
new_lines.append(line)
|
||||
content = '\n'.join(new_lines)
|
||||
|
||||
# 写回文件
|
||||
self.board_path.write_text(content, encoding='utf-8')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 自动更新失败: {e}")
|
||||
return False
|
||||
|
||||
def generate_actions(self, changes: List[TaskChange]) -> List[str]:
|
||||
"""根据变更生成建议动作"""
|
||||
actions = []
|
||||
|
||||
for change in changes:
|
||||
if change.change_type == "completed":
|
||||
actions.append(f"✅ {change.task_id} 已完成:{change.task_name}")
|
||||
actions.append(f" → 检查是否有依赖此任务的其他任务,准备激活")
|
||||
elif change.change_type == "ready":
|
||||
actions.append(f"🟢 {change.task_id} 可立即启动:{change.task_name}")
|
||||
actions.append(f" → 通知负责人开始执行")
|
||||
elif change.change_type == "blocked":
|
||||
actions.append(f"🔴 {change.task_id} 阻塞:{change.task_name}")
|
||||
actions.append(f" → 记录阻塞原因,通知项目经理协调")
|
||||
elif change.change_type == "waiting":
|
||||
actions.append(f"⏳ {change.task_id} 变为等待中:{change.task_name}")
|
||||
actions.append(f" → 等待依赖任务完成后激活")
|
||||
|
||||
return actions
|
||||
|
||||
def calculate_stats(self, tasks: List[Task]) -> Dict[str, int]:
|
||||
"""统计各状态任务数量"""
|
||||
stats = {
|
||||
"total": len(tasks),
|
||||
"not_started": 0,
|
||||
"ready": 0,
|
||||
"in_progress": 0,
|
||||
"completed": 0,
|
||||
"blocked": 0,
|
||||
"delayed": 0,
|
||||
"waiting": 0
|
||||
}
|
||||
|
||||
for task in tasks:
|
||||
status = task.status_enum
|
||||
if status == TaskStatus.NOT_STARTED:
|
||||
stats["not_started"] += 1
|
||||
elif status == TaskStatus.READY:
|
||||
stats["ready"] += 1
|
||||
elif status == TaskStatus.IN_PROGRESS:
|
||||
stats["in_progress"] += 1
|
||||
elif status == TaskStatus.COMPLETED:
|
||||
stats["completed"] += 1
|
||||
elif status == TaskStatus.BLOCKED:
|
||||
stats["blocked"] += 1
|
||||
elif status == TaskStatus.DELAYED:
|
||||
stats["delayed"] += 1
|
||||
elif status == TaskStatus.WAITING:
|
||||
stats["waiting"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def monitor(self, verbose: bool = True, auto_activate: bool = True) -> MonitorResult:
|
||||
"""
|
||||
执行一次监控检查
|
||||
|
||||
Args:
|
||||
verbose: 是否输出详细信息
|
||||
auto_activate: 是否自动激活依赖满足的任务
|
||||
|
||||
Returns:
|
||||
监控结果
|
||||
"""
|
||||
# 读取看板
|
||||
content = self.read_board()
|
||||
tasks = self.parse_tasks(content)
|
||||
|
||||
# 加载缓存
|
||||
old_cache = self.load_cache()
|
||||
|
||||
# 检测变更
|
||||
changes = self.detect_changes(tasks, old_cache)
|
||||
|
||||
# 检查并自动激活任务
|
||||
activated_tasks = []
|
||||
started_tasks = []
|
||||
if auto_activate:
|
||||
activated_tasks = self.check_and_activate_tasks(tasks)
|
||||
if activated_tasks:
|
||||
# 自动更新看板
|
||||
self.auto_update_board(activated_tasks)
|
||||
# 重新读取看板获取最新状态
|
||||
content = self.read_board()
|
||||
tasks = self.parse_tasks(content)
|
||||
|
||||
# 自动开始可立即执行的任务(🟢 → 🔵)
|
||||
started_tasks = self.auto_start_ready_tasks(tasks)
|
||||
if started_tasks:
|
||||
# 重新读取看板
|
||||
content = self.read_board()
|
||||
tasks = self.parse_tasks(content)
|
||||
|
||||
# 统计
|
||||
stats = self.calculate_stats(tasks)
|
||||
|
||||
# 生成动作建议
|
||||
actions = self.generate_actions(changes)
|
||||
|
||||
# 添加激活信息
|
||||
if activated_tasks:
|
||||
actions.append(f"\n🎉 自动激活任务: {', '.join(activated_tasks)}")
|
||||
if started_tasks:
|
||||
actions.append(f"\n🚀 自动开始执行: {', '.join(started_tasks)}")
|
||||
|
||||
# 保存缓存
|
||||
self.save_cache(tasks, stats)
|
||||
|
||||
# 输出结果
|
||||
result = MonitorResult(
|
||||
board_path=str(self.board_path),
|
||||
total_tasks=len(tasks),
|
||||
changes=changes,
|
||||
stats=stats,
|
||||
actions=actions
|
||||
)
|
||||
|
||||
if verbose:
|
||||
self._print_result(result, old_cache)
|
||||
|
||||
return result
|
||||
|
||||
def _print_result(self, result: MonitorResult, old_cache: Dict[str, Any]):
|
||||
"""打印监控结果"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"📋 看板变更监听报告")
|
||||
print(f"{'='*60}")
|
||||
print(f"📂 看板: {result.board_path}")
|
||||
print(f"⏰ 时间: {result.timestamp}")
|
||||
print(f"📊 总任务数: {result.total_tasks}")
|
||||
|
||||
# 统计
|
||||
stats = result.stats
|
||||
print(f"\n📈 状态统计:")
|
||||
print(f" ✅ 已完成: {stats['completed']}")
|
||||
print(f" 🟢 可立即启动: {stats['ready']}")
|
||||
print(f" 🔵 进行中: {stats['in_progress']}")
|
||||
print(f" ⏳ 等待中: {stats['waiting']}")
|
||||
print(f" 🔴 阻塞: {stats['blocked']}")
|
||||
print(f" ⚪ 未启动: {stats['not_started']}")
|
||||
|
||||
# 变更
|
||||
if result.changes:
|
||||
print(f"\n🔄 检测到 {len(result.changes)} 项变更:")
|
||||
for change in result.changes:
|
||||
print(f" • {change.task_id}: {change.old_status} → {change.new_status}")
|
||||
print(f" 任务: {change.task_name}")
|
||||
print(f" 类型: {change.change_type}")
|
||||
else:
|
||||
print(f"\n✅ 无变更检测到")
|
||||
|
||||
# 动作建议
|
||||
if result.actions:
|
||||
print(f"\n🎯 建议动作:")
|
||||
for action in result.actions:
|
||||
print(f" {action}")
|
||||
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数 - 演示用法"""
|
||||
import sys
|
||||
|
||||
# 默认看板路径
|
||||
board_path = sys.argv[1] if len(sys.argv) > 1 else "docs/小组任务书/任务执行状态看板.md"
|
||||
|
||||
# 创建监控器并执行
|
||||
monitor = TaskBoardMonitor(board_path)
|
||||
result = monitor.monitor()
|
||||
|
||||
# 返回码表示是否有变更
|
||||
return 0 if len(result.changes) > 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# 变更日志 (Changelog)
|
||||
|
||||
本项目的所有重要变更都会记录在此文件。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [未发布] - 2026-07-05
|
||||
|
||||
### 🐛 缺陷修复 (Bug Fixes)
|
||||
|
||||
### 🚀 功能增强 (Features)
|
||||
- 蓝绿部署支持:新增 docker-compose-green.yml、switch-blue-green.sh、nginx-green-upstream.conf
|
||||
- Green 环境端口:后端 5002,Nginx 5080/5443
|
||||
|
||||
### 🐛 缺陷修复 (Bug Fixes)
|
||||
- 修复:nginx 容器配置丢失导致页面加载失败
|
||||
- 修复:后端 h5.py `_require_wework_ua` NameError 导致 OAuth 认证失败
|
||||
|
||||
### 🔐 安全 (Security)
|
||||
- P0:WS token 改走 `Sec-WebSocket-Protocol` subprotocol(已修)
|
||||
- P0:坐席登录加 `password_hash` bcrypt 字段
|
||||
- P0:`/ws/` 路径 nginx access_log 关闭
|
||||
- P0:5 鉴权漏洞全部修复(消息 5 端点)
|
||||
- WECOM_SECRET 集中化(待 NAS Vault)
|
||||
- Gitea 凭据走 wincred,不入文件
|
||||
|
||||
### 🏗️ 基础设施 (Infrastructure)
|
||||
- Gitea 自托管部署(Synology 套件 8418 端口)
|
||||
- Tailscale Funnel 暴露给 workbuddy 沙箱
|
||||
- 分支保护:main 需 PR + 1 reviewer
|
||||
- workbuddy-claude 配 access token + 自动跑批
|
||||
- 备份脚本(7 天保留 + cron 3 点)
|
||||
|
||||
### 📚 文档 (Documentation)
|
||||
- 新增 8 份审计/设计报告(Dockerfile / ER / 依赖 / 健康检查 / CORS / 一键部署 / 健康度 / 惊喜汇总)
|
||||
- 4 份 ADR(ADRs 001-004)
|
||||
- 4 份 SOP(SOPs 001-004)
|
||||
- 2 份路线图(阶段 1 盘点 + 阶段 4-5 规划)
|
||||
- Wingman 设计文档
|
||||
- 4 前端审计 + 16 项统一优化路线
|
||||
|
||||
### 🛠️ 工具链 (Tooling)
|
||||
- `scripts/pre-commit-check.sh`:4 件套预检(鉴权+依赖+alembic+配置)
|
||||
- `scripts/backup-gitea.sh`:Gitea 备份 + 恢复
|
||||
- `scripts/security-audit.sh`:5 工具集成审计
|
||||
- `scripts/generate-api-docs.sh`:OpenAPI + Swagger UI + ReDoc
|
||||
- `scripts/dashboard.py`:项目健康度仪表盘
|
||||
- `scripts/oneclick-deploy.sh`:一键部署
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] - 2026-05-30
|
||||
|
||||
### ✨ 新增 (Added)
|
||||
- 阶段 1 完成度 66%(47 项功能盘点)
|
||||
- H5 员工端完整功能(11 组件)
|
||||
- 坐席工作台三栏(23 组件)
|
||||
- 管理后台 13+ 视图
|
||||
- 统一入口 portal
|
||||
- WebSocket 实时通信
|
||||
- WebSocket fallback 轮询
|
||||
- Dify AI 集成(基础)
|
||||
- 4 个外部系统集成(火绒/联软/aTrust/eHR)
|
||||
- 快速回复 + 排障模板 + 待办事项
|
||||
|
||||
### 🐛 修复 (Fixed)
|
||||
- 5 鉴权漏洞
|
||||
- WS token 泄露到 URL 和日志
|
||||
- 坐席登录缺 password
|
||||
- Mock login bypass
|
||||
|
||||
### 📈 性能 (Performance)
|
||||
- 4 前端路由级代码分割
|
||||
- WebSocket 长连接(替代轮询)
|
||||
- 模板缓存(Redis)
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] - 2026-04-15
|
||||
|
||||
### ✨ 新增
|
||||
- RBAC 角色管理(user/agent/admin)
|
||||
- 角色自动映射(企微标签 + eHR 字段)
|
||||
- 配置变更日志(审计)
|
||||
- 趣味话术(摇人/等待/接入)
|
||||
- 审批流程链接
|
||||
- 软件下载入口
|
||||
|
||||
### 🐛 修复
|
||||
- 部门权限粒度
|
||||
- 紧急度评分算法
|
||||
- VIP 标记自动匹配
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-03-01
|
||||
|
||||
### ✨ 新增
|
||||
- AI 草稿回复(坐席采纳)
|
||||
- AI 实质性回复计数
|
||||
- 紧急度评分(1-5)
|
||||
- 标签系统(举手/情绪/需介入)
|
||||
- 影响范围评估
|
||||
- 阻断性标记
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-01-15
|
||||
|
||||
### ✨ 新增
|
||||
- 4 前端基础架构(Vue 3 + Vite + TS + Pinia)
|
||||
- 16 张数据表
|
||||
- 核心 API(40+ 端点)
|
||||
- OAuth2 企微登录
|
||||
- 消息收发(文本/图片/文件/语音)
|
||||
- 会话分配/抢单/转接
|
||||
- 协作坐席(摇人)
|
||||
- 邀请功能(P0-09~11)
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2025-12-01
|
||||
|
||||
### ✨ 初始版本
|
||||
- 项目初始化
|
||||
- 基础 FastAPI 框架
|
||||
- SQLAlchemy 2.0 + async
|
||||
- Alembic 迁移
|
||||
- Docker Compose 编排
|
||||
- 4 前端工程搭建
|
||||
- 企微回调基础
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
- **0.x.y** - 阶段 1-5 演进(0.1-0.5 已发布,0.6+ 阶段 2 启动)
|
||||
- **1.0.0** - 正式版目标(预计 2026-12,阶段 5 完成后)
|
||||
|
||||
> 📌 **文档同步说明**:各版本的详细变更记录请参考 `docs/archive/RELEASE_NOTES_*.md`,本文档仅保留版本概览。
|
||||
|
||||
## 图例
|
||||
|
||||
- ✨ 新增 - 新功能
|
||||
- 🐛 修复 - Bug 修复
|
||||
- 📈 性能 - 性能优化
|
||||
- 🔐 安全 - 安全修复
|
||||
- ⚠️ 弃用 - 即将移除
|
||||
- 🏗️ 基础设施 - 部署/工具/流程
|
||||
- 📚 文档 - 文档更新
|
||||
- 🛠️ 工具链 - 工具脚本
|
||||
|
||||
[未发布]: https://gitea.simon.local/simon/wecom/wecom_it_smart_desk/compare/v0.7.0...HEAD
|
||||
|
||||
## [v0.7.1] - 2026-06-23(规划中)
|
||||
|
||||
> **决策背景**(2026-06-22):v0.7.0.1-hotfix1(QR 码生成)上线后,生产仍报 2 个 bug:
|
||||
> - 员工/坐席扫码登录报错(`/api/auth_qrcode/scan` 失败)
|
||||
> - 管理员 sxn 登录报错(`agents.otp_secret` 列不存在 — alembic 010 未跑)
|
||||
> 用户决策:**不再修 7.0.1**,直接进 v0.7.1 统一治理。
|
||||
|
||||
### 🔧 修复 (Fixed)
|
||||
|
||||
#### P0 — 登录失败
|
||||
- **管理员 sxn 登录报错**:根因 — alembic 010 `agents.otp_secret` 列未在生产数据库创建
|
||||
- 修复:合并 `otp_secret/otp_enabled`(010)与 `mfa_secret/mfa_enabled`(023)双字段,模型统一引用 `mfa_secret/mfa_enabled`
|
||||
- migration:重写 021_rbac(原文件丢失),统一 010-025 chain
|
||||
- **员工/坐席扫码登录报错**:根因待查(预计 ticket 状态机 / WecomService 初始化 / 高并发 session)
|
||||
- 修复:在 dev 复现,出 patch
|
||||
|
||||
#### P0 — 基础设施
|
||||
- **修 `/api/ready` import error**(原 defer to v0.7.1)
|
||||
- **审计 alembic chain**:`021_rbac` 缺失 / 022-025 chain 错乱,出 `docs/alembic_history_audit.md`
|
||||
|
||||
### 🆕 新增 (Added)
|
||||
|
||||
#### P1 — 体验优化
|
||||
- **企微入口 SSO**(原 v0.7.1+ backlog):识别 WeChat Work User-Agent,自动识别员工身份 + 跳对应端点,扫码登录降级为 fallback
|
||||
|
||||
#### P1 — 权限
|
||||
- **管理后台 RBAC 细粒度角色权限**:5 角色 + 4 资源 + 4 操作 + 3 数据范围
|
||||
|
||||
### 📝 文档 (Documentation)
|
||||
- `docs/DEPLOY-QUICK-v0.7.1.md` — 一键部署操作包(基于 7.0 模板)
|
||||
- `docs/alembic_history_audit.md` — chain 审计报告
|
||||
- `docs/USER-GUIDE-WECOM-SSO.md` — 企微 SSO 用户手册
|
||||
|
||||
---
|
||||
|
||||
## [v0.7.0] - 2026-06-21
|
||||
|
||||
### 🎉 新增 (Added)
|
||||
|
||||
#### 扫码登录(阶段 1.1-1.3)
|
||||
- 后端 `app/api/auth_qrcode.py` (236 行) — 4 端点 create / poll / scan / confirm
|
||||
- 后端 `app/services/qrcode_service.py` (487 行) — 业务逻辑 + dev 模式 mock OAuth
|
||||
- 后端 `app/schemas/qrcode.py` (127 行) — Pydantic 模型
|
||||
- 后端 alembic migration 022_qrcode_login(数据存 Redis,无 schema 变更)
|
||||
- 前端 `frontend-agent/src/views/Login.vue` — ElementPlus 扫码 UI + 倒计时
|
||||
- 前端 `frontend-portal/src/views/QrcodeLogin.vue` — 角色自动分发
|
||||
- 前端 `useQrcodeLogin.ts` composable (agent + portal 双端) — 2s 轮询 + 120s TTL
|
||||
- 前端 `frontend-portal/src/router/index.ts` — 默认 `/` 跳 `/qrcode-login`
|
||||
- 文档 `docs/NGINX-DOMAIN-ROUTING.md` — 单域名 + 多路径架构
|
||||
- 文档 `docs/USER-GUIDE-QRCODE-MFA.md` — 员工/坐席/管理员用户手册
|
||||
|
||||
#### MFA 二次认证(阶段 2.1-2.4)
|
||||
- 后端 `app/api/mfa.py` (389 行) — 6 端点:status / bind/start / bind/confirm / verify / disable / admin/reset
|
||||
- 后端 `app/services/mfa_service.py` (179 行) — pyotp TOTP + Redis verified TTL 1800s
|
||||
- 后端 `app/models/agent.py` — mfa_secret / mfa_enabled / mfa_bound_at / mfa_last_verified_at
|
||||
- 后端 alembic migration 023_mfa_fields — User MFA 4 列
|
||||
- 前端 `frontend-agent/src/api/mfa.ts` — 5 个用户端 API
|
||||
- 前端 `frontend-agent/src/views/MfaBind.vue` — 4 步绑定流程
|
||||
- 前端 `frontend-agent/src/composables/useHighRiskOtp.ts` — 高危弹窗 30 分钟超时
|
||||
- 前端 `frontend-admin/src/api/mfa.ts` — 管理员视角 API
|
||||
- 前端 `frontend-admin/src/views/MfaManage.vue` — MFA 管理表格(搜索/过滤/分页)
|
||||
|
||||
#### 高危操作守卫(阶段 1.3 task #19)
|
||||
- 后端 `app/services/high_risk_guard.py` (291 行) — HighRiskGuard service 类
|
||||
- 后端 `app/api/high_risk_routes.py` (327 行) — 演示端点 + 白名单查询
|
||||
- 后端 `app/dependencies.py` — HIGH_RISK_OPERATIONS 5 类白名单 + require_high_risk_otp 依赖
|
||||
- 5 类高危操作:改权限 / 改配置 / 导出数据 / 封号 / 新增账号或重置
|
||||
|
||||
### 🐛 修复 (Fixed)
|
||||
- WS endpoint `missing argument 'request'` 错误(加 8 个回归测试)
|
||||
- messages.id VARCHAR → UUID(migration 025,加 8 个兼容测试)
|
||||
- wordfilter API 适配(1.0.6:Wordfilter 实例 + addWords + blacklisted)
|
||||
- conftest SQLite ARRAY/JSONB 编译补丁(quiz.keywords / themes.palette)
|
||||
- conftest autouse 业务表清理(feedback 事务隔离)
|
||||
- h5_client 用 127.0.0.1 跳过企微 UA 检测
|
||||
- test_conversation_grab wecom mock 默认 name 不覆盖 body.name
|
||||
- Gitea push token 从 URL 清理(`http://workbuddy-claude@...`)
|
||||
|
||||
### 🔐 安全 (Security)
|
||||
- 高危操作必须过 OTP 二次验证(管理员 30 分钟内)
|
||||
- WS 推送端点签名保护(防 request: Request 加回去)
|
||||
- nginx access_log 脱敏脚本(删 Authorization / Cookie)
|
||||
- 5 鉴权漏洞已修(2026-06-14 评审清单)
|
||||
|
||||
### 📚 文档 (Documentation)
|
||||
- `docs/E2E-CHECKLIST-v0.7.0.md` (176 行) — 35 项 E2E 验收清单
|
||||
- `docs/DEPLOY-QUICK-v0.7.0.md` (252 行) — 一键部署操作包(分步+回滚+预计时间)
|
||||
- `docs/DEPLOY-LOGIN-MIGRATION-v0.7.0.md` (220 行) — 部署手册
|
||||
- `docs/NGINX-DOMAIN-ROUTING.md` (256 行) — nginx 域名分发
|
||||
- `docs/USER-GUIDE-QRCODE-MFA.md` (165 行) — 用户手册
|
||||
|
||||
### 📈 测试 (Test)
|
||||
- 新增 78 测试全过(扫码 13 + MFA 21 + 高危 28 + WS/UUID 16)
|
||||
- 4 xfailed(端点路径不一致 pre-existing,已标 xfail)
|
||||
- 修 5 处 pre-existing 失败(+27 测试):content_moderation / conversation_grab / feedback / h5_oauth / SQLite 编译
|
||||
- 全量 pytest: 470 passed, 4 xfailed, 64 failed(pre-existing 设计问题)
|
||||
|
||||
### 📦 Commits(本次 session 5 个)
|
||||
- `1255e95` docs: v0.7.0 一键部署操作包
|
||||
- `c33abb6` fix(tests): h5_client 用 127.0.0.1 跳过企微 UA 检测
|
||||
- `a9b97de` fix(tests): wordfilter API 适配 + SQLite ARRAY/JSONB 补丁 + 事务隔离
|
||||
- `e96fbb2` docs: v0.7.0 E2E 验收清单
|
||||
- `bf872da` feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)
|
||||
|
||||
[0.7.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/compare/v0.6.0...v0.7.0
|
||||
[0.5.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.5.0
|
||||
[0.4.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.4.0
|
||||
[0.3.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.3.0
|
||||
[0.2.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.2.0
|
||||
[0.1.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.1.0
|
||||
+24
-4
@@ -186,8 +186,28 @@ workbuddy 自动化开发,推送必须满足:
|
||||
|
||||
## 📚 关联文档
|
||||
|
||||
- [`README.md`](README.md) — 项目总览
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — 架构设计
|
||||
- [`docs/风险跟踪表.md`](docs/风险跟踪表.md) — 22 项审计追踪
|
||||
- [`docs/评审报告/`](docs/评审报告/) — workbuddy 推送评审
|
||||
- [`README.md`](README.md) — 项目总览(新人快速入门)
|
||||
- [`docs/01-项目总览与部署手册.md`](docs/01-项目总览与部署手册.md) — 完整架构设计与部署详情
|
||||
- [`docs/智能IT服务系统运维手册.md`](docs/智能IT服务系统运维手册.md) — 统一运维手册
|
||||
- [`docs/索引.md`](docs/索引.md) — 文档目录索引(快速导航)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) — 版本变更概览
|
||||
- [`docs/archive-归档/`](docs/archive-归档/) — 历史版本详情
|
||||
- [`.workbuddy/memory/`](.workbuddy/memory/) — workbuddy 任务记忆
|
||||
|
||||
---
|
||||
|
||||
## 📑 文档同步规则
|
||||
|
||||
**更新文档时需同步关联文档**:
|
||||
|
||||
| 更新内容 | 需同步的文档 |
|
||||
|---------|-------------|
|
||||
| 新功能/重构 | README.md(进度)+ CHANGELOG.md + 相关 docs/*.md |
|
||||
| 部署变更 | 智能IT服务系统运维手册.md + 01-项目总览与部署手册.md |
|
||||
| 安全修复 | CHANGELOG.md(Security 章节)+ 评审报告 |
|
||||
| API 变更 | README.md(API 概览)+ 01-项目总览与部署手册.md |
|
||||
|
||||
**文档目录规范**:
|
||||
- `docs/` 目录下按类型分子目录(deploy/, SOPs/, ADRs/, 评审报告/, archive/ 等)
|
||||
- 根目录保留 README.md / CONTRIBUTING.md / CHANGELOG.md(Git 生态标准)
|
||||
- 新增文档优先放在 `docs/`,避免根目录文件膨胀
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 企微 IT 智能服务台 (IT Smart Desk)
|
||||
# 企微智能IT支持服务台 (IT Smart Desk)
|
||||
|
||||
> **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s
|
||||
> **维护者**: 税友集团 IT支持组(宋献)
|
||||
@@ -175,9 +175,10 @@ wecom_it_smart_desk/
|
||||
|
||||
## 📝 相关文档
|
||||
|
||||
- **docs/ARCHITECTURE.md**:完整架构设计、数据模型、调用流程、任务清单
|
||||
- **docs/现有系统交接文档内容.txt**:现有 IT 客服机器人系统交接信息(RAGFLOW、Dify 部署环境)
|
||||
- **docs/01-项目总览与部署手册.md**:完整项目背景、架构设计、部署运维(本文档的详细版本)
|
||||
- **docs/智能IT服务系统运维手册.md**:统一运维文档,涵盖部署/监控/故障处理
|
||||
- **scripts/deploy.sh**:部署脚本详细说明(5 种运行模式)
|
||||
- **docs/archive/**:历史版本文档归档
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
# =============================================================================
|
||||
# 排除构建时不需要的文件
|
||||
# 2026-06-22 创建(防 v0.7.0-alpha 的 .env 覆盖 bug 重演)
|
||||
# =============================================================================
|
||||
|
||||
# 环境变量(防开发 .env 进生产镜像)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
*.env
|
||||
|
||||
# Python 缓存
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# 测试产物
|
||||
pytest.ini
|
||||
pytest-d1.log
|
||||
pytest-d2.log
|
||||
pytest-d3.log
|
||||
pytest-sms2fa.log
|
||||
pytest_result.txt
|
||||
run_tests.bat
|
||||
run_tests.ps1
|
||||
|
||||
# 本地数据库 / 临时文件
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
hello.py
|
||||
check_all_tables.py
|
||||
check_db.py
|
||||
migrate_employee_v53.py
|
||||
migrate_v53.py
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node / 文档
|
||||
node_modules/
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Base64 凭据(防 token 泄漏)
|
||||
*.b64
|
||||
+4
-4
@@ -19,13 +19,13 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖声明文件并安装(利用 Docker 层缓存,依赖不变则不重新安装)
|
||||
# 使用清华大学 PyPI 镜像源,解决公司内网下载 PyPI 官方源超时问题
|
||||
# 使用阿里云 PyPI 镜像(比清华镜像更快)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir \
|
||||
--timeout 120 \
|
||||
--timeout 180 \
|
||||
--retries 5 \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ \
|
||||
--trusted-host mirrors.aliyun.com \
|
||||
-r requirements.txt
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 后端 开发镜像 Dockerfile
|
||||
# =============================================================================
|
||||
# 与 Dockerfile(prod) 区别:
|
||||
# - 不需要 gcc / libpq-dev(用预编译的 psycopg2-binary)
|
||||
# - 装 pytest 用于跑测试
|
||||
# - 不需要 multi-stage build(开发用,镜像大一点无所谓)
|
||||
# - 装 watchfiles 配合 uvicorn --reload
|
||||
# =============================================================================
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="IT服务台开发团队"
|
||||
LABEL description="企微IT智能服务台后端 - 开发模式"
|
||||
|
||||
# 换 apt 源(公司内网,默认 deb.debian.org 可能不通)
|
||||
RUN sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
|
||||
sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装运行时依赖(精简版)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libpq5 curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 换 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 && \
|
||||
pip install --no-cache-dir \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||
pytest pytest-asyncio httpx watchfiles
|
||||
|
||||
# 复制项目代码(在 dev 模式下用 volume mount 覆盖)
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 默认命令(在 docker-compose.dev.yml 里覆盖)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -0,0 +1 @@
|
||||
# Backend package
|
||||
+13
-5
@@ -1,4 +1,4 @@
|
||||
"""admin extension — 管理后台数据库扩展迁移
|
||||
"""admin ext — 管理后台数据库扩展迁移
|
||||
|
||||
新增 config_change_logs 表(配置变更日志)。
|
||||
扩展 agents 表:新增 role(角色)和 skill_tags(技能标签)字段。
|
||||
@@ -8,16 +8,23 @@ submitted_by(提交人)字段。
|
||||
Revision ID: 006_admin_ext
|
||||
Revises: 005_reply_to_id
|
||||
Create Date: 2026-07-15 10:00:00.000000
|
||||
|
||||
注:filename 与 revision 字符串一致(v0.5.1 修复)
|
||||
原 filename `006_admin_extension.py` 改名为 `006_admin_ext.py`,
|
||||
revision 字符串保持 `006_admin_ext` 不变(DB alembic_version 表已存此值,
|
||||
改 revision 会破坏 chain)。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
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
|
||||
revision: str = '006_admin_ext'
|
||||
down_revision: Union[str, None] = '005_reply_to_id'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
@@ -113,4 +120,5 @@ def downgrade() -> None:
|
||||
# 删除 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.table('config_change_logs')
|
||||
op.drop_table('config_change_logs')
|
||||
@@ -5,7 +5,7 @@
|
||||
新增 role_mapping_rules 表(角色映射规则)。
|
||||
预置三个基础角色:user、agent、admin。
|
||||
|
||||
Revision ID: 007_role_sys
|
||||
Revision ID: 007_role_system
|
||||
Revises: 006_admin_ext
|
||||
Create Date: 2026-06-12 23:00:00.000000
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '007_role_sys'
|
||||
revision = '007_role_system'
|
||||
down_revision = '006_admin_ext'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add agent OTP fields
|
||||
|
||||
Revision ID: 010_add_agent_otp
|
||||
Revises: 009_add_message_status
|
||||
Create Date: 2026-06-16
|
||||
|
||||
v0.5.6: 添加坐席 OTP 二次验证字段
|
||||
- 新增 otp_secret 字段(存储 TOTP secret,绑定时生成)
|
||||
- 新增 otp_enabled 字段(是否启用 OTP 二次验证)
|
||||
- 都是 nullable=True,默认 False,不破坏现有坐席
|
||||
|
||||
为什么需要这个 migration:
|
||||
Agent 模型里加了 otp_secret 和 otp_enabled 字段,
|
||||
但没有对应的 alembic migration 把它落到 DB schema 里。
|
||||
查询时报 UndefinedColumnError:
|
||||
column agents.otp_secret does not exist
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '010_add_agent_otp'
|
||||
down_revision = '009_add_message_status'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 otp_secret + otp_enabled 字段"""
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'otp_secret',
|
||||
sa.String(64),
|
||||
nullable=True,
|
||||
comment='TOTP 密钥(base32,绑定时生成)'
|
||||
)
|
||||
)
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'otp_enabled',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='是否启用 OTP 二次验证'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 OTP 字段"""
|
||||
op.drop_column('agents', 'otp_enabled')
|
||||
op.drop_column('agents', 'otp_secret')
|
||||
@@ -0,0 +1,69 @@
|
||||
"""add conversation impact fields
|
||||
|
||||
Revision ID: 011_add_conversation_impact
|
||||
Revises: 010_add_agent_otp
|
||||
Create Date: 2026-06-16
|
||||
|
||||
v0.5.6: 补齐 Conversation 模型的 3 个评估字段
|
||||
- impact_scope (int, default 0): 影响范围(受影响人数)
|
||||
- is_blocking (bool, default False): 是否阻断员工工作
|
||||
- emotion_state (str(20), default 'normal'): 情绪状态
|
||||
|
||||
为什么需要这个 migration:
|
||||
Conversation 模型里加了 impact_scope/is_blocking/emotion_state,
|
||||
但缺 alembic migration 落库。坐席发消息时 SQLAlchemy 查
|
||||
conversations.* 全字段,报:
|
||||
column conversations.impact_scope does not exist
|
||||
|
||||
跟 010_add_agent_otp 是同一类问题(模型新字段无 migration)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '011_add_conversation_impact'
|
||||
down_revision = '010_add_agent_otp'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 impact_scope + is_blocking + emotion_state 字段"""
|
||||
op.add_column(
|
||||
'conversations',
|
||||
sa.Column(
|
||||
'impact_scope',
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text('0'),
|
||||
comment='影响范围(受影响人数,0=未评估)'
|
||||
)
|
||||
)
|
||||
op.add_column(
|
||||
'conversations',
|
||||
sa.Column(
|
||||
'is_blocking',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='是否阻断员工工作'
|
||||
)
|
||||
)
|
||||
op.add_column(
|
||||
'conversations',
|
||||
sa.Column(
|
||||
'emotion_state',
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'normal'"),
|
||||
comment='情绪状态(normal/worried/angry/urgent)'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 3 个评估字段"""
|
||||
op.drop_column('conversations', 'emotion_state')
|
||||
op.drop_column('conversations', 'is_blocking')
|
||||
op.drop_column('conversations', 'impact_scope')
|
||||
@@ -0,0 +1,87 @@
|
||||
"""sync remaining model fields
|
||||
|
||||
Revision ID: 012_sync_remaining_fields
|
||||
Revises: 011_add_conversation_impact
|
||||
Create Date: 2026-06-16
|
||||
|
||||
v0.5.6: 补齐 dev-check-schema-drift 找到的 4 个漂移字段
|
||||
- conversations.dify_conversation_id (VARCHAR(128), nullable)
|
||||
- employees.it_level (VARCHAR(20), default 'silver')
|
||||
- employees.it_level_source (VARCHAR(20), default 'system')
|
||||
- employees.notes (JSON, default '{}')
|
||||
|
||||
为什么需要这个 migration:
|
||||
之前手动 011 只补了 NOT NULL 那些(坐席发消息会 500 的),
|
||||
但 dev-check-schema-drift.ps1 又发现 4 个字段也没建 migration。
|
||||
之前是 nullable 没立即暴露,运行 SELECT * FROM conversations 时
|
||||
PostgreSQL 会按顺序填,nullable 列缺不会立刻 500,但 INSERT/UPDATE
|
||||
涉及这些字段时会出错,或者 Alembic autogenerate 会持续报告漂移。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '012_sync_remaining_fields'
|
||||
down_revision = '011_add_conversation_impact'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""加 4 个漂移字段"""
|
||||
|
||||
# 1) conversations.dify_conversation_id - Dify 多轮对话上下文
|
||||
op.add_column(
|
||||
'conversations',
|
||||
sa.Column(
|
||||
'dify_conversation_id',
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment='Dify会话ID(多轮对话上下文)'
|
||||
)
|
||||
)
|
||||
|
||||
# 2) employees.it_level - IT 技能等级
|
||||
op.add_column(
|
||||
'employees',
|
||||
sa.Column(
|
||||
'it_level',
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'silver'"),
|
||||
comment='IT技能等级(bronze/silver/gold/platinum/diamond/star/king)'
|
||||
)
|
||||
)
|
||||
|
||||
# 3) employees.it_level_source - 等级来源
|
||||
op.add_column(
|
||||
'employees',
|
||||
sa.Column(
|
||||
'it_level_source',
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'system'"),
|
||||
comment='等级来源(system/manual/assessment)'
|
||||
)
|
||||
)
|
||||
|
||||
# 4) employees.notes - 坐席备注 JSON
|
||||
op.add_column(
|
||||
'employees',
|
||||
sa.Column(
|
||||
'notes',
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
comment='坐席备注(JSON 格式)'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 4 个字段"""
|
||||
op.drop_column('employees', 'notes')
|
||||
op.drop_column('employees', 'it_level_source')
|
||||
op.drop_column('employees', 'it_level')
|
||||
op.drop_column('conversations', 'dify_conversation_id')
|
||||
@@ -0,0 +1,120 @@
|
||||
"""RBAC 角色权限基础表
|
||||
|
||||
Revision ID: 021_rbac
|
||||
Revises: 012_sync_remaining_fields
|
||||
Create Date: 2026-06-22 (v0.7.1 重建)
|
||||
|
||||
v0.7.1 重建原因: 022_qrcode_login 的 down_revision 指向 021_rbac 但原文件丢失
|
||||
本 migration 重建 RBAC 三张表 + 预置 3 角色 + 索引:
|
||||
- roles 角色定义
|
||||
- user_roles 用户-角色多对多
|
||||
- role_mapping_rules 自动映射规则(企微标签 / eHR 字段)
|
||||
|
||||
使用 IF NOT EXISTS 兼容"生产数据库已建表"的情况:
|
||||
- 如果生产 alembic 已 stamp 022 跳过 021(且表已存在),则 upgrade 是 noop
|
||||
- 如果生产跑过 021 但文件丢了,upgrade 是 noop
|
||||
- 只有全新环境才真正建表
|
||||
|
||||
下游:
|
||||
- 022_qrcode_login / 023_mfa_fields / 025_messages_id_uuid / 026_drop_agent_otp_legacy
|
||||
- 都在 021 之后(022 改为 down_revision="021_rbac")
|
||||
|
||||
预置数据:
|
||||
- user 角色 (is_default=True, 所有在职员工自动获得)
|
||||
- agent 角色 (IT坐席)
|
||||
- admin 角色 (管理员, is_default=False)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '021_rbac'
|
||||
down_revision = '012_sync_remaining_fields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""重建 RBAC 三张表(IF NOT EXISTS 兼容)。"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. roles 表
|
||||
# ----------------------------------------------------------------------
|
||||
if not inspector.has_table('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, default=list,
|
||||
comment='权限列表(JSON数组)'),
|
||||
sa.Column('is_default', sa.Boolean, nullable=False, default=False,
|
||||
comment='是否默认角色(所有员工自动获得)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
comment='更新时间'),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. user_roles 表
|
||||
# ----------------------------------------------------------------------
|
||||
if not inspector.has_table('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,
|
||||
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 表
|
||||
# ----------------------------------------------------------------------
|
||||
if not inspector.has_table('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, default=0,
|
||||
comment='优先级(数值越大优先级越高)'),
|
||||
sa.Column('is_active', sa.Boolean, nullable=False, default=True,
|
||||
comment='是否启用'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
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'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 RBAC 三张表(顺序: 子表 → 父表)。"""
|
||||
op.drop_table('role_mapping_rules')
|
||||
op.drop_table('user_roles')
|
||||
op.drop_table('roles')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""qrcode login (Phase 1.1)
|
||||
|
||||
Revision ID: 022_qrcode_login
|
||||
Revises: 021_rbac
|
||||
Create Date: 2026-06-21
|
||||
|
||||
Phase 1.1 扫码登录后端接口(task #14)。
|
||||
|
||||
设计说明:
|
||||
扫码登录的所有状态都存在 Redis(无需新增数据库表):
|
||||
- qrcode:ticket:{ticket} → {created_at, expires_at}, TTL 120s
|
||||
- qrcode:scan:{ticket} → {employee_id, name, scanned_at}, TTL 120s
|
||||
- qrcode:confirm:{ticket} → {token, confirmed_at, roles}, TTL 60s
|
||||
|
||||
不动 User / Agent 模型(MFA 字段留给 Phase 2.1)。
|
||||
不动 auth2fa.py(SMS 备用通道保留)。
|
||||
|
||||
为什么仍然生成这个 migration 文件:
|
||||
1. alembic 版本链不能断,021 → 022 必须存在(后续 023+ 需要接续)
|
||||
2. 标记 Phase 1.1 上线,方便运维追溯和回滚标记
|
||||
3. upgrade()/downgrade() 都是空操作,因为没有 schema 变更
|
||||
|
||||
运维注意事项:
|
||||
- 该 migration 不需要执行 SQL(已注释),但需要"alembic stamp 022"让 alembic_version 表对齐
|
||||
- 如果未来扫码登录要持久化历史记录(审计/防滥用),再追加 023_qrcode_audit.py 加 qrcode_login_logs 表
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "022_qrcode_login"
|
||||
down_revision = "021_rbac"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Phase 1.1 扫码登录无 schema 变更,upgrade 留空。
|
||||
|
||||
预留说明: 如果部署时 alembic stamp 未执行,导致 backend 启动报
|
||||
"alembic_version" mismatch,只需 `alembic stamp 022` 即可对齐。
|
||||
"""
|
||||
# 故意 pass:扫码登录的所有数据存 Redis,无 DB schema 变更
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Phase 1.1 扫码登录无 schema 变更,downgrade 留空。"""
|
||||
# 故意 pass
|
||||
pass
|
||||
@@ -0,0 +1,100 @@
|
||||
"""add agent MFA fields
|
||||
|
||||
Revision ID: 023_mfa_fields
|
||||
Revises: 012_sync_remaining_fields
|
||||
Create Date: 2026-06-21
|
||||
|
||||
Phase 2.1 task #17: pyotp TOTP 服务 + User MFA 字段
|
||||
- 新增 mfa_secret 字段(存储 TOTP secret,绑定时生成,首次验证前不算启用)
|
||||
- 新增 mfa_enabled 字段(是否启用 MFA,默认 False)
|
||||
- 新增 mfa_bound_at 字段(首次绑定完成时间,可空)
|
||||
- 新增 mfa_last_verified_at 字段(最近一次验证成功时间,可空)
|
||||
|
||||
为什么需要独立字段而非复用早期 otp_*:
|
||||
Phase 2.1 的 MFA 是面向全员(员工 + 坐席)的统一二次认证方案,
|
||||
与早期仅供 admin 强制 OTP 的 otp_secret / otp_enabled 是两套体系。
|
||||
字段独立便于后续维护 + 迁移路径清晰。
|
||||
|
||||
为什么不破坏现有坐席:
|
||||
- mfa_secret 默认为 NULL,允许已注册坐席不绑定
|
||||
- mfa_enabled 用 server_default=text('false')(字符串 false,不是 Python False),
|
||||
否则 Alembic 会写入整数 0 在 PG 里被解读为 truthy
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '023_mfa_fields'
|
||||
down_revision = '012_sync_remaining_fields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 4 个 MFA 字段到 agents 表"""
|
||||
# --------------------------------------------------------------------------
|
||||
# mfa_secret: TOTP 共享密钥(base32,绑定时生成)
|
||||
# 可空,默认 None — 用户没绑定时就是空
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'mfa_secret',
|
||||
sa.String(32),
|
||||
nullable=True,
|
||||
comment='MFA TOTP 共享密钥(base32,绑定时生成)',
|
||||
)
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# mfa_enabled: 是否启用 MFA
|
||||
# 非空,默认 False
|
||||
# server_default 必须用 text('false') 字符串形式(PG 把 false 解析为布尔 false)
|
||||
# 直接传 sa.text('False') 或 Python False 会被 SQLAlchemy 当成 truthy 写出 '1'
|
||||
# 详见 memory: feedback-adopted-default-bug.md
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'mfa_enabled',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='MFA 是否启用(False/True)',
|
||||
)
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# mfa_bound_at: 首次绑定完成时间(可空)
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'mfa_bound_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment='MFA 首次绑定完成时间',
|
||||
)
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# mfa_last_verified_at: 最近一次验证成功时间(可空,审计用)
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'mfa_last_verified_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment='MFA 最近一次验证成功时间',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 4 个 MFA 字段(按添加的逆序)"""
|
||||
op.drop_column('agents', 'mfa_last_verified_at')
|
||||
op.drop_column('agents', 'mfa_bound_at')
|
||||
op.drop_column('agents', 'mfa_enabled')
|
||||
op.drop_column('agents', 'mfa_secret')
|
||||
@@ -0,0 +1,81 @@
|
||||
# =============================================================================
|
||||
# Alembic migration: messages.id 改为 UUID 列类型
|
||||
# =============================================================================
|
||||
# 背景(2026-06-21 评审):
|
||||
# 当前 messages.id 在本地 dev 是 String(36) 存 UUID 字符串,
|
||||
# 生产 PostgreSQL 应该是原生 UUID 列类型(性能更好,索引更小,类型严格)。
|
||||
# 现状:本地 SQLite/String(36) 与生产 PostgreSQL/UUID 类型不一致,
|
||||
# 跨环境数据迁移和 ORM 比较容易踩坑。
|
||||
#
|
||||
# 修复目标:
|
||||
# 1. 生产 PostgreSQL: messages.id 改为原生 UUID 类型
|
||||
# - 节省存储(16 bytes vs 36 bytes)
|
||||
# - 索引更高效
|
||||
# - 数据库层强类型校验
|
||||
# 2. 应用层兼容:SQLAlchemy 仍用 String(36),Python 端 str(uuid4()),
|
||||
# PG driver 会自动 cast 到 UUID 列(同 initial migration 的兼容策略)
|
||||
#
|
||||
# 注意:这个 migration 只在 PostgreSQL 上有效(UUID 是 PG 关键字)。
|
||||
# SQLite 测试环境会跳过执行(使用 `IF EXISTS` 或 try/except 兼容)。
|
||||
# 实际上 SQLite 在 dev 用 create_all() 自动建表,根本不会跑 alembic。
|
||||
#
|
||||
# v1.0 前必做(对应 P0 评审 #60 messages.id 类型不匹配):
|
||||
# 评审报告: docs/review/sql-messages-id-varchar-vs-uuid.md
|
||||
# =============================================================================
|
||||
|
||||
"""messages id UUID type
|
||||
|
||||
Revision ID: 025_messages_id_uuid
|
||||
Revises: 012_sync_remaining_fields
|
||||
Create Date: 2026-06-21
|
||||
|
||||
v1.0 P0: messages.id 从 VARCHAR(32)/String(36) 改为 PostgreSQL 原生 UUID 类型
|
||||
|
||||
为什么需要这个 migration:
|
||||
- 当前 id 列是 VARCHAR,存 UUID 字符串(36 chars)
|
||||
- 生产 PG 应改用 UUID 类型,节省存储 + 数据库层强类型
|
||||
- SQLAlchemy 仍用 String(36) 兼容 SQLite/PG,Python 端 str(uuid4()) 通用
|
||||
- 数据无损:36 字符 UUID 字符串可直接 cast 到 UUID 列
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '025_messages_id_uuid'
|
||||
down_revision = '012_sync_remaining_fields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""把 messages.id 改为 PostgreSQL UUID 类型。
|
||||
|
||||
实现细节:
|
||||
- 用 USING id::UUID 让 PG 自动把现有 VARCHAR 字符串 cast 到 UUID
|
||||
- 用 IF EXISTS 防御 SQLite 测试环境(没这列会跳过)
|
||||
- 只在 PostgreSQL 上跑(UUID 是 PG 关键字)
|
||||
|
||||
兼容性:
|
||||
- 应用层 SQLAlchemy 模型:仍用 String(36),PG driver 自动 cast
|
||||
- Python 端:str(uuid.uuid4()) 生成 36 字符字符串,等价 UUID 字面量
|
||||
- 现有 36 字符 UUID 字符串数据:无丢失,无错误
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
# 只在 PostgreSQL 上执行(SQLite 测试环境无 UUID 关键字)
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"ALTER TABLE messages ALTER COLUMN id TYPE UUID USING id::UUID"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""把 messages.id 改回 VARCHAR(32)。
|
||||
|
||||
警告:downgrade 会丢失 PG 强类型约束,生产回滚需谨慎。
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"ALTER TABLE messages ALTER COLUMN id TYPE VARCHAR(32) USING id::VARCHAR"
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""drop legacy agent OTP fields
|
||||
|
||||
Revision ID: 026_drop_agent_otp_legacy
|
||||
Revises: 025_messages_id_uuid
|
||||
Create Date: 2026-06-22
|
||||
|
||||
v0.7.1: 清理 v0.5.6 引入的 otp_secret / otp_enabled 双字段
|
||||
原因: 旧 OTP 字段只用于高危操作前的二次验证,mfa_secret/mfa_enabled(migration 023)
|
||||
已涵盖该用途。两个字段名不同导致 v0.7.0 生产报错:
|
||||
column agents.otp_secret does not exist(alembic 010 之前没在生产跑过)
|
||||
|
||||
策略: 用 IF EXISTS 兼容"列不存在"情况(因为生产数据库可能从来没建过这列)
|
||||
DROP COLUMN 不会破坏生产 — mfa_secret 是新的生产字段,otp_secret 只是历史遗留
|
||||
|
||||
下游: agents.py / admin_api.py 改用 mfa_secret/mfa_enabled
|
||||
Agent 模型删 otp_secret/otp_enabled 字段
|
||||
|
||||
回退: 此 migration 的 downgrade 重新添加 otp_secret/otp_enabled
|
||||
如果生产用过 OTP 的话要回退(目前 IT 支持服务未正式上线,无此风险)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '026_drop_agent_otp_legacy'
|
||||
down_revision = '025_messages_id_uuid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""删除 legacy OTP 字段(IF EXISTS 兼容列不存在的场景)。"""
|
||||
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_secret")
|
||||
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_enabled")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回退: 重新添加 legacy OTP 字段。"""
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'otp_secret',
|
||||
sa.String(64),
|
||||
nullable=True,
|
||||
comment='TOTP 密钥(base32,绑定时生成)'
|
||||
)
|
||||
)
|
||||
op.add_column(
|
||||
'agents',
|
||||
sa.Column(
|
||||
'otp_enabled',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='是否启用 OTP 二次验证'
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""audit_logs 表 — 高危操作/登录/MFA 审计日志
|
||||
|
||||
Revision ID: 027_audit_logs
|
||||
Revises: 026_drop_agent_otp_legacy
|
||||
Create Date: 2026-06-22 (v0.7.1)
|
||||
|
||||
v0.7.1 task #89 实施,配合 RBAC 5 角色的 audit_log 资源(给 auditor 角色只读用)
|
||||
|
||||
字段:
|
||||
- id: UUID 主键
|
||||
- employee_id: 操作人(企微 UserID / 'system')
|
||||
- action: 操作类型
|
||||
- resource: 目标资源类型
|
||||
- resource_id: 目标资源 ID
|
||||
- details: JSON 详细上下文
|
||||
- result: success / failure / partial
|
||||
- ip_address: 来源 IP
|
||||
- user_agent: 来源 UA
|
||||
- created_at: 时间
|
||||
|
||||
索引:
|
||||
- idx_audit_employee_id: 按操作人查
|
||||
- idx_audit_action: 按操作类型查
|
||||
- idx_audit_resource: 按资源类型+ID 查
|
||||
- idx_audit_created_at: 按时间范围查(默认倒序)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '027_audit_logs'
|
||||
down_revision = '026_drop_agent_otp_legacy'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""建 audit_logs 表 + 索引。"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table('audit_logs'):
|
||||
op.create_table(
|
||||
'audit_logs',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('employee_id', sa.String(100), nullable=False,
|
||||
comment='操作人(employee_id / system)'),
|
||||
sa.Column('action', sa.String(50), nullable=False,
|
||||
comment='操作类型'),
|
||||
sa.Column('resource', sa.String(50), nullable=False,
|
||||
comment='目标资源类型'),
|
||||
sa.Column('resource_id', sa.String(100), nullable=True,
|
||||
comment='目标资源 ID'),
|
||||
sa.Column('details', sa.JSON, nullable=True,
|
||||
comment='详细上下文(JSON)'),
|
||||
sa.Column('result', sa.String(20), nullable=False, server_default='success',
|
||||
comment='执行结果'),
|
||||
sa.Column('ip_address', sa.String(64), nullable=True,
|
||||
comment='来源 IP'),
|
||||
sa.Column('user_agent', sa.Text, nullable=True,
|
||||
comment='来源 User-Agent'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
comment='时间'),
|
||||
)
|
||||
|
||||
# 4 个索引 (IF NOT EXISTS 兼容)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_employee_id ON audit_logs (employee_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs (action)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_logs (resource, resource_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs (created_at)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删 audit_logs 表(顺序: 删索引 → 删表)。"""
|
||||
op.execute("DROP INDEX IF EXISTS idx_audit_created_at")
|
||||
op.execute("DROP INDEX IF EXISTS idx_audit_resource")
|
||||
op.execute("DROP INDEX IF EXISTS idx_audit_action")
|
||||
op.execute("DROP INDEX IF EXISTS idx_audit_employee_id")
|
||||
op.execute("DROP TABLE IF EXISTS audit_logs")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""merge heads: 022_qrcode_login + 023_mfa_fields + 027_audit_logs
|
||||
|
||||
Revision ID: 028_merge_heads
|
||||
Revises: 022_qrcode_login, 023_mfa_fields, 027_audit_logs
|
||||
Create Date: 2026-06-22
|
||||
|
||||
v0.7.1 部署 P0 修复 2026-06-22:
|
||||
三个 head 来自:
|
||||
- 022_qrcode_login (原 down_revision='021_rbac' 指向不存在的 021, 改成 '012_sync_remaining_fields' 后变 head)
|
||||
- 023_mfa_fields (down_revision='012_sync_remaining_fields' 平行挂 012)
|
||||
- 027_audit_logs (v0.7.1 audit_log 模型, 顺 025→026 接续)
|
||||
|
||||
合并这三个 head 成单一 028_merge_heads 节点,让 alembic upgrade head 不再
|
||||
报 "Multiple head revisions are present"。
|
||||
本 migration 是 noop(纯拓扑合并,无 schema 变更),生产 DB 当前 025_messages_id_uuid
|
||||
早已跑过 022(noop pass)和 023(实际加 MFA 字段),只需要让链可解析。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '028_merge_heads'
|
||||
down_revision = ('022_qrcode_login', '023_mfa_fields', '027_audit_logs')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""noop: 纯合并,无 schema 变更"""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""noop: 纯合并,无 schema 变更"""
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add message server_timestamp for message ordering
|
||||
|
||||
Revision ID: 041_message_server_timestamp
|
||||
Revises:
|
||||
Create Date: 2026-07-02
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '041_message_server_timestamp'
|
||||
down_revision: Union[str, None] = '027_audit_logs'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add server_timestamp field for message ordering
|
||||
# BIGINT to store millisecond-level timestamp for precise ordering
|
||||
op.add_column(
|
||||
'messages',
|
||||
sa.Column('server_timestamp', sa.BigInteger(), nullable=True, comment='服务端时间戳(毫秒)')
|
||||
)
|
||||
# Add index for server_timestamp queries
|
||||
op.create_index(
|
||||
'idx_messages_server_timestamp',
|
||||
'messages',
|
||||
['server_timestamp']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_messages_server_timestamp', table_name='messages')
|
||||
op.drop_column('messages', 'server_timestamp')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add employee avatar_updated_at field for avatar refresh tracking
|
||||
|
||||
Revision ID: 042_add_employee_avatar_updated_at
|
||||
Revises: 041_message_server_timestamp
|
||||
Create Date: 2026-07-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '042_add_employee_avatar_updated_at'
|
||||
down_revision: Union[str, None] = '041_message_server_timestamp'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add avatar_updated_at field to employees table
|
||||
# This field tracks when the avatar was last updated from WeCom API
|
||||
op.add_column(
|
||||
'employees',
|
||||
sa.Column('avatar_updated_at', sa.DateTime(), nullable=True, comment='头像最后更新时间')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('employees', 'avatar_updated_at')
|
||||
@@ -0,0 +1,66 @@
|
||||
"""add knowledge iteration tables: conversation_annotations and knowledge_suggestions
|
||||
|
||||
Revision ID: 043_knowledge_iteration
|
||||
Revises: 042_add_employee_avatar_updated_at
|
||||
Create Date: 2026-07-06
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '043_knowledge_iteration'
|
||||
down_revision: Union[str, None] = '042_add_employee_avatar_updated_at'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. 创建会话标注表 conversation_annotations
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'conversation_annotations',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('conversation_id', sa.String(36), nullable=False, index=True),
|
||||
sa.Column('agent_id', sa.String(36), nullable=False),
|
||||
sa.Column('message_id', sa.String(36), nullable=False),
|
||||
sa.Column('feedback', sa.String(20), nullable=False),
|
||||
sa.Column('comment', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_annotation_conversation', 'conversation_annotations', ['conversation_id'])
|
||||
op.create_index('idx_annotation_message', 'conversation_annotations', ['message_id'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. 创建知识库优化建议表 knowledge_suggestions
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('suggestion_type', sa.String(20), nullable=False, server_default='new_faq'),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True),
|
||||
sa.Column('title', sa.String(256), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('category', sa.String(64), nullable=False, server_default='其他'),
|
||||
sa.Column('tags', sa.JSON(), nullable=False, server_default='[]'),
|
||||
sa.Column('source_type', sa.String(30), nullable=False),
|
||||
sa.Column('source_data', sa.JSON(), nullable=True),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('reject_reason', sa.Text(), nullable=True),
|
||||
sa.Column('reviewer_id', sa.String(36), nullable=True),
|
||||
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_suggestion_status', 'knowledge_suggestions', ['status'])
|
||||
op.create_index('idx_suggestion_type', 'knowledge_suggestions', ['suggestion_type'])
|
||||
op.create_index('idx_suggestion_created', 'knowledge_suggestions', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('knowledge_suggestions')
|
||||
op.drop_table('conversation_annotations')
|
||||
@@ -0,0 +1,174 @@
|
||||
"""add automation tables (阶段5 自动化闭环): auto_sessions / auto_actions /
|
||||
auto_approval_tickets / auto_scenario_configs / auto_rule_versions /
|
||||
auto_action_logs / auto_mapping_cache
|
||||
|
||||
Revision ID: 044_automation
|
||||
Revises: 043_knowledge_iteration
|
||||
Create Date: 2026-07-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '044_automation'
|
||||
down_revision: Union[str, None] = '043_knowledge_iteration'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. 自动化处置会话 auto_sessions
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_sessions',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('conversation_id', sa.String(36), nullable=True, index=True),
|
||||
sa.Column('employee_id', sa.String(64), nullable=False, index=True),
|
||||
sa.Column('agent_id', sa.String(64), nullable=True, index=True),
|
||||
sa.Column('scenario_key', sa.String(64), nullable=True, index=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='created', index=True),
|
||||
sa.Column('mode', sa.String(20), nullable=False, server_default='real_exec'),
|
||||
sa.Column('confidence', sa.Float(), nullable=False, server_default='0.0'),
|
||||
sa.Column('intent', sa.JSON(), nullable=True),
|
||||
sa.Column('current_action_id', sa.String(36), nullable=True),
|
||||
sa.Column('title', sa.String(256), nullable=False, server_default=''),
|
||||
sa.Column('auto_close_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('closed_by', sa.String(64), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_session_employee', 'auto_sessions', ['employee_id'])
|
||||
op.create_index('idx_auto_session_status', 'auto_sessions', ['status'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. 处置动作 auto_actions
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_actions',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('session_id', sa.String(36), nullable=False, index=True),
|
||||
sa.Column('action_index', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('action_type', sa.String(64), nullable=False, server_default=''),
|
||||
sa.Column('adapter', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('risk_level', sa.String(16), nullable=False, server_default='read'),
|
||||
sa.Column('title', sa.String(256), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True),
|
||||
sa.Column('payload', sa.JSON(), nullable=True),
|
||||
sa.Column('result', sa.JSON(), nullable=True),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('approved_by', sa.String(64), nullable=True),
|
||||
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_action_session', 'auto_actions', ['session_id'])
|
||||
op.create_index('idx_auto_action_status', 'auto_actions', ['status'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. 审批单 auto_approval_tickets
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_approval_tickets',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('action_id', sa.String(36), nullable=False, index=True),
|
||||
sa.Column('session_id', sa.String(36), nullable=False, index=True),
|
||||
sa.Column('approver_id', sa.String(64), nullable=True),
|
||||
sa.Column('channel', sa.String(16), nullable=False, server_default='agent'),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('decision_note', sa.Text(), nullable=True),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_approval_action', 'auto_approval_tickets', ['action_id'])
|
||||
op.create_index('idx_auto_approval_session', 'auto_approval_tickets', ['session_id'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. 场景配置 auto_scenario_configs
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_scenario_configs',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('scenario_key', sa.String(64), nullable=False, unique=True, index=True),
|
||||
sa.Column('name', sa.String(128), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('trigger_conditions', sa.JSON(), nullable=True),
|
||||
sa.Column('actions', sa.JSON(), nullable=True),
|
||||
sa.Column('approval_strategy', sa.JSON(), nullable=True),
|
||||
sa.Column('current_version_id', sa.String(36), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_scenario_key', 'auto_scenario_configs', ['scenario_key'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. 规则版本 auto_rule_versions
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_rule_versions',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('scenario_key', sa.String(64), nullable=False, index=True),
|
||||
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('content', sa.JSON(), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='draft', index=True),
|
||||
sa.Column('canary_percent', sa.Integer(), nullable=False, server_default='100'),
|
||||
sa.Column('created_by', sa.String(64), nullable=True),
|
||||
sa.Column('remark', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_rule_version_scenario', 'auto_rule_versions', ['scenario_key'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. 外部调用审计日志 auto_action_logs
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_action_logs',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('session_id', sa.String(36), nullable=True, index=True),
|
||||
sa.Column('action_id', sa.String(36), nullable=True, index=True),
|
||||
sa.Column('employee_id', sa.String(64), nullable=True),
|
||||
sa.Column('event', sa.String(128), nullable=False, server_default=''),
|
||||
sa.Column('direction', sa.String(8), nullable=False, server_default='out'),
|
||||
sa.Column('system', sa.String(32), nullable=False, server_default='internal'),
|
||||
sa.Column('request', sa.JSON(), nullable=True),
|
||||
sa.Column('response', sa.JSON(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('latency_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_action_log_session', 'auto_action_logs', ['session_id'])
|
||||
op.create_index('idx_auto_action_log_action', 'auto_action_logs', ['action_id'])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7. 映射缓存 auto_mapping_cache
|
||||
# --------------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'auto_mapping_cache',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('employee_id', sa.String(64), nullable=False, index=True),
|
||||
sa.Column('source', sa.String(32), nullable=False, server_default='lianruan'),
|
||||
sa.Column('mapped_data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index('idx_auto_mapping_employee', 'auto_mapping_cache', ['employee_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('auto_mapping_cache')
|
||||
op.drop_table('auto_action_logs')
|
||||
op.drop_table('auto_rule_versions')
|
||||
op.drop_table('auto_scenario_configs')
|
||||
op.drop_table('auto_approval_tickets')
|
||||
op.drop_table('auto_actions')
|
||||
op.drop_table('auto_sessions')
|
||||
@@ -0,0 +1,140 @@
|
||||
"""add graph/confidence/audience fields to knowledge_suggestions and knowledge_base
|
||||
|
||||
扩充 KnowledgeSuggestion 12 个字段(confidence/audience/issue/action/relation_type/
|
||||
parent_issue/graph_meta/graph_sync_status/source_failed/queued_at/applied_at)和
|
||||
KnowledgeBase 2 个字段(graph_sync_status/graph_node_uuid)。
|
||||
|
||||
关联设计文档:增量设计-知识库迭代与痛点缓解 §2.1
|
||||
|
||||
Revision ID: 045_graph_confidence_audience
|
||||
Revises: 044_automation
|
||||
Create Date: 2026-07-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '045_graph_confidence_audience'
|
||||
down_revision: Union[str, None] = '044_automation'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. knowledge_suggestions 表 — 新增 12 个字段
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('confidence', sa.Float(), nullable=True,
|
||||
comment='AI 生成置信度(0.0-1.0)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('audience', sa.String(30), nullable=True,
|
||||
comment='受众类型:employee_quick_reply / engineer_workguide'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('issue', sa.String(256), nullable=True,
|
||||
comment='图节点:问题名称(对应 Neo4j Issue.name)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('action', sa.String(256), nullable=True,
|
||||
comment='图节点:动作名称(对应 Neo4j Action.name)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('relation_type', sa.String(30), nullable=True,
|
||||
comment='图关系类型:LEADS_TO / RELATES_TO / CAN_JUMP_TO'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('parent_issue', sa.String(256), nullable=True,
|
||||
comment='父 Issue 名称(用于图关系构建)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('graph_meta', sa.JSON(), nullable=True,
|
||||
comment='图结构扩展元数据(JSON)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('graph_sync_status', sa.String(20), nullable=False,
|
||||
server_default='pending',
|
||||
comment='图同步状态:pending / synced / failed'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('source_failed', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='AI 生成失败标记(Dify 不可用或置信度不足)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='入队列时间'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('applied_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='应用到 KB 的时间'),
|
||||
)
|
||||
|
||||
# 新增索引
|
||||
op.create_index(
|
||||
'idx_suggestion_audience', 'knowledge_suggestions', ['audience'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_suggestion_confidence', 'knowledge_suggestions', ['confidence'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_suggestion_graph_sync', 'knowledge_suggestions', ['graph_sync_status'],
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. knowledge_base 表 — 新增 2 个字段
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'knowledge_base',
|
||||
sa.Column('graph_sync_status', sa.String(20), nullable=False,
|
||||
server_default='pending',
|
||||
comment='图同步状态:pending / synced / failed'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_base',
|
||||
sa.Column('graph_node_uuid', sa.String(36), nullable=True,
|
||||
comment='关联 Neo4j Issue 节点的 uuid'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 回滚 knowledge_suggestions
|
||||
# --------------------------------------------------------------------------
|
||||
op.drop_index('idx_suggestion_graph_sync', table_name='knowledge_suggestions')
|
||||
op.drop_index('idx_suggestion_confidence', table_name='knowledge_suggestions')
|
||||
op.drop_index('idx_suggestion_audience', table_name='knowledge_suggestions')
|
||||
|
||||
op.drop_column('knowledge_suggestions', 'applied_at')
|
||||
op.drop_column('knowledge_suggestions', 'queued_at')
|
||||
op.drop_column('knowledge_suggestions', 'source_failed')
|
||||
op.drop_column('knowledge_suggestions', 'graph_sync_status')
|
||||
op.drop_column('knowledge_suggestions', 'graph_meta')
|
||||
op.drop_column('knowledge_suggestions', 'parent_issue')
|
||||
op.drop_column('knowledge_suggestions', 'relation_type')
|
||||
op.drop_column('knowledge_suggestions', 'action')
|
||||
op.drop_column('knowledge_suggestions', 'issue')
|
||||
op.drop_column('knowledge_suggestions', 'audience')
|
||||
op.drop_column('knowledge_suggestions', 'confidence')
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 回滚 knowledge_base
|
||||
# --------------------------------------------------------------------------
|
||||
op.drop_column('knowledge_base', 'graph_node_uuid')
|
||||
op.drop_column('knowledge_base', 'graph_sync_status')
|
||||
@@ -0,0 +1,9 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 管理后台 API 子包
|
||||
# =============================================================================
|
||||
# 包标记文件
|
||||
# 2026-06-16 添加: 修复与同名文件 app/api/admin.py 冲突
|
||||
# 背景: router.py 引用 from app.api.admin.security_comparison import router
|
||||
# Python 优先选 admin.py 当 module,导致 admin/ 目录被忽略
|
||||
# 加上此文件后,admin/ 目录被识别为正式 package,优先于同名 .py 文件
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
终端安全对比 API
|
||||
|
||||
路径: /api/admin/security/comparison
|
||||
鉴权: require_admin
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.admin_api import require_admin
|
||||
from app.services.security_comparison import (
|
||||
TerminalSecurityComparison,
|
||||
comparison_task_config,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/security/comparison", tags=["终端安全对比"])
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
class CompareRequest(BaseModel):
|
||||
"""手动触发比对请求"""
|
||||
pass # 无参数,手动触发
|
||||
|
||||
|
||||
class CompareSummaryResponse(BaseModel):
|
||||
"""比对汇总响应"""
|
||||
lianruan_count: int
|
||||
huorong_count: int
|
||||
no_huorong_count: int
|
||||
compliance_rate: str
|
||||
generated_at: str
|
||||
|
||||
|
||||
class NoHuorongDevice(BaseModel):
|
||||
"""未安装火绒设备"""
|
||||
hostname: str
|
||||
ip: str
|
||||
useraccount: Optional[str] = None
|
||||
dept: Optional[str] = None
|
||||
last_login: Optional[str] = None
|
||||
osver: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class TaskConfigRequest(BaseModel):
|
||||
"""任务配置请求"""
|
||||
name: str # 任务名称
|
||||
cron: str # Cron 表达式,如 "0 9 * * 1" 每周一9点
|
||||
recipients: list[str] # 企微接收人user_id列表
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TaskConfigResponse(BaseModel):
|
||||
"""任务配置响应"""
|
||||
task_id: str
|
||||
name: str
|
||||
cron: str
|
||||
recipients: list[str]
|
||||
enabled: bool
|
||||
last_run: Optional[str] = None
|
||||
next_run: Optional[str] = None
|
||||
|
||||
|
||||
# --- API Endpoints ---
|
||||
@router.get("/summary", response_model=CompareSummaryResponse)
|
||||
async def get_comparison_summary(current_user=Depends(require_admin)):
|
||||
"""获取比对汇总数据"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
summary = await service.compare_summary()
|
||||
return summary
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
@router.get("/no-huorong", response_model=list[NoHuorongDevice])
|
||||
async def get_no_huorong_devices(current_user=Depends(require_admin)):
|
||||
"""获取未安装火绒的电脑清单"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
devices = await service.get_no_huorong_devices()
|
||||
return devices
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_comparison(current_user=Depends(require_admin)):
|
||||
"""手动触发比对并推送企微消息"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
# 1. 执行比对
|
||||
no_huorong = await service.get_no_huorong_devices()
|
||||
|
||||
# 2. 生成消息
|
||||
if no_huorong:
|
||||
msg = f"⚠️ 终端安全检查:发现 {len(no_huorong)} 台电脑未安装火绒\n\n"
|
||||
for dev in no_huorong[:10]: # 只显示前10条
|
||||
msg += f"• {dev.get('hostname')} ({dev.get('ip')})\n"
|
||||
if len(no_huorong) > 10:
|
||||
msg += f"... 还有 {len(no_huorong)-10} 台"
|
||||
else:
|
||||
msg = "✅ 终端安全检查:所有电脑已安装火绒"
|
||||
|
||||
# 3. TODO: 推送到企微(需要企微消息API)
|
||||
logger.info(f"比对结果: {msg}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"no_huorong_count": len(no_huorong),
|
||||
"message": msg,
|
||||
}
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
# --- 任务配置 API ---
|
||||
@router.get("/tasks", response_model=list[TaskConfigResponse])
|
||||
async def list_tasks(current_user=Depends(require_admin)):
|
||||
"""列出所有定时任务"""
|
||||
tasks = comparison_task_config.list_tasks()
|
||||
return tasks
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=TaskConfigResponse)
|
||||
async def create_task(
|
||||
config: TaskConfigRequest,
|
||||
current_user=Depends(require_admin)
|
||||
):
|
||||
"""创建定时任务"""
|
||||
task_id = str(uuid4())[:8]
|
||||
|
||||
comparison_task_config.add_task(task_id, {
|
||||
"name": config.name,
|
||||
"cron": config.cron,
|
||||
"recipients": config.recipients,
|
||||
"enabled": config.enabled,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
return TaskConfigResponse(
|
||||
task_id=task_id,
|
||||
**config.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
current_user=Depends(require_admin)
|
||||
):
|
||||
"""删除定时任务"""
|
||||
success = comparison_task_config.delete_task(task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# 日志记录
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,6 +15,7 @@
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -294,8 +295,10 @@ async def admin_unbind_agent_otp(
|
||||
if not agent:
|
||||
raise AppException(1001, "坐席不存在")
|
||||
|
||||
agent.otp_secret = None
|
||||
agent.otp_enabled = 0
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
@@ -893,12 +896,37 @@ async def get_agent_performance(
|
||||
async def get_system_logs(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
|
||||
config_key: Optional[str] = Query(None, description="按配置键精确筛选"),
|
||||
changed_by: Optional[str] = Query(None, description="按操作人 agent_id 精确筛选"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="变更时间起始(ISO8601, 闭区间)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="变更时间截止(ISO8601, 闭区间)"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取系统日志(配置变更日志)。"""
|
||||
result = await admin_service.get_system_logs(db, page=page, page_size=page_size)
|
||||
return success_response(data=result)# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
|
||||
"""获取系统日志(配置变更日志),支持配置键/操作人/时间范围筛选。
|
||||
|
||||
Args:
|
||||
config_key: 按配置键精确匹配(可选)
|
||||
changed_by: 按操作人 agent_id 精确匹配(可选)
|
||||
from_time: 变更时间起始(别名 from,可选)
|
||||
to_time: 变更时间截止(别名 to,可选)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含筛选后的配置变更日志
|
||||
"""
|
||||
result = await admin_service.get_system_logs(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
config_key=config_key,
|
||||
changed_by=changed_by,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
)
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
|
||||
@router.get("/integrations/lianruan/terminals/{devname}/detail")
|
||||
async def get_lianruan_terminal_detail(
|
||||
devname: str,
|
||||
@@ -1012,3 +1040,71 @@ async def ragflow_retrieval(
|
||||
return success_response(data={"error": e.message, "error_code": "config_missing"})
|
||||
except RagflowError as e:
|
||||
return success_response(data={"error": e.message, "error_code": "api_error"})
|
||||
|
||||
|
||||
# ---------- POST /api/admin/users/{employee_id}/revoke-token ----------
|
||||
@router.post("/users/{employee_id}/revoke-token")
|
||||
async def revoke_user_token(
|
||||
employee_id: str,
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""强制撤销指定用户的登录Token(管理员操作)。
|
||||
|
||||
清除该用户在 Redis 中的所有 Token,使其被迫下线。
|
||||
同时记录审计日志。
|
||||
|
||||
Args:
|
||||
employee_id: 要撤销 Token 的用户 ID(企微 UserID)
|
||||
|
||||
Returns:
|
||||
撤销结果
|
||||
"""
|
||||
from app.dependencies import get_redis
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
|
||||
redis_client = await get_redis()
|
||||
|
||||
# 搜索可能的 Token key 模式
|
||||
# 1. user:token:* - 统一格式
|
||||
# 2. agent:token:* - 坐席端
|
||||
# 3. employee:token:* - 员工端
|
||||
revoked_count = 0
|
||||
patterns = ["user:token:*", "agent:token:*", "employee:token:*"]
|
||||
|
||||
for pattern in patterns:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await redis_client.scan(cursor, match=pattern, count=100)
|
||||
for key in keys:
|
||||
token_data = await redis_client.get(key)
|
||||
if token_data:
|
||||
try:
|
||||
import json
|
||||
data = json.loads(token_data)
|
||||
if data.get("employee_id") == employee_id:
|
||||
await redis_client.delete(key)
|
||||
revoked_count += 1
|
||||
logger.info(f"撤销 Token: key={key}, employee_id={employee_id}")
|
||||
except (json.JSONDecodeError, Exception):
|
||||
pass
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# 记录审计日志
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=admin.employee_id,
|
||||
action="revoke_token",
|
||||
resource="user",
|
||||
resource_id=employee_id,
|
||||
details={"revoked_count": revoked_count, "operator": admin.employee_id},
|
||||
result="success",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={
|
||||
"employee_id": employee_id,
|
||||
"revoked_count": revoked_count,
|
||||
"message": f"已撤销 {revoked_count} 个 Token" if revoked_count > 0 else "未找到该用户的有效 Token",
|
||||
})
|
||||
@@ -13,12 +13,14 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
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.dependencies import get_current_user, UserInfo, require_role, dep_redis
|
||||
from app.database import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.role import Role
|
||||
from app.models.role_mapping_rule import RoleMappingRule
|
||||
from app.models.user_role import UserRole
|
||||
@@ -30,6 +32,7 @@ from app.schemas.role import (
|
||||
RoleResponse,
|
||||
UserRoleResponse,
|
||||
)
|
||||
from app.services.employee_directory import get_org_directory, resolve_target
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -129,6 +132,55 @@ async def get_roles(
|
||||
return success_response(data=[r.model_dump() for r in role_list])
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 1.5. 用户角色分配列表
|
||||
# ==========================================================================
|
||||
|
||||
# ---------- GET /api/admin/roles/user-roles ----------
|
||||
@router.get("/user-roles")
|
||||
async def list_user_role_assignments(
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取所有用户角色分配记录。
|
||||
|
||||
Returns:
|
||||
List of user role assignments with employee_id, role info, source, etc.
|
||||
"""
|
||||
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
|
||||
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
|
||||
# 同时 LEFT JOIN employees 表获取员工姓名。
|
||||
# assigned_at 定义为 NOT NULL,nulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
|
||||
stmt = (
|
||||
select(UserRole, Role, Employee)
|
||||
.outerjoin(Role, UserRole.role_id == Role.id)
|
||||
.outerjoin(Employee, UserRole.employee_id == Employee.employee_id)
|
||||
.order_by(UserRole.assigned_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
assignments = []
|
||||
for user_role, role, employee in rows:
|
||||
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
|
||||
role_name = role.name if role else "unknown"
|
||||
role_display = (role.display_name or role.name) if role else "未知角色"
|
||||
# employee 可能为 None(员工已从组织架构移除)
|
||||
employee_name = employee.name if employee else ""
|
||||
assignments.append({
|
||||
"employee_id": user_role.employee_id,
|
||||
"employee_name": employee_name,
|
||||
"role_name": role_name,
|
||||
"role_display_name": role_display,
|
||||
"source": user_role.source or "manual",
|
||||
"assigned_by": user_role.assigned_by or "",
|
||||
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
|
||||
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
||||
})
|
||||
|
||||
return success_response(data=assignments)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 2. 用户角色分配/撤销
|
||||
# ==========================================================================
|
||||
@@ -139,22 +191,46 @@ async def assign_role(
|
||||
body: RoleAssignRequest,
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""手动分配角色。
|
||||
|
||||
为指定用户分配角色,记录分配者和分配原因。
|
||||
支持输入「员工账号 或 姓名」:
|
||||
- 先经 employee_directory.resolve_target 解析为企微 UserID;
|
||||
- 再用企微通讯录实时校验该员工确实属于企微组织架构(不在组织内则拒绝)。
|
||||
安全限制:禁止管理员给自己分配角色。
|
||||
|
||||
Args:
|
||||
body: 分配角色请求
|
||||
body: 分配角色请求(target=账号/姓名,或兼容 employee_id)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
redis: Redis 客户端(组织目录缓存用)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 取待解析的目标(优先 target,兼容旧 employee_id)
|
||||
raw = (body.target or body.employee_id or "").strip()
|
||||
if not raw:
|
||||
raise AppException(4001, "请输入员工账号或姓名")
|
||||
|
||||
# 解析 + 实时校验:是否为企微组织架构内的真实员工
|
||||
resolved = await resolve_target(raw, db, redis)
|
||||
if resolved.get("ambiguous"):
|
||||
cands = resolved["candidates"]
|
||||
names = "、".join(f"{c['name']}({c['employee_id']})" for c in cands[:5])
|
||||
raise AppException(
|
||||
4016,
|
||||
f"匹配到多名员工,请改用员工账号精确分配:{names}",
|
||||
)
|
||||
if not resolved.get("found"):
|
||||
raise AppException(4017, resolved.get("reason", "员工不存在"))
|
||||
|
||||
employee_id = resolved["employee_id"]
|
||||
|
||||
# 安全限制:禁止管理员给自己分配角色
|
||||
if body.employee_id == admin.employee_id:
|
||||
if admin.employee_id and employee_id == admin.employee_id:
|
||||
raise AppException(4014, "不能给自己分配角色")
|
||||
|
||||
# 查询目标角色
|
||||
@@ -167,7 +243,7 @@ async def assign_role(
|
||||
|
||||
# 检查是否已拥有该角色
|
||||
existing_stmt = select(UserRole).where(
|
||||
UserRole.employee_id == body.employee_id,
|
||||
UserRole.employee_id == employee_id,
|
||||
UserRole.role_id == role.id,
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
@@ -178,7 +254,7 @@ async def assign_role(
|
||||
|
||||
# 创建用户角色关联
|
||||
user_role = UserRole(
|
||||
employee_id=body.employee_id,
|
||||
employee_id=employee_id,
|
||||
role_id=role.id,
|
||||
source="manual",
|
||||
assigned_by=admin.employee_id,
|
||||
@@ -186,11 +262,50 @@ async def assign_role(
|
||||
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}")
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
|
||||
|
||||
return success_response(message=f"角色 {body.role_name} 分配成功")
|
||||
|
||||
|
||||
# ---------- GET /api/admin/roles/employees/search ----------
|
||||
@router.get("/employees/search")
|
||||
async def search_employees(
|
||||
q: str = Query("", description="姓名或员工账号关键字"),
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""员工目录搜索(分配角色时自动补全用)。
|
||||
|
||||
返回匹配关键字的员工候选(账号 + 姓名 + 部门),并在 full_directory 字段
|
||||
标明当前目录是否覆盖全公司(True=企微全组织;False=仅已登录员工,需开通
|
||||
通讯录读取权限才能搜索全公司)。
|
||||
|
||||
Args:
|
||||
q: 搜索关键字(姓名或账号,至少 1 个字符)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
redis: Redis 客户端
|
||||
|
||||
Returns:
|
||||
Dict: { items: [...], full_directory: bool|None }
|
||||
"""
|
||||
q = (q or "").strip()
|
||||
if len(q) < 1:
|
||||
return success_response(data={"items": [], "full_directory": None})
|
||||
|
||||
directory, full = await get_org_directory(db, redis)
|
||||
ql = q.lower()
|
||||
hits = [
|
||||
m for m in directory
|
||||
if ql in (m["employee_id"] or "").lower()
|
||||
or ql in (m["name"] or "").lower()
|
||||
]
|
||||
# 优先精确账号命中,其次姓名包含
|
||||
items = hits[:20]
|
||||
return success_response(data={"items": items, "full_directory": full})
|
||||
|
||||
|
||||
# ---------- POST /api/admin/roles/revoke ----------
|
||||
@router.post("/revoke")
|
||||
async def revoke_role(
|
||||
@@ -382,3 +497,132 @@ async def delete_mapping_rule(
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
||||
|
||||
return success_response(message="映射规则删除成功")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. 权限矩阵可视化 (v0.7.1 task #86)
|
||||
# ==========================================================================
|
||||
# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵
|
||||
# 嵌套结构方便前端直接渲染表格:
|
||||
# {
|
||||
# "roles": [{name, display_name, permissions: [string]}],
|
||||
# "resources": [conversation, agent, ...],
|
||||
# "actions": [read, create, update, delete],
|
||||
# "scopes": [own, department, all],
|
||||
# "matrix": {
|
||||
# "agent": { # 角色名
|
||||
# "conversation:read:own": true,
|
||||
# "conversation:read:all": true,
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ==========================================================================
|
||||
@router.get("/permissions/matrix")
|
||||
async def get_permissions_matrix(
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 RBAC 完整权限矩阵(管理后台可视化用)。
|
||||
|
||||
返回 5 角色预置的 permissions JSON,前端用此数据渲染
|
||||
角色 × 资源 × 操作 × 范围 的可读表格。
|
||||
|
||||
Args:
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含完整权限矩阵
|
||||
"""
|
||||
from app.services.rbac_service import (
|
||||
ROLE_PERMISSIONS,
|
||||
VALID_ACTIONS,
|
||||
VALID_RESOURCES,
|
||||
VALID_SCOPES,
|
||||
permissions_to_strings,
|
||||
)
|
||||
|
||||
# 1. 查 DB 拿角色元数据(显示名等)
|
||||
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
||||
result = await db.execute(stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
# 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name)
|
||||
role_list = []
|
||||
matrix = {}
|
||||
for role in roles:
|
||||
# 优先用代码常量(单一可信源);DB 字段仅作元数据
|
||||
perms = ROLE_PERMISSIONS.get(role.name, set())
|
||||
perms_list = permissions_to_strings(perms)
|
||||
|
||||
role_list.append({
|
||||
"name": role.name,
|
||||
"display_name": role.display_name,
|
||||
"description": role.description,
|
||||
"is_default": role.is_default,
|
||||
"permission_count": len(perms_list),
|
||||
})
|
||||
|
||||
# 3. 角色 × 资源 × 操作 × 范围 的全矩阵
|
||||
# true/false 表征是否拥有此权限
|
||||
# 前端用此渲染表格,空格表示"不适用"
|
||||
role_matrix = {}
|
||||
for resource in VALID_RESOURCES:
|
||||
for action in VALID_ACTIONS:
|
||||
for scope in VALID_SCOPES:
|
||||
perm = f"{resource}:{action}:{scope}"
|
||||
role_matrix[perm] = (resource, action, scope) in perms
|
||||
matrix[role.name] = role_matrix
|
||||
|
||||
return success_response(data={
|
||||
"roles": role_list,
|
||||
"resources": VALID_RESOURCES,
|
||||
"actions": VALID_ACTIONS,
|
||||
"scopes": VALID_SCOPES,
|
||||
"matrix": matrix,
|
||||
})
|
||||
|
||||
|
||||
# ---------- GET /api/admin/roles/permissions/check ----------
|
||||
# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有
|
||||
# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission
|
||||
@router.get("/permissions/check")
|
||||
async def check_my_permission(
|
||||
resource: str = Query(..., description="资源"),
|
||||
action: str = Query(..., description="操作"),
|
||||
scope: str = Query("own", description="数据范围"),
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
):
|
||||
"""检查当前管理员是否拥有指定权限(给前端按钮级控制用)。
|
||||
|
||||
永远返回 true(因为 require_admin 已确保是 admin)。
|
||||
此端点存在是为了给前端一个统一入口,实际权限由后端强制。
|
||||
未来扩展:可加 current_user 参数(非 admin 角色也能调)。
|
||||
|
||||
Args:
|
||||
resource: 资源
|
||||
action: 操作
|
||||
scope: 数据范围
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 has_permission 字段
|
||||
"""
|
||||
from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings
|
||||
|
||||
user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()}
|
||||
|
||||
has_perm = check_permission(
|
||||
user_roles=admin.roles,
|
||||
user_permissions=user_perms,
|
||||
required_resource=resource,
|
||||
required_action=action,
|
||||
required_scope=scope,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"has_permission": has_perm,
|
||||
"resource": resource,
|
||||
"action": action,
|
||||
"scope": scope,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 管理员用户管理 API
|
||||
# =============================================================================
|
||||
# 说明:管理员用户的 CRUD API
|
||||
# 端点:
|
||||
# GET /api/admin/users — 获取管理员列表
|
||||
# POST /api/admin/users — 创建管理员
|
||||
# GET /api/admin/users/{id} — 获取管理员详情
|
||||
# PUT /api/admin/users/{id} — 更新管理员
|
||||
# DELETE /api/admin/users/{id} — 删除管理员
|
||||
# POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import UserInfo, get_current_user, require_role
|
||||
from app.schemas.admin_user import (
|
||||
AdminUserCreateRequest,
|
||||
AdminUserListResponse,
|
||||
AdminUserResetPasswordRequest,
|
||||
AdminUserResponse,
|
||||
AdminUserUpdateRequest,
|
||||
)
|
||||
from app.services.admin_user_service import AdminUserService
|
||||
from app.utils.response import AppException, success_response
|
||||
from app.utils.error_codes import ErrorCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/admin/users", tags=["管理员用户管理"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 0. GET /api/admin/users/me — 获取当前登录用户信息
|
||||
# =============================================================================
|
||||
@router.get("/me", response_model=None)
|
||||
async def get_current_admin_user(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前登录的管理员用户信息。
|
||||
|
||||
无需额外权限,任何已登录用户都可以访问。
|
||||
|
||||
Args:
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
当前用户信息
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
agent = await service.get_user_by_user_id(current_user.employee_id)
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. GET /api/admin/users — 获取管理员列表
|
||||
# =============================================================================
|
||||
@router.get("", response_model=None)
|
||||
@require_role("admin")
|
||||
async def list_admin_users(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
is_active: Optional[bool] = Query(None, description="是否激活(true=在线,false=离线)"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户列表。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
is_active: 按激活状态过滤
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
管理员列表
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
items, total = await service.list_admin_users(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
# 转换为响应格式
|
||||
user_responses = []
|
||||
for agent in items:
|
||||
user_responses.append(AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
))
|
||||
|
||||
return success_response(data=AdminUserListResponse(
|
||||
items=user_responses,
|
||||
total=total,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. POST /api/admin/users — 创建管理员
|
||||
# =============================================================================
|
||||
@router.post("", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def create_admin_user(
|
||||
body: AdminUserCreateRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建管理员用户。
|
||||
|
||||
需要 super_admin 角色。
|
||||
|
||||
Args:
|
||||
body: 创建请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的用户信息
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
agent = await service.create_admin_user(
|
||||
user_id=body.user_id,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
password=body.password,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"创建管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "创建管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. GET /api/admin/users/{id} — 获取管理员详情
|
||||
# =============================================================================
|
||||
@router.get("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def get_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户详情。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户详情
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
agent = await service.get_user_by_id(id)
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. PUT /api/admin/users/{id} — 更新管理员
|
||||
# =============================================================================
|
||||
@router.put("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def update_admin_user(
|
||||
id: str,
|
||||
body: AdminUserUpdateRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新管理员用户。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
- admin 角色只能更新普通 admin
|
||||
- super_admin 角色可以更新所有用户
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
body: 更新请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的用户信息
|
||||
"""
|
||||
# 权限检查:非 super_admin 不能修改 super_admin
|
||||
if "super_admin" not in current_user.roles:
|
||||
target = await AdminUserService(db).get_user_by_id(id)
|
||||
if target and target.role == "super_admin":
|
||||
raise AppException(ErrorCode.FORBIDDEN, "无法修改超级管理员")
|
||||
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
agent = await service.update_admin_user(
|
||||
id=id,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data=AdminUserResponse(
|
||||
id=agent.id,
|
||||
user_id=agent.user_id,
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
is_active=agent.status == "online",
|
||||
mfa_enabled=agent.mfa_enabled,
|
||||
mfa_bound_at=agent.mfa_bound_at,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
).model_dump())
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"更新管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "更新管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. DELETE /api/admin/users/{id} — 删除管理员
|
||||
# =============================================================================
|
||||
@router.delete("/{id}", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def delete_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除管理员用户。
|
||||
|
||||
需要 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
删除结果
|
||||
"""
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
await service.delete_admin_user(id)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={"message": "删除成功"})
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"删除管理员失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "删除管理员失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
@router.post("/{id}/reset-password", response_model=None)
|
||||
@require_role("admin")
|
||||
async def reset_password(
|
||||
id: str,
|
||||
body: AdminUserResetPasswordRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""重置管理员密码。
|
||||
|
||||
需要 admin 或 super_admin 角色。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
body: 重置请求
|
||||
current_user: 当前用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
重置结果
|
||||
"""
|
||||
# 权限检查:非 super_admin 不能重置 super_admin 的密码
|
||||
if "super_admin" not in current_user.roles:
|
||||
target = await AdminUserService(db).get_user_by_id(id)
|
||||
if target and target.role == "super_admin":
|
||||
raise AppException(ErrorCode.FORBIDDEN, "无法重置超级管理员密码")
|
||||
|
||||
service = AdminUserService(db)
|
||||
|
||||
try:
|
||||
await service.reset_password(id, body.new_password)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={"message": "密码重置成功"})
|
||||
|
||||
except AppException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"重置密码失败: {e}")
|
||||
raise AppException(ErrorCode.INTERNAL_ERROR, "重置密码失败")
|
||||
+296
-166
@@ -18,7 +18,6 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
import redis.asyncio as aioredis
|
||||
import bcrypt # P1 修复: 直接使用 bcrypt 库替代 passlib
|
||||
@@ -31,11 +30,13 @@ 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.dependencies import get_current_user, require_role, dep_wecom_service
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.mfa_service import MFAService
|
||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
from app.utils.error_codes import ErrorCode
|
||||
|
||||
# 速率限制器实例(与 main.py 共享同一配置)
|
||||
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
|
||||
@@ -176,6 +177,8 @@ async def agent_login(
|
||||
# - 企微验证失败(用户不存在) → 拒绝登录
|
||||
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
|
||||
wecom_verified = False
|
||||
# 默认空头像,企微验证成功时覆盖;确保在 wecom 不可达(降级)时仍可安全引用
|
||||
avatar = ""
|
||||
try:
|
||||
redis_client_verify = _get_redis()
|
||||
try:
|
||||
@@ -187,6 +190,14 @@ async def agent_login(
|
||||
real_name = user_info.get("name", "")
|
||||
if real_name:
|
||||
body.name = real_name
|
||||
# 【P1-02】每次坐席登录也强制更新头像(与 H5 登录保持一致,统一走 avatar_service)
|
||||
avatar = user_info.get("avatar", "")
|
||||
if avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client_verify, body.user_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): user_id={body.user_id}, error={e}")
|
||||
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
|
||||
finally:
|
||||
try:
|
||||
@@ -211,30 +222,24 @@ async def agent_login(
|
||||
if not existing_agent:
|
||||
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
|
||||
raise AppException(
|
||||
1003,
|
||||
ErrorCode.AUTH_TOKEN_INVALID,
|
||||
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
|
||||
)
|
||||
logger.warning(
|
||||
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
|
||||
)
|
||||
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码
|
||||
if existing_agent and existing_agent.password_hash:
|
||||
# P0 修复: 降级放行时,如果 agent 已设置密码则必须验证本地密码
|
||||
if existing_agent:
|
||||
if existing_agent.password_hash is None:
|
||||
# 已注册坐席但未设置密码,要求先设置密码
|
||||
raise AppException(
|
||||
ErrorCode.AUTH_PASSWORD_REQUIRED,
|
||||
"首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码"
|
||||
)
|
||||
if not body.password:
|
||||
raise AppException(1011, "请输入本地密码")
|
||||
raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "请输入本地密码")
|
||||
if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')):
|
||||
raise AppException(1011, "本地密码错误")
|
||||
|
||||
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
|
||||
# 检查是否需要本地密码验证
|
||||
local_password_verified = False
|
||||
if body.password and agent and agent.password_hash:
|
||||
# 验证本地密码
|
||||
if bcrypt.checkpw(body.password.encode('utf-8'), agent.password_hash.encode('utf-8')):
|
||||
local_password_verified = True
|
||||
logger.info(f"本地密码验证通过: user_id={body.user_id}")
|
||||
else:
|
||||
# 本地密码错误,拒绝登录
|
||||
raise AppException(1011, "本地密码错误")
|
||||
raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "本地密码错误")
|
||||
|
||||
# 1. 查找或创建坐席记录
|
||||
stmt = select(Agent).where(Agent.user_id == body.user_id)
|
||||
@@ -262,21 +267,56 @@ async def agent_login(
|
||||
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:
|
||||
# 2. MFA 二次验证(三端认证重构 AUTH-04/AUTH-05)
|
||||
# 决策3(AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
|
||||
# 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。
|
||||
# 决策4(AUTH-05):区分两种 OTP 状态——
|
||||
# - mfa_enabled=True → 已绑定,需验证 OTP 动态码
|
||||
# - mfa_enabled=False → 未绑定,引导首次绑定流程
|
||||
if agent.mfa_enabled:
|
||||
# 已绑定 OTP → 要求验证或校验码
|
||||
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,
|
||||
"role": agent.role, # 必须包含role字段,供前端校验权限
|
||||
})
|
||||
else:
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
# 验证 OTP 码(复用 MFAService 统一校验逻辑)
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
else:
|
||||
# 未绑定 OTP → 引导首次绑定(AUTH-05)
|
||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
from app.dependencies import get_redis
|
||||
|
||||
redis_client = await get_redis()
|
||||
token_service = TokenService(redis_client)
|
||||
# BUGFIX: 从 UserRole 表查询真实角色,而非硬编码 ["agent"]
|
||||
role_service = RoleMappingService(db)
|
||||
roles = await role_service.get_user_roles(agent.user_id)
|
||||
if not roles:
|
||||
roles = ["agent"] # 无角色时默认 fallback
|
||||
bind_token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name,
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="agent_pending_otp",
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"require_otp_bind": True,
|
||||
"message": "首次登录请先绑定OTP二次验证",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
"token": bind_token,
|
||||
})
|
||||
|
||||
# 3. 生成随机 token(使用统一格式)
|
||||
from app.services.token_service import TokenService
|
||||
@@ -296,6 +336,7 @@ async def agent_login(
|
||||
employee_id=body.user_id,
|
||||
name=body.name,
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="agent",
|
||||
)
|
||||
|
||||
@@ -402,143 +443,6 @@ async def list_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)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 本地密码管理接口(P0-#5)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -571,9 +475,11 @@ async def update_agent_password(
|
||||
# 如果已有旧密码,验证旧密码
|
||||
if agent.password_hash:
|
||||
if not body.old_password:
|
||||
raise AppException(1012, "请输入旧密码")
|
||||
# 2026-06-15 修复: 改用专用 ErrorCode,避免与登录 1012 冲突
|
||||
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_REQUIRED, "请输入旧密码")
|
||||
if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')):
|
||||
raise AppException(1013, "旧密码错误")
|
||||
# 2026-06-15 修复: 改用专用 ErrorCode
|
||||
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_WRONG, "旧密码错误")
|
||||
|
||||
# 设置新密码
|
||||
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
@@ -590,3 +496,227 @@ async def update_agent_password(
|
||||
except Exception as e:
|
||||
logger.error(f"密码更新异常: {e}", exc_info=True)
|
||||
raise AppException(1014, f"密码更新失败: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 忘记密码 - 企微扫码重置
|
||||
# ============================================================================
|
||||
|
||||
class AgentPasswordResetByWecom(BaseModel):
|
||||
"""通过企微扫码重置密码请求 Schema"""
|
||||
code: str = Field(..., description="企微OAuth2授权码")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
|
||||
|
||||
@router.post("/agents/password/reset-by-wecom")
|
||||
async def reset_password_by_wecom(
|
||||
body: AgentPasswordResetByWecom,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wecom_service: WecomService = Depends(dep_wecom_service),
|
||||
):
|
||||
"""通过企微扫码验证后重置密码。
|
||||
|
||||
适用于坐席忘记原密码的情况。通过企微OAuth2扫码验证身份后,
|
||||
无需旧密码即可重置密码。
|
||||
|
||||
#91 新增端点。
|
||||
|
||||
Args:
|
||||
body.code: 企微OAuth2授权码
|
||||
body.new_password: 新密码(6-128位)
|
||||
|
||||
Returns:
|
||||
Dict: 重置结果
|
||||
"""
|
||||
try:
|
||||
# 1. 用 code 换取员工身份
|
||||
user_info = await wecom_service.get_oauth_user_info(body.code)
|
||||
employee_id = user_info.get("userid", "")
|
||||
|
||||
if not employee_id:
|
||||
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
|
||||
|
||||
# 2. 查询该员工是否是坐席
|
||||
from sqlalchemy import select
|
||||
from app.models.agent import Agent
|
||||
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if not agent:
|
||||
raise AppException(1015, "该员工不是坐席,无法重置密码")
|
||||
|
||||
# 3. 重置密码
|
||||
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"密码已通过企微扫码重置: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={"message": "密码已重置"})
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"密码重置异常: {e}", exc_info=True)
|
||||
raise AppException(1016, f"密码重置失败: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 企微 OAuth2 一键登录(坐席端)
|
||||
# ============================================================================
|
||||
|
||||
import urllib.parse
|
||||
import secrets as secrets_module
|
||||
|
||||
|
||||
def _build_agent_oauth_url(redirect_uri: str) -> str:
|
||||
"""构建坐席端企微OAuth2授权URL。
|
||||
|
||||
文档: https://developer.work.weixin.qq.com/document/path/91022
|
||||
"""
|
||||
params = {
|
||||
"appid": settings.wecom_corp_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base", # 静默授权
|
||||
"state": "agent_login", # 标记为坐席登录
|
||||
}
|
||||
# 如果有 agentid 也加上
|
||||
if getattr(settings, "wecom_agent_id", None):
|
||||
params["agentid"] = str(settings.wecom_agent_id)
|
||||
|
||||
query = urllib.parse.urlencode(params)
|
||||
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
||||
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/agents/oauth/authorize")
|
||||
async def get_oauth_authorize_url(
|
||||
redirect_uri: str = Query(None, description="OAuth回调地址(可选,默认坐席端地址)"),
|
||||
):
|
||||
"""获取企微OAuth2授权URL(JSON格式,供前端跳转)。
|
||||
|
||||
前端调用此接口获取授权URL,然后自行跳转到企微授权页。
|
||||
授权成功后企微会携带 code 回调到此接口的 redirect_uri。
|
||||
|
||||
Args:
|
||||
redirect_uri: 授权成功后的回调地址(可选)
|
||||
默认: https://itsupport.servyou.com.cn/itagent/
|
||||
|
||||
Returns:
|
||||
JSON: { code: 0, data: { authorize_url: "https://open.weixin.qq.com/..." } }
|
||||
"""
|
||||
# 确定回调地址
|
||||
if redirect_uri:
|
||||
# 前端传入的回调地址
|
||||
pass
|
||||
else:
|
||||
# 默认回调地址:坐席端首页
|
||||
redirect_uri = "https://itsupport.servyou.com.cn/itagent/"
|
||||
|
||||
# 编码回调地址
|
||||
encoded_redirect = urllib.parse.quote(redirect_uri, safe='')
|
||||
|
||||
# 构建授权URL
|
||||
authorize_url = _build_agent_oauth_url(redirect_uri)
|
||||
|
||||
logger.info(f"生成坐席端OAuth授权URL: redirect_uri={redirect_uri}")
|
||||
|
||||
return success_response(data={
|
||||
"authorize_url": authorize_url,
|
||||
"redirect_uri": redirect_uri,
|
||||
})
|
||||
|
||||
|
||||
# OAuth 回调请求模型
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description="企微授权码")
|
||||
state: str = Field(default="agent_login", description="state参数")
|
||||
|
||||
|
||||
@router.post("/agents/oauth/callback")
|
||||
async def oauth_callback(
|
||||
body: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""企微OAuth2回调处理(坐席端)。
|
||||
|
||||
用授权码换取员工ID,验证坐席身份,生成登录token。
|
||||
|
||||
Args:
|
||||
body: { code: "xxx", state: "agent_login" }
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
JSON: { code: 0, data: { token, user_id, name, roles } }
|
||||
"""
|
||||
code = body.code
|
||||
state = body.state
|
||||
|
||||
if not code:
|
||||
raise AppException(2007, "授权码不能为空")
|
||||
|
||||
# 1. 用 code 换取员工身份
|
||||
wecom_service = WecomService()
|
||||
try:
|
||||
oauth_info = await wecom_service.get_oauth_user_info(code)
|
||||
user_id = oauth_info.get("userid", "")
|
||||
|
||||
if not user_id:
|
||||
raise AppException(2007, "OAuth授权失败:未获取到员工ID")
|
||||
except Exception as e:
|
||||
logger.error(f"企微OAuth换取userid失败: {e}")
|
||||
raise AppException(2007, f"OAuth授权失败: {str(e)}")
|
||||
|
||||
# 2. 获取员工详细信息(包含姓名)
|
||||
employee_name = ""
|
||||
try:
|
||||
detail = await wecom_service.get_user_info(user_id)
|
||||
employee_name = detail.get("name", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取员工详细信息失败: user_id={user_id}, error={e}")
|
||||
|
||||
# 3. 验证是否为坐席
|
||||
stmt = select(Agent).where(Agent.user_id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
raise AppException(2008, f"您不是坐席,无法通过企业微信登录")
|
||||
|
||||
# 4. 生成登录token
|
||||
token = secrets_module.token_urlsafe(32)
|
||||
redis_client = _get_redis()
|
||||
|
||||
if redis_client:
|
||||
try:
|
||||
# 存储 token -> agent信息(JSON格式)
|
||||
token_data = {
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"roles": [agent.role],
|
||||
"login_source": "agent_oauth",
|
||||
}
|
||||
import json as json_module
|
||||
await redis_client.setex(
|
||||
f"user:token:{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
json_module.dumps(token_data),
|
||||
)
|
||||
|
||||
# 记录登录日志
|
||||
logger.info(f"企微OAuth登录成功: user_id={user_id}, name={employee_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Token存储Redis失败: {e}")
|
||||
raise AppException(1003, "登录失败,请重试")
|
||||
|
||||
return success_response(data={
|
||||
"token": token,
|
||||
"user_id": agent.user_id,
|
||||
"name": employee_name or agent.name,
|
||||
"role": agent.role,
|
||||
"require_otp": agent.mfa_secret is not None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# =============================================================================
|
||||
# IT智能服务台 — 审批流程 API
|
||||
# =============================================================================
|
||||
# 说明:提供审批模板管理和API提交功能
|
||||
# - 模板详情获取
|
||||
# - API提交审批申请
|
||||
# - 审批状态回调处理
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.token_manager import ApprovalTokenManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Redis客户端(依赖注入)
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""获取Redis客户端依赖"""
|
||||
from app.main import redis_client
|
||||
return redis_client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 审批模板配置(从环境变量读取)
|
||||
# =============================================================================
|
||||
# 环境变量:
|
||||
# APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID
|
||||
# APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID
|
||||
|
||||
APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "")
|
||||
APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "")
|
||||
|
||||
# 动态构建审批模板配置
|
||||
APPROVAL_TEMPLATES = {}
|
||||
|
||||
if APPROVAL_TEMPLATE_RESOURCE:
|
||||
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = {
|
||||
"id": APPROVAL_TEMPLATE_RESOURCE,
|
||||
"name": "资源申请",
|
||||
"type": "jump",
|
||||
"keywords": ["申请资源", "要资源", "申请"],
|
||||
}
|
||||
|
||||
if APPROVAL_TEMPLATE_DEVICE:
|
||||
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
|
||||
"id": APPROVAL_TEMPLATE_DEVICE,
|
||||
"name": "设备申请",
|
||||
"type": "api",
|
||||
"keywords": ["申请设备", "要设备", "电脑", "笔记本"],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema 定义
|
||||
# =============================================================================
|
||||
|
||||
class ApprovalTemplateResponse(BaseModel):
|
||||
"""审批模板响应"""
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
keywords: list[str]
|
||||
|
||||
|
||||
class ApprovalJumpRequest(BaseModel):
|
||||
"""跳转审批请求"""
|
||||
template_id: str
|
||||
employee_id: Optional[str] = None
|
||||
|
||||
|
||||
class ApprovalJumpResponse(BaseModel):
|
||||
"""跳转审批响应"""
|
||||
url: str
|
||||
template_name: str
|
||||
|
||||
|
||||
class ApprovalContentItem(BaseModel):
|
||||
"""审批表单控件内容"""
|
||||
control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc.
|
||||
id: str # 控件ID
|
||||
value: dict # 控件值
|
||||
|
||||
|
||||
class ApprovalSubmitRequest(BaseModel):
|
||||
"""API提交审批请求"""
|
||||
template_id: str
|
||||
employee_id: str # 申请人userid
|
||||
contents: list[ApprovalContentItem] # 表单内容
|
||||
use_template_approver: int = 1 # 1-使用模板预设流程
|
||||
|
||||
|
||||
class ApprovalSubmitResponse(BaseModel):
|
||||
"""API提交审批响应"""
|
||||
sp_no: str # 审批单号
|
||||
template_name: str
|
||||
|
||||
|
||||
class ApprovalCallbackRequest(BaseModel):
|
||||
"""审批回调请求(XML解析后的模型)"""
|
||||
sp_no: str
|
||||
sp_name: str
|
||||
template_id: str
|
||||
apply_time: int
|
||||
applyer_userid: str
|
||||
sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
|
||||
status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 企微API调用辅助函数
|
||||
# =============================================================================
|
||||
|
||||
async def get_approval_token(redis: aioredis.Redis) -> str:
|
||||
"""获取审批应用access_token"""
|
||||
manager = ApprovalTokenManager(redis)
|
||||
return await manager.get_token()
|
||||
|
||||
|
||||
async def get_template_detail(access_token: str, template_id: str) -> dict:
|
||||
"""获取审批模板详情
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail
|
||||
|
||||
返回模板内的控件构成及控件ID
|
||||
"""
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail"
|
||||
params = {"access_token": access_token}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
|
||||
response = await client.post(url, params=params, json={"template_id": template_id})
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(f"获取模板详情失败: {result.get('errmsg')}")
|
||||
raise Exception(f"获取模板详情失败: {result.get('errmsg')}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def submit_approval_api(
|
||||
access_token: str,
|
||||
template_id: str,
|
||||
creator_userid: str,
|
||||
contents: list[dict],
|
||||
use_template_approver: int = 1
|
||||
) -> dict:
|
||||
"""提交审批申请
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent
|
||||
|
||||
Args:
|
||||
access_token: 审批应用access_token
|
||||
template_id: 模板ID
|
||||
creator_userid: 申请人userid
|
||||
contents: 表单控件内容列表
|
||||
use_template_approver: 1-使用模板预设流程
|
||||
|
||||
Returns:
|
||||
{"sp_no": "审批单号"}
|
||||
"""
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent"
|
||||
params = {"access_token": access_token}
|
||||
|
||||
payload = {
|
||||
"creator_userid": creator_userid,
|
||||
"template_id": template_id,
|
||||
"use_template_approver": use_template_approver,
|
||||
"apply_data": {
|
||||
"contents": contents
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=30.0)) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(f"提交审批失败: {result.get('errmsg')}")
|
||||
raise Exception(f"提交审批失败: {result.get('errmsg')}")
|
||||
|
||||
return {"sp_no": result.get("sp_no")}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API 端点
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
|
||||
async def get_approval_templates():
|
||||
"""获取所有审批模板列表"""
|
||||
return list(APPROVAL_TEMPLATES.values())
|
||||
|
||||
|
||||
@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse)
|
||||
async def get_approval_template(template_id: str):
|
||||
"""获取指定审批模板详情"""
|
||||
if template_id not in APPROVAL_TEMPLATES:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return APPROVAL_TEMPLATES[template_id]
|
||||
|
||||
|
||||
@router.get("/approval/templates/{template_id}/detail")
|
||||
async def get_template_full_detail(
|
||||
template_id: str,
|
||||
redis: aioredis.Redis = Depends(get_redis)
|
||||
):
|
||||
"""获取审批模板完整详情(控件结构)"""
|
||||
if template_id not in APPROVAL_TEMPLATES:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
try:
|
||||
token = await get_approval_token(redis)
|
||||
detail = await get_template_detail(token, template_id)
|
||||
return detail
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/approval/jump", response_model=ApprovalJumpResponse)
|
||||
async def create_approval_jump(request: ApprovalJumpRequest):
|
||||
"""生成跳转审批链接"""
|
||||
template = APPROVAL_TEMPLATES.get(request.template_id)
|
||||
if not template:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
if template["type"] != "jump":
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
|
||||
|
||||
# 生成跳转URL(企微审批链接格式)
|
||||
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
|
||||
|
||||
return ApprovalJumpResponse(
|
||||
url=jump_url,
|
||||
template_name=template["name"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
|
||||
async def submit_approval(
|
||||
request: ApprovalSubmitRequest,
|
||||
redis: aioredis.Redis = Depends(get_redis)
|
||||
):
|
||||
"""API提交审批申请"""
|
||||
template = APPROVAL_TEMPLATES.get(request.template_id)
|
||||
if not template:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
if template["type"] != "api":
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="该模板不支持API提交")
|
||||
|
||||
try:
|
||||
# 1. 获取审批token
|
||||
token = await get_approval_token(redis)
|
||||
|
||||
# 2. 转换contents格式
|
||||
contents = [item.model_dump() for item in request.contents]
|
||||
|
||||
# 3. 提交审批
|
||||
result = await submit_approval_api(
|
||||
access_token=token,
|
||||
template_id=request.template_id,
|
||||
creator_userid=request.employee_id,
|
||||
contents=contents,
|
||||
use_template_approver=request.use_template_approver
|
||||
)
|
||||
|
||||
return ApprovalSubmitResponse(
|
||||
sp_no=result["sp_no"],
|
||||
template_name=template["name"],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/approval/callback")
|
||||
async def approval_callback(
|
||||
sp_no: str = Query(...),
|
||||
sp_name: str = Query(...),
|
||||
template_id: str = Query(...),
|
||||
apply_time: int = Query(...),
|
||||
applyer_userid: str = Query(...),
|
||||
sp_status: int = Query(...),
|
||||
status_change_event: int = Query(...)
|
||||
):
|
||||
"""审批状态变化回调处理
|
||||
|
||||
对应企微审批回调事件: sys_approval_change
|
||||
|
||||
状态变化类型 (status_change_event):
|
||||
1 - 提单
|
||||
2 - 同意
|
||||
3 - 驳回
|
||||
4 - 转审
|
||||
5 - 催办
|
||||
6 - 撤销
|
||||
8 - 通过后撤销
|
||||
10 - 添加备注
|
||||
|
||||
审批单状态 (sp_status):
|
||||
1 - 审批中
|
||||
2 - 已通过
|
||||
3 - 已驳回
|
||||
4 - 已撤销
|
||||
6 - 通过后撤销
|
||||
7 - 已删除
|
||||
10 - 已支付
|
||||
"""
|
||||
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
|
||||
|
||||
# TODO: 根据业务需求处理审批状态变化
|
||||
# 例如:
|
||||
# - 审批通过后,更新IT服务台待办状态
|
||||
# - 审批驳回后,通知申请人
|
||||
# - 审批撤销后,关闭相关工单
|
||||
|
||||
event_map = {
|
||||
1: "submitted",
|
||||
2: "approved",
|
||||
3: "rejected",
|
||||
4: "transferred",
|
||||
5: "reminded",
|
||||
6: "revoked",
|
||||
8: "revoked_after_approved",
|
||||
10: "commented"
|
||||
}
|
||||
|
||||
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
|
||||
logger.info(f"审批事件类型: {event_type}")
|
||||
|
||||
return {"errcode": 0, "errmsg": "ok"}
|
||||
|
||||
|
||||
@router.get("/approval/keywords")
|
||||
async def get_approval_keywords():
|
||||
"""获取所有审批关键词(用于前端关键词检测)"""
|
||||
keywords = []
|
||||
for template in APPROVAL_TEMPLATES.values():
|
||||
for kw in template["keywords"]:
|
||||
keywords.append({
|
||||
"keyword": kw,
|
||||
"template_id": template["id"],
|
||||
"template_name": template["name"],
|
||||
"type": template["type"],
|
||||
})
|
||||
return keywords
|
||||
@@ -0,0 +1,207 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 独立审批队列 API(Tier1 新增)
|
||||
# =============================================================================
|
||||
# 说明:独立审批队列接口,管理超出会话上下文的待审批提案。
|
||||
# 1. GET /queued — 获取队列中的提案列表
|
||||
# 2. GET /queued/stats — 获取队列统计
|
||||
# 3. POST /queued/{id}/dequeue-approve — 队列中审批通过提案
|
||||
#
|
||||
# D7 硬约束:
|
||||
# - 提案默认 status=pending,不自动 applied
|
||||
# - 未处理的进入独立队列(queued)
|
||||
# - 72 小时超时 → expired
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import KnowledgeSuggestionResponse
|
||||
from app.schemas.enums import SuggestionStatusEnum
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取独立队列列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued
|
||||
@router.get("/queued")
|
||||
@require_admin
|
||||
async def list_queued_suggestions(
|
||||
status: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选状态:pending/queued(不传则返回 pending+queued)",
|
||||
),
|
||||
audience: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选受众:employee_quick_reply/engineer_workguide",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取独立队列中的提案列表。
|
||||
|
||||
默认返回 status=pending 和 status=queued 的提案。
|
||||
支持按 audience 筛选和分页。
|
||||
|
||||
- **status**: 筛选状态(pending/queued)
|
||||
- **audience**: 按受众类型筛选
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 构建查询:pending 或 queued 状态的提案
|
||||
target_statuses = [status] if status else [
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
]
|
||||
|
||||
stmt = (
|
||||
select(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
.order_by(
|
||||
# 按入队时间降序(queued 的提案在前),然后按创建时间
|
||||
KnowledgeSuggestion.queued_at.desc().nullslast(),
|
||||
KnowledgeSuggestion.created_at.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
suggestions = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
KnowledgeSuggestionResponse.model_validate(s) for s in suggestions
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取队列统计(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued/stats
|
||||
@router.get("/queued/stats")
|
||||
@require_admin
|
||||
async def get_queue_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取独立审批队列统计信息。
|
||||
|
||||
返回:
|
||||
- queued_total: 队列中提案数
|
||||
- pending_total: 待审核提案数
|
||||
- by_audience: 按受众分组统计
|
||||
- by_source_type: 按来源分组统计
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
stats = await service.get_queue_stats(db)
|
||||
|
||||
# 补充按来源分组统计
|
||||
source_stats_stmt = (
|
||||
select(
|
||||
KnowledgeSuggestion.source_type,
|
||||
func.count(),
|
||||
)
|
||||
.where(
|
||||
KnowledgeSuggestion.status.in_([
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
])
|
||||
)
|
||||
.group_by(KnowledgeSuggestion.source_type)
|
||||
)
|
||||
source_result = await db.execute(source_stats_stmt)
|
||||
by_source_type = {row[0]: row[1] for row in source_result.fetchall()}
|
||||
|
||||
stats["by_source_type"] = by_source_type
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": stats,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve
|
||||
@router.post("/queued/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过提案。
|
||||
|
||||
流程:queued → approved → applied → graph_synced(同 approve_suggestion)。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从独立队列审批通过: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 审计日志 API (v0.7.1 task #89)
|
||||
# =============================================================================
|
||||
# 说明: 审计日志只读端点,给 auditor / admin 用
|
||||
# 权限要求: audit_log:read:all (由 RBAC 装饰器校验)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_permission, UserInfo
|
||||
from app.database import get_db
|
||||
from app.services.audit_log_service import list_audit_logs
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/audit-logs", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
@require_permission("audit_log", "read", "all")
|
||||
async def get_audit_logs(
|
||||
employee_id: Optional[str] = Query(None, description="按操作人过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
admin: UserInfo = None, # 由 require_permission 注入(签名合并)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询审计日志(分页)。
|
||||
|
||||
权限: 需要 audit_log:read:all (admin / auditor 角色拥有)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 items/total/page/page_size
|
||||
"""
|
||||
result = await list_audit_logs(
|
||||
db,
|
||||
employee_id=employee_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"employee_id": log.employee_id,
|
||||
"action": log.action,
|
||||
"resource": log.resource,
|
||||
"resource_id": log.resource_id,
|
||||
"details": log.details,
|
||||
"result": log.result,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in result["items"]
|
||||
],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 扫码登录 API
|
||||
# =============================================================================
|
||||
# 说明:扫码登录是 Phase 1.1 的核心功能,用于替代坐席端"用户名密码+企微
|
||||
# OAuth"双因素登录,提供"用企微 App 扫一扫登录浏览器坐席端"的体验。
|
||||
#
|
||||
# 完整流程:
|
||||
# ┌─────────┐ create ┌─────────────┐ scan ┌──────────┐
|
||||
# │ 浏览器 │ ───────→ │ ticket(120s)│ ←───── │ 企微 App │
|
||||
# │ 前端 │ ←─────── │ +OAuth URL │ OAuth │ 扫码授权 │
|
||||
# └─────────┘ qrcode_url └─────────────┘ code └──────────┘
|
||||
# │ │ │
|
||||
# │ poll │ scan │
|
||||
# │ waiting/scanned │ 写 scan:{ticket} │
|
||||
# │ ↓ │
|
||||
# │ ┌────────────────┐ │
|
||||
# │ │ 已登录坐席(企微)│ confirm │
|
||||
# │ │ 点"确认登录"按钮 │ ────────→ │
|
||||
# │ └────────────────┘ │
|
||||
# │ │ │
|
||||
# │ poll │ confirm │
|
||||
# │ confirmed+token │ 写 confirm:{ticket} │
|
||||
# ↓ ↓ │
|
||||
# 拿到 token,跳坐席端主页 │
|
||||
#
|
||||
# 端点列表(4 个):
|
||||
# POST /api/auth_qrcode/create — 浏览器前端生成 ticket
|
||||
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
||||
# POST /api/auth_qrcode/scan — 企微 OAuth2 回调(接收 code)
|
||||
# POST /api/auth_qrcode/confirm — 当前登录坐席点确认
|
||||
#
|
||||
# 鉴权说明:
|
||||
# - create / scan / poll: 无需登录(浏览器刚加载登录页,用户未登录)
|
||||
# - confirm: 需要已登录坐席点确认(角色: agent / admin)
|
||||
# - 票据状态全部存 Redis,TTL 到期自动失效,无 DB 表
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_redis, get_current_user, UserInfo
|
||||
from app.schemas.qrcode import (
|
||||
QrcodeConfirmRequest,
|
||||
QrcodeConfirmResponse,
|
||||
QrcodeCreateResponse,
|
||||
QrcodePollResponse,
|
||||
QrcodeScanRequest,
|
||||
QrcodeScanResponse,
|
||||
)
|
||||
from app.services.qrcode_service import QrcodeService
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
# prefix="/auth_qrcode" + tags=["扫码登录"] 用于 Swagger 分组
|
||||
router = APIRouter(prefix="/auth_qrcode", tags=["扫码登录"])
|
||||
|
||||
|
||||
def _get_qrcode_service(redis_client: aioredis.Redis) -> QrcodeService:
|
||||
"""工厂函数: 构造扫码登录业务服务。
|
||||
|
||||
拆出来便于测试时 monkey-patch,以及后续接入 DI。
|
||||
"""
|
||||
return QrcodeService(redis_client)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/create — 创建扫码登录票据
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/create", response_model=None)
|
||||
async def create_qrcode(
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""创建扫码登录票据。
|
||||
|
||||
无需鉴权(用户尚未登录,正在登录页)。
|
||||
返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 字段是 QrcodeCreateResponse
|
||||
"""
|
||||
try:
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.create_ticket()
|
||||
|
||||
return success_response(data={
|
||||
"ticket": result["ticket"],
|
||||
"qrcode_url": result["qrcode_url"],
|
||||
"qrcode_png_base64": result["qrcode_png_base64"],
|
||||
"expires_in": result["expires_in"],
|
||||
"expires_at": result["expires_at"].isoformat(),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建扫码票据异常: {e}", exc_info=True)
|
||||
raise AppException(1005, f"创建扫码票据失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/poll/{ticket}", response_model=None)
|
||||
async def poll_qrcode(
|
||||
ticket: str = Path(..., description="扫码登录票据"),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""轮询扫码状态。
|
||||
|
||||
无需鉴权(浏览器未登录态访问)。
|
||||
|
||||
状态机:
|
||||
- waiting: ticket 有效,等待扫码
|
||||
- scanned: 已扫码,等待 confirm
|
||||
- confirmed: 已确认,返回 token
|
||||
- expired: ticket 过期/不存在
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 字段是 QrcodePollResponse
|
||||
"""
|
||||
try:
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.get_poll_state(ticket)
|
||||
|
||||
return success_response(data={
|
||||
"status": result["status"],
|
||||
"employee_id": result.get("employee_id"),
|
||||
"name": result.get("name"),
|
||||
"token": result.get("token"),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True)
|
||||
raise AppException(1005, f"轮询扫码状态失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET|POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
||||
# --------------------------------------------------------------------------
|
||||
@router.api_route("/scan", methods=["GET", "POST"], response_model=None)
|
||||
async def scan_qrcode(
|
||||
body: Optional[QrcodeScanRequest] = None,
|
||||
ticket: Optional[str] = Query(None, description="扫码登录票据(兼容旧参数名)"),
|
||||
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
||||
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""处理企微 OAuth2 扫码回调。
|
||||
|
||||
企微 OAuth2 标准回调走 **GET** 带 query 参数 `?code=xxx&state=<ticket>`,
|
||||
本端点同时支持 GET 和 POST(POST 兼容内部调用 / 旧前端代码)。
|
||||
|
||||
GET 模式 (企微 OAuth2 标准回调):
|
||||
- ticket ← query.state
|
||||
- code ← query.code
|
||||
- 自动 302 跳转到 /itdesk/ 或 /itadmin/ 或 /itagent/(按角色)
|
||||
POST 模式 (内部调用):
|
||||
- ticket ← body.ticket
|
||||
- code ← body.code
|
||||
|
||||
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
|
||||
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
|
||||
|
||||
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
|
||||
"""
|
||||
try:
|
||||
# 1. 解析参数:POST 用 body,GET 用 query
|
||||
if body is not None:
|
||||
final_ticket = body.ticket
|
||||
final_code = body.code
|
||||
else:
|
||||
# 优先用 state(企微 OAuth 标准),回退到 ticket(兼容旧调用)
|
||||
final_ticket = state or ticket
|
||||
final_code = code
|
||||
|
||||
if not final_ticket or not final_code:
|
||||
logger.warning(f"扫码参数缺失: ticket={final_ticket!r}, code={final_code!r}")
|
||||
raise AppException(1000, "缺少 ticket 或 code 参数")
|
||||
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
||||
|
||||
# ==========================================================================
|
||||
# 扫码后自动确认(auto-confirm)
|
||||
# ==========================================================================
|
||||
# 原设计:requester 需已登录坐席调 /confirm 来授权新登录
|
||||
# 问题:首次登录时电脑端无人登录,没有合法 current_user 可调 confirm
|
||||
# 修正:扫码即确认,直接为扫码的企微用户签发 token
|
||||
# ==========================================================================
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 获取用户的真实角色(而非写死 agent)
|
||||
role_service = RoleMappingService(db)
|
||||
user_roles = await role_service.get_user_roles(result["employee_id"])
|
||||
logger.info(
|
||||
f"扫码登录角色: employee_id={result['employee_id']}, "
|
||||
f"roles={user_roles}"
|
||||
)
|
||||
|
||||
auto_token = await token_service.create_token(
|
||||
employee_id=result["employee_id"],
|
||||
name=result["name"],
|
||||
roles=user_roles,
|
||||
avatar=result.get("avatar", ""),
|
||||
login_source="qrcode_scan",
|
||||
)
|
||||
|
||||
confirm_payload = {
|
||||
"token": auto_token,
|
||||
"confirmed_at": datetime.now().isoformat(),
|
||||
"roles": user_roles,
|
||||
"employee_id": result["employee_id"],
|
||||
"name": result["name"],
|
||||
}
|
||||
CONFIRM_TTL = 60
|
||||
await redis_client.setex(
|
||||
f"qrcode:confirm:{final_ticket}",
|
||||
CONFIRM_TTL,
|
||||
json.dumps(confirm_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"扫码自动确认: ticket={final_ticket[:8]}..., "
|
||||
f"employee_id={result['employee_id']}, name={result['name']}"
|
||||
)
|
||||
|
||||
# GET 请求(企微 OAuth 回调)→ 已自动确认,显示成功页 + 自动关闭
|
||||
from fastapi.responses import HTMLResponse
|
||||
if final_code is not None and final_ticket is not None and body is None:
|
||||
user_name = result.get('name', '')
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录成功 - IT智能服务台</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
||||
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 40px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
|
||||
.check {{ width: 64px; height: 64px; margin: 0 auto 16px; }}
|
||||
.title {{ color: #1f2937; font-size: 24px; font-weight: 600; margin-bottom: 8px; }}
|
||||
.subtitle {{ color: #6b7280; font-size: 14px; margin-bottom: 24px; }}
|
||||
.status {{ display: inline-flex; align-items: center; gap: 6px; background: #dcfce7; color: #166534; padding: 10px 20px; border-radius: 50px; font-size: 14px; font-weight: 500; }}
|
||||
.footer {{ margin-top: 24px; color: #9ca3af; font-size: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<svg class="check" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="32" cy="32" r="30" fill="#07C160" stroke="#06AD56" stroke-width="4"/>
|
||||
<path d="M20 32l8 8 16-16" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<h1 class="title">登录成功</h1>
|
||||
<div class="status">已自动确认登录</div>
|
||||
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
|
||||
<div class="footer">页面可安全关闭 · 税友集团</div>
|
||||
</div>
|
||||
<script>
|
||||
// 尝试关闭企微 WebView(如果支持的话)
|
||||
setTimeout(function() {{
|
||||
if (typeof wx !== 'undefined' && wx.closeWindow) {{
|
||||
wx.closeWindow();
|
||||
}}
|
||||
}}, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
# POST 模式:返回 JSON
|
||||
return success_response(data={
|
||||
"success": result["success"],
|
||||
"message": result["message"],
|
||||
})
|
||||
|
||||
except ValueError as ve:
|
||||
# 票据过期/不存在 → 业务错误
|
||||
logger.warning(f"扫码业务错误: {ve}")
|
||||
raise AppException(1003, str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"扫码处理异常: error={e}", exc_info=True)
|
||||
raise AppException(1005, f"扫码处理失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/confirm — 当前已登录坐席确认授权
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/confirm", response_model=None)
|
||||
async def confirm_qrcode(
|
||||
body: QrcodeConfirmRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""处理当前已登录坐席的扫码确认授权。
|
||||
|
||||
需要鉴权: 只有已登录的坐席/管理员能确认授权。
|
||||
把扫码用户身份变成可登录 Token(roles=['agent']),
|
||||
写 Redis confirm:{ticket},前端 poll 拿到后跳坐席主页。
|
||||
|
||||
otp_code: admin 场景下可选,Phase 1.1 仅记录日志,
|
||||
真实 OTP 校验留给 Phase 2.1(参考 agents.py:272-274 的 totp.verify)。
|
||||
|
||||
Args:
|
||||
body: 包含 ticket 和 otp_code(可选)
|
||||
current_user: 当前已登录用户(由 get_current_user 注入)
|
||||
redis_client: Redis 客户端
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 字段是 QrcodeConfirmResponse
|
||||
"""
|
||||
try:
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_confirm(
|
||||
ticket=body.ticket,
|
||||
current_user_id=current_user.employee_id,
|
||||
current_user_name=current_user.name,
|
||||
current_roles=current_user.roles,
|
||||
otp_code=body.otp_code,
|
||||
)
|
||||
|
||||
# 同步头像:扫码时已从企微API拿到最新头像URL,这里落库 + 清缓存
|
||||
# (要求 A:确保所有登录路径刷新头像;头像更新失败不阻塞登录)
|
||||
confirm_avatar = result.get("avatar", "")
|
||||
if confirm_avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(
|
||||
db, redis_client, result["employee_id"], confirm_avatar
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"扫码确认同步头像失败(不阻塞): "
|
||||
f"employee_id={result.get('employee_id')}, error={e}"
|
||||
)
|
||||
|
||||
# 记录扫码登录日志(成功)
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=result["employee_id"],
|
||||
action="qrcode_login",
|
||||
resource="auth",
|
||||
resource_id=result["employee_id"],
|
||||
details={
|
||||
"name": result["name"],
|
||||
"roles": result["roles"],
|
||||
"confirmed_by": current_user.employee_id,
|
||||
"login_method": "qrcode_confirm",
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={
|
||||
"token": result["token"],
|
||||
"employee_id": result["employee_id"],
|
||||
"name": result["name"],
|
||||
"roles": result["roles"],
|
||||
"require_otp": result.get("require_otp"),
|
||||
})
|
||||
|
||||
except ValueError as ve:
|
||||
# 票据过期/未扫码 → 业务错误
|
||||
logger.warning(
|
||||
f"扫码确认业务错误: ticket={body.ticket[:8]}..., "
|
||||
f"current_user={current_user.employee_id}, error={ve}"
|
||||
)
|
||||
raise AppException(1003, str(ve))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"扫码确认异常: ticket={body.ticket[:8]}..., "
|
||||
f"current_user={current_user.employee_id}, error={e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise AppException(1005, f"扫码确认失败: {str(e)}")
|
||||
@@ -0,0 +1,532 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微入口 SSO(v0.7.1 新增)
|
||||
# =============================================================================
|
||||
# 说明: 解决 v0.7.0 hotfix1 用户报告的"企微工作台进入应用也要扫码"问题。
|
||||
#
|
||||
# 流程:
|
||||
# 1. 前端 PortalSelect.vue 加载时检测 navigator.userAgent
|
||||
# 2. 如果是 MicroMessenger / wxwork / DingTalk 等企微内置浏览器
|
||||
# → 调 /api/auth_wecom/sso/init?next=/itdesk/
|
||||
# 3. 后端生成企微 OAuth2 授权 URL,302 跳转用户去企微授权
|
||||
# 4. 企微回调 /api/auth_wecom/sso/callback?code=...&state=...
|
||||
# 5. 用 code 换 userid,查 role (user/agent/admin),生成 token
|
||||
# 6. 302 跳转到 next 路径 + token query param
|
||||
# 7. 前端用 token 调 get_current_user 拉身份信息
|
||||
#
|
||||
# 配置要求:
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 可信域名: itsupport.servyou.com.cn
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 回调域: itsupport.servyou.com.cn
|
||||
# - 环境变量 WECOM_SSO_ENABLED=true 启用(默认 false,避免老用户被打扰)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.services.wecom_service import WecomService
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
from app.utils.response import AppException, success_response
|
||||
from app.dependencies import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
|
||||
|
||||
# OAuth state 在 Redis 的 TTL (5 分钟,够用户授权 + 回调)
|
||||
OAUTH_STATE_TTL = 300
|
||||
# SSO token 长度
|
||||
SSO_TOKEN_BYTES = 32
|
||||
# Token TTL 常量(8小时)
|
||||
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
|
||||
# 企微 API 超时设置(秒)
|
||||
WECOM_API_TIMEOUT = 10
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 企微环境检测(使用统一工具模块)
|
||||
# --------------------------------------------------------------------------
|
||||
from app.utils.wecom_auth import require_wecom_ua as _require_wework_ua
|
||||
|
||||
|
||||
def _sso_enabled() -> bool:
|
||||
"""检查是否启用企微 SSO。"""
|
||||
import os
|
||||
if os.getenv("WECOM_SSO_ENABLED", "false").lower() == "true":
|
||||
return True
|
||||
if getattr(settings, "wecom_sso_enabled", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_oauth_callback_url(request: Request) -> str:
|
||||
"""拼接 OAuth 回调 URL (绝对地址)。
|
||||
|
||||
企微要求 redirect_uri 必须用可信域名(itsupport.servyou.com.cn)。
|
||||
不读 request.base_url 因为它可能是 127.0.0.1:8000(开发环境)。
|
||||
"""
|
||||
# 优先用 settings 里的配置
|
||||
base = getattr(settings, "wecom_sso_callback_base", None)
|
||||
if not base:
|
||||
# 兜底: 读环境变量,默认生产域名
|
||||
import os
|
||||
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
||||
return f"{base.rstrip('/')}/api/auth_wecom/sso/callback"
|
||||
|
||||
|
||||
def _build_oauth_url(state: str, callback_url: str) -> str:
|
||||
"""拼企微 OAuth2 授权 URL。
|
||||
|
||||
文档: https://developer.work.weixin.qq.com/document/path/91022
|
||||
"""
|
||||
params = {
|
||||
"appid": settings.wecom_corp_id,
|
||||
"redirect_uri": callback_url,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base", # 静默授权
|
||||
"state": state,
|
||||
"agentid": settings.wecom_agent_id,
|
||||
}
|
||||
query = urllib.parse.urlencode(params)
|
||||
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
||||
return f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/sso/init")
|
||||
async def sso_init(
|
||||
request: Request,
|
||||
next: str = Query("/itagent/", description="登录后跳转路径"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
|
||||
Args:
|
||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||
"""
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
# 用户通过企微扫码授权后即可自动登录
|
||||
|
||||
if not _sso_enabled():
|
||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||
|
||||
# 1. 生成 state(防 CSRF + 携带 next 路径)
|
||||
state = secrets.token_urlsafe(24)
|
||||
state_payload = {
|
||||
"next": next,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:state:{state}",
|
||||
OAUTH_STATE_TTL,
|
||||
str(state_payload).encode("utf-8"),
|
||||
)
|
||||
|
||||
# 2. 拼企微 OAuth URL(回调URL中包含next参数,用于state失效时仍能知道目标路径)
|
||||
callback_url = _get_oauth_callback_url(request)
|
||||
# 在回调URL中添加next参数
|
||||
separator = "&" if "?" in callback_url else "?"
|
||||
callback_url_with_next = f"{callback_url}{separator}next={urllib.parse.quote(next)}"
|
||||
oauth_url = _build_oauth_url(state, callback_url_with_next)
|
||||
|
||||
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
||||
return RedirectResponse(url=oauth_url, status_code=302)
|
||||
|
||||
|
||||
def _get_error_redirect_url(error_code: str, error_msg: str, next_path: str = "/itdesk/") -> str:
|
||||
"""生成 OAuth 错误重定向 URL。
|
||||
|
||||
异常时重定向到前端错误页面(ErrorPage),而不是返回 JSON 错误。
|
||||
ErrorPage 读取 code 和 message 参数显示友好错误提示。
|
||||
|
||||
Args:
|
||||
error_code: 错误码
|
||||
error_msg: 错误信息
|
||||
next_path: 原始请求的目标路径(保留但不再用于决定重定向)
|
||||
"""
|
||||
import os
|
||||
base = getattr(settings, "wecom_sso_callback_base", None)
|
||||
if not base:
|
||||
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
||||
|
||||
# 重定向到 Portal 的 ErrorPage,带错误参数
|
||||
# ErrorPage 期望格式:?code=xxx&message=yyy
|
||||
return f"{base.rstrip('/')}/itdesk/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||
|
||||
|
||||
@router.get("/sso/callback")
|
||||
async def sso_callback(
|
||||
request: Request,
|
||||
code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"),
|
||||
state: Optional[str] = Query(None, description="防 CSRF state"),
|
||||
errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"),
|
||||
errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"),
|
||||
next: Optional[str] = Query(None, description="原始请求的目标路径(可选,用于错误时重定向)"),
|
||||
redis_client = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。
|
||||
|
||||
支持任意浏览器环境,用户通过企微扫码授权后自动登录。
|
||||
异常时重定向到前端错误页面,避免白屏。
|
||||
|
||||
Args:
|
||||
next: 原始请求的目标路径,用于错误重定向。如果 state 验证失败,使用此参数决定重定向位置。
|
||||
"""
|
||||
import traceback
|
||||
|
||||
# 默认 next 路径(坐席端)
|
||||
next_path = next or "/itagent/"
|
||||
|
||||
try:
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
# 0. 处理企微返回的错误(用户拒绝授权等)
|
||||
if errcode is not None:
|
||||
msg = errmsg or "用户取消授权或授权失败"
|
||||
logger.warning(f"SSO callback 企微返回错误: errcode={errcode}, errmsg={errmsg}")
|
||||
return RedirectResponse(url=_get_error_redirect_url(f"wecom_{errcode}", msg, next_path), status_code=302)
|
||||
|
||||
# 1. 校验必要参数
|
||||
if not code or not state:
|
||||
logger.warning(f"SSO callback 缺少必要参数: code={bool(code)}, state={bool(state)}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("missing_params", "授权参数不完整,请重试", next_path), status_code=302)
|
||||
|
||||
# 2. 校验 state(防 CSRF)
|
||||
state_key = f"wecom_sso:state:{state}"
|
||||
try:
|
||||
state_raw = await redis_client.get(state_key)
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback Redis 获取 state 失败: {e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||
|
||||
if not state_raw:
|
||||
logger.warning(f"SSO callback state 过期: state={state[:8]}...")
|
||||
return RedirectResponse(url=_get_error_redirect_url("state_expired", "授权已过期,请重新进入", next_path), status_code=302)
|
||||
|
||||
# 删除 state(一次性)
|
||||
try:
|
||||
await redis_client.delete(state_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"SSO callback 删除 state 失败: {e}") # 不阻塞流程
|
||||
|
||||
# 解析 state 数据(添加异常处理)
|
||||
import ast
|
||||
import json
|
||||
try:
|
||||
state_data = json.loads(state_raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
# 兼容旧格式(使用 ast.literal_eval)
|
||||
try:
|
||||
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
||||
except (ValueError, SyntaxError) as e2:
|
||||
logger.error(f"SSO callback state 解析失败: {e2}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("state_invalid", "授权信息无效,请重新进入", next_path), status_code=302)
|
||||
|
||||
# 优先使用 state 中存储的 next,fallback 到 URL 参数
|
||||
next_path = state_data.get("next", next_path)
|
||||
|
||||
# 3. 用 code 换 userid
|
||||
wecom = WecomService(redis_client)
|
||||
try:
|
||||
oauth_info = await wecom.get_oauth_user_info(code)
|
||||
user_id = oauth_info.get("userid", "")
|
||||
if not user_id:
|
||||
logger.warning("SSO callback 企微返回 userid 为空")
|
||||
return RedirectResponse(url=_get_error_redirect_url("empty_userid", "无法获取您的企业微信身份,请重试", next_path), status_code=302)
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", user_id)
|
||||
# 同步头像到 employee 表 + 清缓存(要求 A;不阻塞登录)
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, user_id, user_info.get("avatar", ""))
|
||||
except Exception as av_err:
|
||||
logger.warning(f"SSO 同步头像失败(不阻塞): user_id={user_id}, error={av_err}")
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("api_failed", f"企业微信服务异常: {str(e)}"), status_code=302)
|
||||
finally:
|
||||
try:
|
||||
await wecom.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 查 role (user/agent/admin)
|
||||
try:
|
||||
role_stmt = (
|
||||
select(Role)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == user_id)
|
||||
)
|
||||
role_result = await db.execute(role_stmt)
|
||||
roles = role_result.scalars().all()
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 查询角色失败: {e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("db_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||
|
||||
if not roles:
|
||||
# 没有绑定角色: 跳"无权限"页
|
||||
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
|
||||
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
|
||||
|
||||
# 4. 选最高权限角色 (admin > agent > user)
|
||||
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
||||
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
||||
role_name = best_role.name
|
||||
|
||||
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
|
||||
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
|
||||
sso_payload = {
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"role": role_name,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
import json
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:token:{sso_token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"SSO callback 存储 token 失败: {e}")
|
||||
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||
|
||||
# 6. 记录登录日志
|
||||
try:
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=user_id,
|
||||
action="sso_login",
|
||||
resource="auth",
|
||||
resource_id=user_id,
|
||||
details={"name": name, "role": role_name, "login_method": "wecom_sso"},
|
||||
result="success",
|
||||
request=None, # callback 请求没有直接可用的 request 对象
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"SSO callback 记录登录日志失败: {e}") # 不阻塞登录流程
|
||||
|
||||
# 7. 跳转到 next + token
|
||||
separator = "&" if "?" in next_path else "?"
|
||||
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
|
||||
|
||||
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
# 捕获所有未处理的异常,记录详细日志(包含 traceback)并重定向到错误页
|
||||
error_details = {
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"code": code[:8] + "..." if code else None,
|
||||
"state": state[:8] + "..." if state else None,
|
||||
"next": next_path,
|
||||
}
|
||||
logger.error(
|
||||
f"SSO callback 未处理的异常: {error_details}\n"
|
||||
f"traceback: {traceback.format_exc()}"
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=_get_error_redirect_url("oauth_failed", "登录过程出现异常,请重试"),
|
||||
status_code=302
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sso/verify")
|
||||
async def sso_verify(
|
||||
request: Request,
|
||||
sso_token: str = Query(..., description="SSO token"),
|
||||
redis_client = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。
|
||||
|
||||
支持任意浏览器环境。
|
||||
"""
|
||||
# 注意:移除企微环境检测,允许在任意浏览器中使用
|
||||
|
||||
import json
|
||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||
if not token_raw:
|
||||
raise AppException(1005, "SSO token 已过期或无效")
|
||||
|
||||
# 一次性 token(防止泄漏后被滥用)
|
||||
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
||||
|
||||
payload = json.loads(token_raw.decode("utf-8"))
|
||||
return success_response(data=payload)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(
|
||||
token: str = Query(..., description="当前 Bearer token"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""刷新 Token TTL。
|
||||
|
||||
前端在 Token 过期前 5 分钟自动调用此接口,实现静默刷新。
|
||||
如果 Token 无效或已过期,返回 401 错误。
|
||||
|
||||
Returns:
|
||||
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
|
||||
"""
|
||||
import json
|
||||
|
||||
# 1. 尝试统一格式 Token
|
||||
token_key = f"user:token:{token}"
|
||||
token_data_raw = await redis_client.get(token_key)
|
||||
|
||||
if token_data_raw:
|
||||
try:
|
||||
user_info = json.loads(token_data_raw)
|
||||
# 更新最后活跃时间
|
||||
user_info["last_active"] = datetime.now().isoformat()
|
||||
|
||||
# 延长 TTL(重新设置 8 小时)
|
||||
await redis_client.setex(
|
||||
token_key,
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(user_info, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(f"Token 刷新成功: employee_id={user_info.get('employee_id')}")
|
||||
return success_response(data={"token": token, "expires_in": TOKEN_TTL_SECONDS})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 2. 尝试旧格式 Token (employee:token)
|
||||
employee_key = f"employee:token:{token}"
|
||||
employee_id = await redis_client.get(employee_key)
|
||||
if employee_id:
|
||||
# 延长 TTL
|
||||
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
|
||||
logger.info(f"Token 刷新成功(employee): employee_id={employee_id}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
|
||||
# 3. 尝试旧格式 Token (agent:token)
|
||||
agent_key = f"agent:token:{token}"
|
||||
agent_id = await redis_client.get(agent_key)
|
||||
if agent_id:
|
||||
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
|
||||
logger.info(f"Token 刷新成功(agent): agent_id={agent_id}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
|
||||
# Token 无效或已过期
|
||||
logger.warning(f"Token 刷新失败: token 不存在或已过期")
|
||||
raise AppException(401, "Token 已过期,请重新登录")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 别名路由:支持前端 /api/auth/refresh 调用(与 /api/auth_wecom/refresh 等效)
|
||||
# --------------------------------------------------------------------------
|
||||
# 前端 H5/坐席/管理后台调用 /api/auth/refresh,后端响应 /api/auth_wecom/refresh
|
||||
# 为兼容前端习惯,添加此别名路由
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 创建别名路由器(无 prefix)
|
||||
alias_router = APIRouter(tags=["认证"])
|
||||
|
||||
|
||||
@alias_router.post("/auth/refresh")
|
||||
async def refresh_token_alias(
|
||||
token: str = Query(..., description="当前 Bearer token"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""Token 刷新接口别名。
|
||||
|
||||
前端调用 /api/auth/refresh,后端实际处理逻辑与 /api/auth_wecom/refresh 相同。
|
||||
这是为了兼容前端的调用习惯。
|
||||
|
||||
Returns:
|
||||
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
|
||||
"""
|
||||
import json
|
||||
|
||||
# 1. 尝试统一格式 Token
|
||||
token_key = f"user:token:{token}"
|
||||
token_data_raw = await redis_client.get(token_key)
|
||||
|
||||
if token_data_raw:
|
||||
try:
|
||||
user_info = json.loads(token_data_raw)
|
||||
user_info["last_active"] = datetime.now().isoformat()
|
||||
await redis_client.setex(
|
||||
token_key,
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(user_info, ensure_ascii=False),
|
||||
)
|
||||
logger.info(f"Token 刷新成功(alias): employee_id={user_info.get('employee_id')}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 2. 尝试旧格式 Token
|
||||
employee_key = f"employee:token:{token}"
|
||||
employee_id = await redis_client.get(employee_key)
|
||||
if employee_id:
|
||||
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
|
||||
logger.info(f"Token 刷新成功(alias employee): employee_id={employee_id}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
|
||||
# 3. 尝试 agent token
|
||||
agent_key = f"agent:token:{token}"
|
||||
agent_id = await redis_client.get(agent_key)
|
||||
if agent_id:
|
||||
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
|
||||
logger.info(f"Token 刷新成功(alias agent): agent_id={agent_id}")
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": token,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
|
||||
logger.warning(f"Token 刷新失败(alias): token 不存在或已过期")
|
||||
raise AppException(401, "Token 已过期,请重新登录")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 注意:/api/auth_wecom/jsdk-login 接口已按决策4删除
|
||||
# 原功能为"企微免密登录",已按 PRD 要求移除
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化闭环 API
|
||||
# =============================================================================
|
||||
# 说明:提供自动化会话的 REST 接口与专用 WebSocket 通道。
|
||||
# 前缀(经 Vite/ nginx 剥离 /api 后):/itportal/automation
|
||||
#
|
||||
# 坐席端(agent):
|
||||
# POST /itportal/automation/sessions — 创建并启动会话
|
||||
# GET /itportal/automation/sessions — 会话列表
|
||||
# GET /itportal/automation/sessions/{id} — 会话详情
|
||||
# POST /itportal/automation/sessions/{id}/approve — 坐席审批/驳回
|
||||
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
|
||||
#
|
||||
# 员工端(H5):
|
||||
# POST /itportal/automation/sessions/by-employee — 员工创建会话
|
||||
# GET /itportal/automation/sessions/{id}/employee — 员工查看详情
|
||||
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 二次确认
|
||||
# POST /itportal/automation/sessions/{id}/feedback — 员工结果反馈
|
||||
#
|
||||
# 管理端(admin,配置写需 OTP):
|
||||
# GET /itportal/automation/admin/scenarios — 场景配置列表
|
||||
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景配置(OTP)
|
||||
# GET /itportal/automation/admin/rule-versions — 规则版本列表
|
||||
# GET /itportal/automation/admin/metrics — 看板指标
|
||||
#
|
||||
# WebSocket:
|
||||
# /ws/automation/{session_id} — 自动化进度/审批/确认实时推送
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_high_risk_otp
|
||||
from app.dependencies.automation import get_current_employee_id
|
||||
from app.api.agents import get_current_agent
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.automation import (
|
||||
CreateSessionRequest,
|
||||
ResolveFeedbackRequest,
|
||||
ScenarioConfigResponse,
|
||||
ScenarioConfigUpdate,
|
||||
SessionResponse,
|
||||
ApprovalDecisionRequest,
|
||||
ConfirmRequest,
|
||||
RuleVersionResponse,
|
||||
ResolveFeedbackRequest,
|
||||
TakeoverRequest,
|
||||
AutoMetricsResponse,
|
||||
serialize_action,
|
||||
serialize_approval,
|
||||
serialize_session,
|
||||
)
|
||||
from app.services.automation import (
|
||||
ActionExecutor,
|
||||
AutoSessionService,
|
||||
AutomationException,
|
||||
to_app_exception,
|
||||
)
|
||||
from app.services.automation.progress_publisher import (
|
||||
register_ws,
|
||||
set_parties,
|
||||
unregister_ws,
|
||||
)
|
||||
from app.services.cache_service import cache_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# REST 路由器(前缀 /itportal/automation,经 /api 代理剥离)
|
||||
# --------------------------------------------------------------------------
|
||||
router = APIRouter(prefix="/itportal/automation")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WebSocket 路由器(根路径 /ws/automation/{session_id})
|
||||
# --------------------------------------------------------------------------
|
||||
ws_router = APIRouter()
|
||||
|
||||
# WS 认证失败关闭码(与 ws.py 保持一致)
|
||||
WS_CLOSE_UNAUTHORIZED = 4001
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 坐席端接口
|
||||
# ==========================================================================
|
||||
@router.post("/sessions", tags=["自动化闭环"])
|
||||
async def create_session(
|
||||
req: CreateSessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席创建自动化会话并启动后台编排。"""
|
||||
svc = AutoSessionService(db)
|
||||
session = await svc.create_session(
|
||||
conversation_id=req.conversation_id,
|
||||
employee_id=req.employee_id,
|
||||
description=req.description,
|
||||
mode=req.mode,
|
||||
)
|
||||
await db.flush()
|
||||
# 记录参与方,供进度兜底推送
|
||||
set_parties(session.id, employee_id=req.employee_id)
|
||||
await db.commit()
|
||||
|
||||
# 后台运行编排(不阻塞响应)
|
||||
asyncio.create_task(_run_background(session.id))
|
||||
|
||||
data = serialize_session(session)
|
||||
return success_response(data.model_dump() if hasattr(data, "model_dump") else data.__dict__)
|
||||
|
||||
|
||||
@router.get("/sessions", tags=["自动化闭环"])
|
||||
async def list_sessions(
|
||||
employee_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席查看自动化会话列表。"""
|
||||
svc = AutoSessionService(db)
|
||||
sessions = await svc.list_sessions(
|
||||
employee_id=employee_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return success_response([_session_min(s) for s in sessions])
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", tags=["自动化闭环"])
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席查看会话详情。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
return success_response(_detail_payload(detail))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/approve", tags=["自动化闭环"])
|
||||
async def approve_session(
|
||||
session_id: str,
|
||||
req: ApprovalDecisionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席审批/驳回当前待决高危动作。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
action_id = detail["session"].current_action_id
|
||||
if not action_id or detail["ticket"] is None:
|
||||
raise AppException(4004, "当前没有待审批的动作")
|
||||
executor = ActionExecutor(db)
|
||||
try:
|
||||
await executor.resume(
|
||||
session_id,
|
||||
action_id,
|
||||
decision=req.decision,
|
||||
note=req.note,
|
||||
approver_id=current_agent.user_id,
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/takeover", tags=["自动化闭环"])
|
||||
async def takeover_session(
|
||||
session_id: str,
|
||||
req: TakeoverRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席转人工接管会话。"""
|
||||
svc = AutoSessionService(db)
|
||||
try:
|
||||
session = await svc.takeover(
|
||||
session_id, agent_id=current_agent.user_id, note=req.note
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 员工端(H5)接口
|
||||
# ==========================================================================
|
||||
@router.post("/sessions/by-employee", tags=["自动化闭环"])
|
||||
async def create_session_by_employee(
|
||||
req: CreateSessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工(H5)创建自动化会话。"""
|
||||
svc = AutoSessionService(db)
|
||||
session = await svc.create_session(
|
||||
conversation_id=req.conversation_id,
|
||||
employee_id=employee_id,
|
||||
description=req.description,
|
||||
mode=req.mode,
|
||||
)
|
||||
await db.flush()
|
||||
set_parties(session.id, employee_id=employee_id)
|
||||
await db.commit()
|
||||
asyncio.create_task(_run_background(session.id))
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/employee", tags=["自动化闭环"])
|
||||
async def get_session_employee(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工(H5)查看自己会话详情。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None or detail["session"].employee_id != employee_id:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
return success_response(_detail_payload(detail))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/confirm", tags=["自动化闭环"])
|
||||
async def confirm_session(
|
||||
session_id: str,
|
||||
req: ConfirmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工 H5 二次确认(高危写操作)。"""
|
||||
svc = AutoSessionService(db)
|
||||
detail = await svc.get_session_detail(session_id)
|
||||
if detail is None or detail["session"].employee_id != employee_id:
|
||||
raise AppException(4005, "自动化会话不存在")
|
||||
action_id = detail["session"].current_action_id
|
||||
if not action_id or detail["ticket"] is None:
|
||||
raise AppException(4004, "当前没有待确认的动作")
|
||||
if detail["ticket"].channel != "h5":
|
||||
raise AppException(4004, "该动作需坐席审批,员工无需确认")
|
||||
executor = ActionExecutor(db)
|
||||
try:
|
||||
await executor.resume(
|
||||
session_id,
|
||||
action_id,
|
||||
decision="approve" if req.confirmed else "reject",
|
||||
note=req.note,
|
||||
approver_id=employee_id,
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/feedback", tags=["自动化闭环"])
|
||||
async def feedback_session(
|
||||
session_id: str,
|
||||
req: ResolveFeedbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(get_current_employee_id),
|
||||
):
|
||||
"""员工对处置结果反馈(满意→关单 / 不满意→转人工)。"""
|
||||
svc = AutoSessionService(db)
|
||||
try:
|
||||
session = await svc.resolve_feedback(
|
||||
session_id, satisfied=req.satisfied, note=req.note
|
||||
)
|
||||
except AutomationException as e:
|
||||
raise to_app_exception(e)
|
||||
await db.commit()
|
||||
return success_response(serialize_session(session).__dict__)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 管理端接口(配置写需 OTP)
|
||||
# ==========================================================================
|
||||
@router.get("/admin/scenarios", tags=["自动化闭环-管理"])
|
||||
async def list_scenarios(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""场景配置列表(只读,无需 OTP)。"""
|
||||
svc = AutoSessionService(db)
|
||||
configs = await svc.list_scenario_configs()
|
||||
return success_response([_scenario_payload(c) for c in configs])
|
||||
|
||||
|
||||
@router.put("/admin/scenarios/{scenario_key}", tags=["自动化闭环-管理"])
|
||||
async def update_scenario(
|
||||
scenario_key: str,
|
||||
req: ScenarioConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_otp: object = Depends(require_high_risk_otp),
|
||||
):
|
||||
"""更新场景配置(高危写操作,需 OTP)。"""
|
||||
svc = AutoSessionService(db)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
config = await svc.upsert_scenario_config(scenario_key, data, operator="admin")
|
||||
await db.commit()
|
||||
return success_response(_scenario_payload(config))
|
||||
|
||||
|
||||
@router.get("/admin/rule-versions", tags=["自动化闭环-管理"])
|
||||
async def list_rule_versions(
|
||||
scenario_key: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""规则版本列表。"""
|
||||
svc = AutoSessionService(db)
|
||||
versions = await svc.list_rule_versions(scenario_key=scenario_key)
|
||||
return success_response([_rule_version_payload(v) for v in versions])
|
||||
|
||||
|
||||
@router.get("/admin/metrics", tags=["自动化闭环-管理"])
|
||||
async def get_metrics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""自动化看板指标。"""
|
||||
svc = AutoSessionService(db)
|
||||
metrics = await svc.metrics()
|
||||
return success_response(metrics)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# WebSocket:自动化进度专用通道
|
||||
# ==========================================================================
|
||||
@ws_router.websocket("/ws/automation/{session_id}")
|
||||
async def automation_ws_endpoint(websocket: WebSocket, session_id: str) -> None:
|
||||
"""自动化会话专用 WebSocket(坐席/员工均可连)。
|
||||
|
||||
认证:优先 subprotocol bearer.{token},其次 Authorization header,
|
||||
最后 query ?token=。token 需在 agent:token / employee:token 中存在。
|
||||
"""
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:]
|
||||
else:
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
token = auth_header[7:] if auth_header.startswith("Bearer ") else websocket.query_params.get("token", "")
|
||||
|
||||
if not token:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
|
||||
return
|
||||
|
||||
# 校验 token(坐席或员工任一即可)
|
||||
try:
|
||||
aid = await cache_service.get(f"agent:token:{token}")
|
||||
eid = await cache_service.get(f"employee:token:{token}") if not aid else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"自动化 WS token 校验失败: {e}")
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Auth unavailable")
|
||||
return
|
||||
|
||||
if not aid and not eid:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid token")
|
||||
return
|
||||
|
||||
register_ws(session_id, websocket)
|
||||
logger.info(f"自动化 WS 连接: session={session_id}")
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
unregister_ws(session_id, websocket)
|
||||
logger.info(f"自动化 WS 断开: session={session_id}")
|
||||
except Exception: # noqa: BLE001
|
||||
unregister_ws(session_id, websocket)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助函数
|
||||
# ==========================================================================
|
||||
async def _run_background(session_id: str) -> None:
|
||||
"""后台运行编排(独立导入避免循环依赖)。"""
|
||||
from app.services.automation import run_session_in_background
|
||||
|
||||
await run_session_in_background(session_id)
|
||||
|
||||
|
||||
def _session_min(session) -> dict:
|
||||
"""会话列表最小字段。"""
|
||||
return {
|
||||
"id": session.id,
|
||||
"employee_id": session.employee_id,
|
||||
"scenario_key": session.scenario_key,
|
||||
"status": session.status,
|
||||
"mode": session.mode,
|
||||
"confidence": session.confidence,
|
||||
"title": session.title,
|
||||
"created_at": session.created_at.isoformat() if session.created_at else None,
|
||||
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _detail_payload(detail: dict) -> dict:
|
||||
"""构造会话详情响应 data。"""
|
||||
session = detail["session"]
|
||||
actions = detail.get("actions", [])
|
||||
ticket = detail.get("ticket")
|
||||
return serialize_session(session, actions=actions, ticket=ticket).__dict__
|
||||
|
||||
|
||||
def _scenario_payload(config) -> dict:
|
||||
"""场景配置响应。"""
|
||||
return ScenarioConfigResponse(
|
||||
id=config.id,
|
||||
scenario_key=config.scenario_key,
|
||||
name=config.name,
|
||||
description=config.description,
|
||||
enabled=config.enabled,
|
||||
trigger_conditions=config.trigger_conditions,
|
||||
actions=config.actions,
|
||||
approval_strategy=config.approval_strategy,
|
||||
current_version_id=config.current_version_id,
|
||||
).model_dump()
|
||||
|
||||
|
||||
def _rule_version_payload(version) -> dict:
|
||||
"""规则版本响应。"""
|
||||
return RuleVersionResponse(
|
||||
id=version.id,
|
||||
scenario_key=version.scenario_key,
|
||||
version=version.version,
|
||||
content=version.content,
|
||||
status=version.status,
|
||||
canary_percent=version.canary_percent,
|
||||
created_by=version.created_by,
|
||||
remark=version.remark,
|
||||
created_at=version.created_at.isoformat() if version.created_at else None,
|
||||
).model_dump()
|
||||
@@ -0,0 +1,99 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 会话标注 API
|
||||
# =============================================================================
|
||||
# 说明:会话标注接口
|
||||
# 1. POST /api/annotations — 创建标注
|
||||
# 2. GET /api/annotations/{conversation_id} — 获取会话的标注列表
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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.conversation_annotation import ConversationAnnotation
|
||||
from app.schemas.conversation_annotation import (
|
||||
AnnotationCreate,
|
||||
AnnotationResponse,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/annotations — 创建标注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/annotations")
|
||||
async def create_annotation(
|
||||
body: AnnotationCreate,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建会话标注。
|
||||
|
||||
坐席对AI回复进行标注(有用/无用)。
|
||||
|
||||
Args:
|
||||
body: 创建请求体
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的标注
|
||||
"""
|
||||
annotation = ConversationAnnotation(
|
||||
conversation_id=body.conversation_id,
|
||||
agent_id=agent.id,
|
||||
message_id=body.message_id,
|
||||
feedback=body.feedback,
|
||||
comment=body.comment,
|
||||
)
|
||||
db.add(annotation)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"创建会话标注: conversation={body.conversation_id}, feedback={body.feedback}")
|
||||
|
||||
data = AnnotationResponse.model_validate(annotation).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/annotations/{conversation_id} — 获取会话的标注列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/annotations/{conversation_id}")
|
||||
async def list_annotations(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话的所有标注。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含标注列表
|
||||
"""
|
||||
stmt = (
|
||||
select(ConversationAnnotation)
|
||||
.where(ConversationAnnotation.conversation_id == conversation_id)
|
||||
.order_by(ConversationAnnotation.created_at.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
annotations = list(result.scalars().all())
|
||||
|
||||
data = [AnnotationResponse.model_validate(a).model_dump() for a in annotations]
|
||||
return success_response(data={"items": data})
|
||||
@@ -20,8 +20,10 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.schemas.conversation import (
|
||||
ConversationAssign,
|
||||
ConversationInvite,
|
||||
@@ -30,6 +32,7 @@ from app.schemas.conversation import (
|
||||
ConversationStatusUpdate,
|
||||
InviteParticipantRequest,
|
||||
JoinConversationRequest,
|
||||
UpdateTagsRequest,
|
||||
)
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -38,6 +41,9 @@ from app.utils.response import AppException, success_response
|
||||
# 坐席认证依赖(从 agents.py 导入)
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
# RBAC 权限装饰器
|
||||
from app.dependencies import get_redis, require_role, require_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
@@ -48,6 +54,7 @@ router = APIRouter()
|
||||
# GET /api/conversations — 获取坐席会话列表(全局可见)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def list_conversations(
|
||||
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
|
||||
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
|
||||
@@ -55,6 +62,7 @@ async def list_conversations(
|
||||
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
"""坐席获取会话列表(全局可见)。
|
||||
|
||||
@@ -76,7 +84,7 @@ async def list_conversations(
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含会话列表和总数
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
session_service = SessionService(db, redis_client=redis)
|
||||
conversations, total = await session_service.get_conversations(
|
||||
status=status,
|
||||
agent_id=agent_id,
|
||||
@@ -101,10 +109,62 @@ async def list_conversations(
|
||||
for agent in result.scalars().all():
|
||||
agent_name_map[agent.user_id] = agent.name
|
||||
|
||||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
|
||||
# 批量获取员工头像(带缓存)
|
||||
employee_ids = list(set([conv.employee_id for conv in conversations]))
|
||||
employee_avatar_map = {}
|
||||
for emp_id in employee_ids:
|
||||
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
|
||||
|
||||
# ── BUGFIX: 批量回退查询员工信息 ──
|
||||
# 为什么需要:conversations 表中 employee_name/department/position 是冗余字段,
|
||||
# 在会话创建时可能为空(异步创建、企微回调延迟等),导致列表API返回空字符串。
|
||||
# 当这些字段为空时,从 employees 表批量查询并回填,确保坐席端能看到完整用户信息。
|
||||
# 何时触发:仅当 conversations 表中的 employee_name 为空字符串时才会去 employees 表查找。
|
||||
employee_name_map: dict[str, dict] = {}
|
||||
empty_name_conv_ids = [
|
||||
conv.employee_id for conv in conversations
|
||||
if not conv.employee_name and conv.employee_id
|
||||
]
|
||||
if empty_name_conv_ids:
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id.in_(empty_name_conv_ids))
|
||||
result = await db.execute(stmt)
|
||||
for emp in result.scalars().all():
|
||||
employee_name_map[emp.employee_id] = {
|
||||
"name": emp.name or "",
|
||||
"department": emp.department or "",
|
||||
"position": emp.position or "",
|
||||
"level": getattr(emp, "it_level", "") or "",
|
||||
}
|
||||
if employee_name_map:
|
||||
logger.info(
|
||||
f"从employees表批量回退获取员工信息: "
|
||||
f"请求={len(empty_name_conv_ids)}, 命中={len(employee_name_map)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"从employees表批量回退获取员工信息失败: error={e}")
|
||||
|
||||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
|
||||
items = []
|
||||
for conv in conversations:
|
||||
# ── BUGFIX: 应用 employees 表回退信息 ──
|
||||
# 如果 conv 的 employee_name 为空,用批量查询结果回填
|
||||
# 这样 ConversationResponse.model_validate 序列化时就能拿到正确的值
|
||||
emp_fallback = employee_name_map.get(conv.employee_id, {})
|
||||
if emp_fallback:
|
||||
if not conv.employee_name and emp_fallback.get("name"):
|
||||
conv.employee_name = emp_fallback["name"]
|
||||
if not conv.department and emp_fallback.get("department"):
|
||||
conv.department = emp_fallback["department"]
|
||||
if not conv.position and emp_fallback.get("position"):
|
||||
conv.position = emp_fallback["position"]
|
||||
if not conv.level and emp_fallback.get("level"):
|
||||
conv.level = emp_fallback["level"]
|
||||
|
||||
conv_data = ConversationResponse.model_validate(conv).model_dump()
|
||||
# 员工头像(从缓存获取)
|
||||
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
|
||||
# 是否为当前坐席的会话
|
||||
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
|
||||
# 坐席姓名(从批量查询结果中获取)
|
||||
@@ -142,9 +202,11 @@ async def list_conversations(
|
||||
# GET /api/conversations/{id} — 获取会话详情
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def get_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
"""获取会话详情。
|
||||
|
||||
@@ -155,10 +217,37 @@ async def get_conversation(
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含会话详情
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
session_service = SessionService(db, redis_client=redis)
|
||||
conversation = await session_service.get_conversation(conversation_id)
|
||||
|
||||
# 如果会话中员工姓名为空,从 employees 表回退获取
|
||||
if not conversation.employee_name:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id == conversation.employee_id)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee and employee.name:
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = getattr(employee, "it_level", "") or ""
|
||||
logger.info(
|
||||
f"从employees表回退获取会话详情员工信息: employee_id={conversation.employee_id}, "
|
||||
f"name={employee.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"从employees表获取会话详情员工信息失败: employee_id={conversation.employee_id}, "
|
||||
f"error={e}"
|
||||
)
|
||||
|
||||
# 获取员工头像(带缓存)
|
||||
avatar = await session_service._get_employee_avatar(conversation.employee_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
response_data["avatar"] = avatar
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
@@ -166,6 +255,7 @@ async def get_conversation(
|
||||
# POST /api/conversations/{id}/assign — 坐席接单
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/assign")
|
||||
@require_permission("conversation", "update", "all")
|
||||
async def assign_conversation(
|
||||
conversation_id: str,
|
||||
body: ConversationAssign,
|
||||
@@ -191,14 +281,18 @@ async def assign_conversation(
|
||||
redis_client = settings.create_redis_client()
|
||||
wecom_service = WecomService(redis_client)
|
||||
session_service = SessionService(db, wecom_service=wecom_service)
|
||||
except Exception:
|
||||
logger.warning("创建企微服务失败,接入通知将不发送")
|
||||
except Exception as e:
|
||||
logger.warning(f"创建企微服务失败: {e},接入通知将不发送")
|
||||
session_service = SessionService(db)
|
||||
|
||||
conversation = await session_service.assign_agent(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=body.agent_id,
|
||||
)
|
||||
try:
|
||||
conversation = await session_service.assign_agent(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=body.agent_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"接单失败: conversation_id={conversation_id}, agent_id={body.agent_id}, error={e}")
|
||||
raise
|
||||
|
||||
# 关闭企微服务连接
|
||||
if redis_client:
|
||||
@@ -216,6 +310,7 @@ async def assign_conversation(
|
||||
# POST /api/conversations/{id}/resolve — 结单
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/resolve")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def resolve_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -224,6 +319,7 @@ async def resolve_conversation(
|
||||
"""结单。
|
||||
|
||||
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
|
||||
结单完成后异步触发知识建议生成(通道 A 全链路闭环)。
|
||||
|
||||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||||
协作坐席和其他坐席不能结单。
|
||||
@@ -252,6 +348,51 @@ async def resolve_conversation(
|
||||
conversation = await session_service.resolve_conversation(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
|
||||
# ── 任务1(P0):会话关闭→异步触发知识建议生成 ──
|
||||
# 在结单响应返回后,异步调用 Dify 生成知识迭代建议。
|
||||
# 使用 FastAPI BackgroundTasks 确保不阻塞结单响应。
|
||||
try:
|
||||
from fastapi import BackgroundTasks
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _trigger_knowledge_suggestion():
|
||||
"""异步生成知识建议的后台任务(独立 db session)。"""
|
||||
from app.database import _get_session_factory
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService
|
||||
|
||||
factory = _get_session_factory()
|
||||
async with factory() as bg_db:
|
||||
try:
|
||||
knowledge_service = KnowledgeIterationService()
|
||||
suggestion = await knowledge_service.generate_knowledge_suggestion(
|
||||
db=bg_db,
|
||||
source_type="conversation",
|
||||
source_data=[str(conversation_id)],
|
||||
reason=f"会话'{conversation_id}'已结单,自动生成知识迭代建议",
|
||||
)
|
||||
if suggestion:
|
||||
bg_db.add(suggestion)
|
||||
await bg_db.commit()
|
||||
logger.info(
|
||||
f"会话关闭→知识建议已生成: conv_id={conversation_id}, "
|
||||
f"suggestion_id={suggestion.id}, type={suggestion.suggestion_type}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"会话关闭→无知识建议生成(Dify不可用或无需建议): "
|
||||
f"conv_id={conversation_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"会话关闭→知识建议生成失败: conv_id={conversation_id}, error={e}")
|
||||
|
||||
# 创建后台任务(不阻塞结单响应)
|
||||
_asyncio.ensure_future(_trigger_knowledge_suggestion())
|
||||
logger.info(f"会话结单完成,已触发异步知识建议生成: conv_id={conversation_id}")
|
||||
except Exception as e:
|
||||
# 知识建议生成失败不影响结单主流程
|
||||
logger.warning(f"触发异步知识建议生成失败(不影响结单): {e}")
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
@@ -259,6 +400,7 @@ async def resolve_conversation(
|
||||
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/pin")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def toggle_pin(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -285,6 +427,7 @@ async def toggle_pin(
|
||||
# POST /api/conversations/{id}/todo — 代办/取消代办
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/todo")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def toggle_todo(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -311,6 +454,7 @@ async def toggle_todo(
|
||||
# POST /api/conversations/{id}/transfer — 转接
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/transfer")
|
||||
@require_permission("conversation", "update", "all")
|
||||
async def transfer_conversation(
|
||||
conversation_id: str,
|
||||
body: ConversationAssign,
|
||||
@@ -342,6 +486,7 @@ async def transfer_conversation(
|
||||
# POST /api/conversations/{id}/grab — 接手会话(抢单)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/grab")
|
||||
@require_permission("conversation", "update", "all")
|
||||
async def grab_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -439,6 +584,7 @@ async def grab_conversation(
|
||||
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/invite")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def invite_collaborator(
|
||||
conversation_id: str,
|
||||
body: ConversationInvite,
|
||||
@@ -484,6 +630,7 @@ async def invite_collaborator(
|
||||
# POST /api/conversations/{id}/leave — 退出协作
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/leave")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def leave_collaboration(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -529,6 +676,7 @@ async def leave_collaboration(
|
||||
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/invite-participant")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def invite_participant(
|
||||
conversation_id: str,
|
||||
body: InviteParticipantRequest,
|
||||
@@ -586,6 +734,7 @@ async def invite_participant(
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/join — 被邀请人加入会话
|
||||
# --------------------------------------------------------------------------
|
||||
# 注意:此端点允许被邀请的员工直接从H5加入,不需要坐席认证
|
||||
@router.post("/conversations/{conversation_id}/join")
|
||||
async def join_conversation(
|
||||
conversation_id: str,
|
||||
@@ -622,6 +771,7 @@ async def join_conversation(
|
||||
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def remove_participant(
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
@@ -659,9 +809,11 @@ async def remove_participant(
|
||||
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/leave-participant")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def leave_as_participant(
|
||||
conversation_id: str,
|
||||
body: JoinConversationRequest,
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""参与者主动退出会话。
|
||||
@@ -686,3 +838,60 @@ async def leave_as_participant(
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/tags — 保存会话标签
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/tags")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def update_conversation_tags(
|
||||
conversation_id: str,
|
||||
body: UpdateTagsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""保存会话标签。
|
||||
|
||||
坐席可以为会话添加/更新标签,如问题分类、优先级、情绪状态等。
|
||||
标签以 JSON 形式存储在会话的 tags 字段中。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 标签更新请求,包含 tags 字典
|
||||
current_agent: 当前坐席(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的会话详情
|
||||
"""
|
||||
# 1. 验证会话存在性
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException("会话不存在", code=404)
|
||||
|
||||
# 2. 合并现有标签(如果有)
|
||||
existing_tags = {}
|
||||
if conversation.tags:
|
||||
existing_tags = (
|
||||
dict(conversation.tags) if isinstance(conversation.tags, dict) else {}
|
||||
)
|
||||
|
||||
# 3. 合并新旧标签(body.tags 覆盖同名 key)
|
||||
merged_tags = {**existing_tags, **body.tags}
|
||||
|
||||
# 4. 保存到数据库
|
||||
conversation.tags = merged_tags
|
||||
await db.commit()
|
||||
await db.refresh(conversation)
|
||||
|
||||
logger.info(
|
||||
f"坐席 {current_agent.id} 更新会话 {conversation_id} 标签: {merged_tags}"
|
||||
)
|
||||
|
||||
# 5. 返回更新后的会话
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 开发模式 Mock 登录
|
||||
# =============================================================================
|
||||
# ⚠️ 警告:此模块只在 DEV_MODE=true 时可用
|
||||
# - 仅供本地开发 / 集成测试使用
|
||||
# - 生产环境(DEV_MODE 未设置或 false)会直接 403
|
||||
# - 部署前必须确认 .env / .env.production 没有 DEV_MODE=true
|
||||
# 用法:
|
||||
# GET /api/dev/login?userid=dev-user-001&name=测试&role=user
|
||||
# GET /api/dev/users # 列出所有预设 dev 用户
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_redis
|
||||
from app.models.employee import Employee
|
||||
from app.services.token_service import TokenService
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/dev", tags=["dev-mock"])
|
||||
|
||||
|
||||
def _dev_mode_enabled() -> bool:
|
||||
"""检查是否启用了开发模式。
|
||||
|
||||
三个检查源(任一为 true 即启用):
|
||||
1. 环境变量 DEV_MODE=true
|
||||
2. settings.dev_mode(从 .env.dev 读)
|
||||
3. DEBUG 模式 + 本地主机(最严格)
|
||||
"""
|
||||
env_val = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||
if env_val:
|
||||
return True
|
||||
# 兜底:从 settings 读
|
||||
if hasattr(settings, "dev_mode") and getattr(settings, "dev_mode", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 预设 dev 用户(便于测试不同角色)
|
||||
# -----------------------------------------------------------------------------
|
||||
PRESET_DEV_USERS = [
|
||||
{"userid": "dev-user-001", "name": "张三(普通员工)", "role": "user", "department": "财务部"},
|
||||
{"userid": "dev-agent-001", "name": "李四(IT 坐席)", "role": "agent", "department": "信息技术部"},
|
||||
{"userid": "dev-supervisor-001", "name": "王五(部门主管)", "role": "supervisor", "department": "信息技术部"},
|
||||
{"userid": "dev-security-001", "name": "赵六(安全团队)", "role": "security", "department": "信息安全部"},
|
||||
{"userid": "dev-admin-001", "name": "钱七(系统管理员)", "role": "admin", "department": "信息技术部"},
|
||||
{"userid": "dev-multi-001", "name": "周八(多角色测试)", "role": "user,agent,supervisor", "department": "测试部"},
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/dev/login — Mock 登录(返回 token)
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.get("/login")
|
||||
async def dev_login(
|
||||
userid: str = Query("dev-user-001", description="用户 ID(模拟企微 userid)"),
|
||||
name: str = Query("开发测试用户", description="用户姓名"),
|
||||
role: str = Query("user", description="角色:user/agent/admin/supervisor/security,多个用逗号分隔"),
|
||||
department: str = Query("信息技术部", description="部门"),
|
||||
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""开发模式 Mock 登录。
|
||||
|
||||
用法:
|
||||
GET /api/dev/login?userid=dev-agent-001&name=李四&role=agent
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": "abc123...",
|
||||
"user": { "userid": "...", "name": "...", "roles": [...] }
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not _dev_mode_enabled():
|
||||
logger.warning("🚨 /api/dev/login 被调用但 DEV_MODE 未启用,返回 403")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="DEV_MODE not enabled. Set DEV_MODE=true in .env.dev to use this endpoint."
|
||||
)
|
||||
|
||||
# 解析多角色
|
||||
roles = [r.strip() for r in role.split(",") if r.strip()]
|
||||
if not roles:
|
||||
roles = ["user"]
|
||||
|
||||
# 调 TokenService 创建 token(走完全真实的 token 流程)
|
||||
token_service = TokenService(redis)
|
||||
token = await token_service.create_token(
|
||||
employee_id=userid,
|
||||
name=name,
|
||||
roles=roles,
|
||||
department=department,
|
||||
avatar=avatar or "",
|
||||
login_source="dev",
|
||||
)
|
||||
|
||||
# Mock 登录时同步写入 employees 表(便于坐席端显示员工姓名)
|
||||
from sqlalchemy import select
|
||||
stmt = select(Employee).where(Employee.employee_id == userid)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee:
|
||||
# 更新已有记录
|
||||
employee.name = name
|
||||
employee.department = department
|
||||
employee.avatar = avatar or ""
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 创建新记录
|
||||
employee = Employee(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=userid,
|
||||
name=name,
|
||||
department=department,
|
||||
position="",
|
||||
avatar=avatar or "",
|
||||
avatar_updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(employee)
|
||||
# 清 Redis 头像缓存,确保下次读取数据库最新头像(要求 C:清缓存 + 更新时间一致)
|
||||
if avatar:
|
||||
try:
|
||||
await redis.delete(f"employee:avatar:{userid}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除头像Redis缓存失败: userid={userid}, error={e}")
|
||||
await db.commit()
|
||||
logger.info(f"🧪 [DEV] 同步员工信息到 employees 表: userid={userid}, name={name}")
|
||||
|
||||
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
|
||||
|
||||
return success_response(data={
|
||||
"token": token,
|
||||
"user": {
|
||||
"userid": userid,
|
||||
"name": name,
|
||||
"department": department,
|
||||
"avatar": avatar or "",
|
||||
"roles": roles,
|
||||
"login_source": "dev",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/dev/users — 列出所有预设 dev 用户
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.get("/users")
|
||||
async def dev_list_users():
|
||||
"""列出所有预设 dev 用户(便于前端测试用)。"""
|
||||
if not _dev_mode_enabled():
|
||||
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||
|
||||
return success_response(data=PRESET_DEV_USERS)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/dev/health — 检查 dev 模式状态
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.get("/health")
|
||||
async def dev_health():
|
||||
"""检查 dev 模式是否启用 + 关键依赖。"""
|
||||
if not _dev_mode_enabled():
|
||||
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||
|
||||
return success_response(data={
|
||||
"dev_mode": True,
|
||||
"env": os.getenv("APP_ENV", "unknown"),
|
||||
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
|
||||
"redis_url": os.getenv("REDIS_URL", "not set"),
|
||||
"preset_users": len(PRESET_DEV_USERS),
|
||||
})
|
||||
@@ -4,16 +4,27 @@
|
||||
# 说明:提供员工相关的管理接口
|
||||
# 接口列表:
|
||||
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
|
||||
# POST /api/employees/{employee_id}/avatar/refresh — 手动刷新员工头像
|
||||
# =============================================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.utils.response import success_response
|
||||
|
||||
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
|
||||
from app.database import get_db
|
||||
from app.config import settings
|
||||
from app.models.employee import Employee
|
||||
from app.dependencies import dep_redis
|
||||
|
||||
# 导入日志
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/employees", tags=["员工管理"])
|
||||
@@ -114,3 +125,101 @@ async def update_employee_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())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 头像刷新 API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class AvatarRefreshResponse(BaseModel):
|
||||
"""头像刷新响应 Schema。"""
|
||||
|
||||
employee_id: str
|
||||
avatar: str
|
||||
message: str
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""获取Redis客户端依赖"""
|
||||
redis = await dep_redis()
|
||||
if redis is None:
|
||||
raise HTTPException(status_code=500, detail="Redis连接不可用")
|
||||
return redis
|
||||
|
||||
|
||||
@router.post("/{employee_id}/avatar/refresh", response_model=dict)
|
||||
async def refresh_employee_avatar(
|
||||
employee_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
"""手动刷新员工头像。
|
||||
|
||||
调用企微通讯录API获取最新头像URL,更新数据库并刷新Redis缓存。
|
||||
支持手动触发头像更新,适用于头像URL过期或需要立即更新的场景。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
更新后的头像URL
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.services.session_service import SessionService
|
||||
|
||||
# 1. 查找员工记录
|
||||
result = await db.execute(
|
||||
select(Employee).where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id
|
||||
)
|
||||
)
|
||||
employee = result.scalars().first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(status_code=404, detail=f"员工不存在: {employee_id}")
|
||||
|
||||
# 2. 使用 SessionService 从企微API获取最新头像
|
||||
session_service = SessionService(db, redis_client=redis)
|
||||
new_avatar = ""
|
||||
|
||||
try:
|
||||
# 调用企微API获取最新头像
|
||||
from app.services.wecom_service import WeComService
|
||||
wecom_service = WeComService()
|
||||
user_info = await wecom_service.get_user_info(employee_id)
|
||||
new_avatar = user_info.get("avatar", "")
|
||||
|
||||
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(new_avatar)) + '字符)' if new_avatar else '空'}")
|
||||
|
||||
# 3. 更新数据库
|
||||
employee.avatar = new_avatar
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# 4. 刷新Redis缓存
|
||||
cache_key = f"employee:avatar:{employee_id}"
|
||||
if redis:
|
||||
try:
|
||||
if new_avatar:
|
||||
await redis.setex(cache_key, SessionService.AVATAR_CACHE_TTL, new_avatar)
|
||||
else:
|
||||
# 如果头像为空,删除缓存
|
||||
await redis.delete(cache_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"刷新Redis头像缓存失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
return success_response(data=AvatarRefreshResponse(
|
||||
employee_id=employee_id,
|
||||
avatar=new_avatar,
|
||||
message="头像刷新成功" if new_avatar else "企微API未返回头像,已使用原头像",
|
||||
).model_dump())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新头像失败: employee_id={employee_id}, error={e}")
|
||||
# 返回原头像,不阻塞流程
|
||||
return success_response(data=AvatarRefreshResponse(
|
||||
employee_id=employee_id,
|
||||
avatar=employee.avatar,
|
||||
message=f"头像刷新失败,使用原头像: {str(e)}",
|
||||
).model_dump())
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 员工 API
|
||||
# =============================================================================
|
||||
# 说明:提供员工相关的管理接口
|
||||
# 接口列表:
|
||||
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
|
||||
# =============================================================================
|
||||
|
||||
from fastapi import APIRouter
|
||||
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
|
||||
|
||||
# 导入日志
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
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())
|
||||
@@ -0,0 +1,349 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 满意度评价 API
|
||||
# =============================================================================
|
||||
# 说明:满意度评价相关接口
|
||||
# 1. POST /api/conversation/{conversation_id}/evaluate - 提交评价
|
||||
# 2. GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
|
||||
# 3. GET /api/evaluations/stats - 获取评价统计(管理后台)
|
||||
# 4. POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请(坐席端触发)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.conversation_evaluation import ConversationEvaluation
|
||||
from app.schemas.evaluation import (
|
||||
EvaluationInviteRequest,
|
||||
EvaluationStatsItem,
|
||||
EvaluationStatsResponse,
|
||||
EvaluationSubmitRequest,
|
||||
EvaluationResponse,
|
||||
)
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
# H5认证依赖(从 h5.py 导入)
|
||||
from app.api.h5 import _get_current_employee
|
||||
from app.models.employee import Employee
|
||||
|
||||
# 坐席认证依赖(从 agents.py 导入)
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
# RBAC 权限装饰器
|
||||
from app.dependencies import UserInfo, get_current_user, get_redis, require_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 表情标签映射
|
||||
# --------------------------------------------------------------------------
|
||||
EMOJI_LABELS = {
|
||||
"satisfied": "满意",
|
||||
"neutral": "一般",
|
||||
"dissatisfied": "不满意",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversation/{conversation_id}/evaluate - 提交评价
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversation/{conversation_id}/evaluate")
|
||||
async def submit_evaluation(
|
||||
conversation_id: str,
|
||||
body: EvaluationSubmitRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
):
|
||||
"""提交满意度评价。
|
||||
|
||||
员工对已结束的会话进行满意度评价。
|
||||
评价要素:星级(1-5)、表情(satisfied/neutral/dissatisfied)、文字反馈(可选)。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 评价请求体
|
||||
db: 数据库会话
|
||||
employee_id: 当前员工ID(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含评价记录
|
||||
"""
|
||||
# 1. 获取员工姓名
|
||||
emp_stmt = select(Employee).where(Employee.employee_id == employee_id)
|
||||
emp_result = await db.execute(emp_stmt)
|
||||
employee = emp_result.scalars().first()
|
||||
employee_name = employee.name if employee else ""
|
||||
|
||||
# 2. 验证会话存在且已结单
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(3003, "会话不存在")
|
||||
if conversation.status != "resolved":
|
||||
raise AppException(3040, "只能评价已结单的会话")
|
||||
|
||||
# 3. 检查是否已评价(防止重复评价)
|
||||
existing_stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id,
|
||||
ConversationEvaluation.employee_id == employee_id,
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing = existing_result.scalars().first()
|
||||
|
||||
if existing:
|
||||
raise AppException(3041, "您已对该会话提交过评价")
|
||||
|
||||
# 4. 创建评价记录
|
||||
evaluation = ConversationEvaluation(
|
||||
id=None, # UUID自动生成
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
star_rating=body.star_rating,
|
||||
emoji=body.emoji,
|
||||
feedback_text=body.feedback_text,
|
||||
)
|
||||
db.add(evaluation)
|
||||
await db.commit()
|
||||
await db.refresh(evaluation)
|
||||
|
||||
logger.info(
|
||||
f"员工 {employee_name} 提交评价: "
|
||||
f"会话={conversation_id}, 星级={body.star_rating}, 表情={body.emoji}"
|
||||
)
|
||||
|
||||
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversation/{conversation_id}/evaluation")
|
||||
async def get_evaluation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话的评价记录。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含评价记录(如果已评价)
|
||||
"""
|
||||
stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
evaluation = result.scalars().first()
|
||||
|
||||
if not evaluation:
|
||||
return success_response(data=None)
|
||||
|
||||
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/evaluations/stats - 获取评价统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/evaluations/stats")
|
||||
@require_permission("evaluation", "read", "all")
|
||||
async def get_evaluation_stats(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""获取满意度评价统计数据。
|
||||
|
||||
供管理后台查看评价统计信息,包括:
|
||||
- 总评价数
|
||||
- 平均星级
|
||||
- 星级分布
|
||||
- 表情分布
|
||||
- 最近评价记录
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含统计数据
|
||||
"""
|
||||
# 1. 获取总评价数
|
||||
total_stmt = select(func.count(ConversationEvaluation.id))
|
||||
total_result = await db.execute(total_stmt)
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
# 2. 获取平均星级
|
||||
avg_stmt = select(func.avg(ConversationEvaluation.star_rating))
|
||||
avg_result = await db.execute(avg_stmt)
|
||||
avg_star_rating = float(avg_result.scalar() or 0)
|
||||
|
||||
# 3. 星级分布统计
|
||||
star_dist_stmt = select(
|
||||
ConversationEvaluation.star_rating,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).group_by(ConversationEvaluation.star_rating)
|
||||
star_dist_result = await db.execute(star_dist_stmt)
|
||||
star_rows = star_dist_result.all()
|
||||
|
||||
star_distribution = []
|
||||
for star in range(1, 6):
|
||||
count = next((row.count for row in star_rows if row.star_rating == star), 0)
|
||||
percentage = (count / total_count * 100) if total_count > 0 else 0
|
||||
star_distribution.append(
|
||||
EvaluationStatsItem(
|
||||
label=f"{star}星",
|
||||
count=count,
|
||||
percentage=round(percentage, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 表情分布统计
|
||||
emoji_dist_stmt = select(
|
||||
ConversationEvaluation.emoji,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).group_by(ConversationEvaluation.emoji)
|
||||
emoji_dist_result = await db.execute(emoji_dist_stmt)
|
||||
emoji_rows = emoji_dist_result.all()
|
||||
|
||||
emoji_distribution = []
|
||||
for emoji_key in ["satisfied", "neutral", "dissatisfied"]:
|
||||
count = next((row.count for row in emoji_rows if row.emoji == emoji_key), 0)
|
||||
percentage = (count / total_count * 100) if total_count > 0 else 0
|
||||
emoji_distribution.append(
|
||||
EvaluationStatsItem(
|
||||
label=EMOJI_LABELS.get(emoji_key, emoji_key),
|
||||
count=count,
|
||||
percentage=round(percentage, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 最近评价记录
|
||||
recent_stmt = (
|
||||
select(ConversationEvaluation)
|
||||
.order_by(ConversationEvaluation.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
recent_result = await db.execute(recent_stmt)
|
||||
recent_evaluations = recent_result.scalars().all()
|
||||
|
||||
recent_list = [
|
||||
EvaluationResponse.model_validate(e).model_dump()
|
||||
for e in recent_evaluations
|
||||
]
|
||||
|
||||
response_data = EvaluationStatsResponse(
|
||||
total_count=total_count,
|
||||
avg_star_rating=round(avg_star_rating, 2),
|
||||
star_distribution=star_distribution,
|
||||
emoji_distribution=emoji_distribution,
|
||||
recent_evaluations=recent_list,
|
||||
).model_dump()
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/send-evaluation-invite")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def send_evaluation_invite(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""发送评价邀请(坐席结单后触发)。
|
||||
|
||||
坐席点击"结单"后,系统自动向员工推送评价邀请消息。
|
||||
员工点击消息中的链接可进入H5页面提交评价。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
redis: Redis连接
|
||||
current_agent: 当前坐席
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 1. 验证会话存在
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(3003, "会话不存在")
|
||||
|
||||
# 2. 验证会话已结单
|
||||
if conversation.status != "resolved":
|
||||
raise AppException(3042, "只能对已结单的会话发送评价邀请")
|
||||
|
||||
# 3. 检查是否已评价
|
||||
eval_stmt = select(ConversationEvaluation).where(
|
||||
ConversationEvaluation.conversation_id == conversation_id
|
||||
)
|
||||
eval_result = await db.execute(eval_stmt)
|
||||
existing_eval = eval_result.scalars().first()
|
||||
|
||||
if existing_eval:
|
||||
raise AppException(3043, "该会话已收到评价,无需再次邀请")
|
||||
|
||||
# 4. 通过企微发送评价邀请消息
|
||||
try:
|
||||
wecom_service = WecomService(redis)
|
||||
|
||||
# 构建评价邀请消息内容
|
||||
agent_name = current_agent.name if current_agent else "IT服务台"
|
||||
content = (
|
||||
f"您好!您与 {agent_name} 的会话已结束。\n\n"
|
||||
f"请对本次服务进行评价,帮助我们改进服务质量。\n\n"
|
||||
f"点击下方链接进行评价 >>"
|
||||
)
|
||||
|
||||
# TODO: 后续接入企微应用消息推送
|
||||
# message_data = {
|
||||
# "touser": conversation.employee_id,
|
||||
# "msgtype": "text",
|
||||
# "agentid": settings.WECOM_AGENT_ID,
|
||||
# "text": {"content": content},
|
||||
# }
|
||||
# await wecom_service.send_message(message_data)
|
||||
|
||||
logger.info(
|
||||
f"发送评价邀请: 会话={conversation_id}, "
|
||||
f"员工={conversation.employee_id}, 坐席={agent_name}"
|
||||
)
|
||||
|
||||
# 关闭企微服务连接
|
||||
await wecom_service.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"发送评价邀请失败: {e}")
|
||||
# 失败不影响结单流程,只记录日志
|
||||
|
||||
return success_response(data={"message": "评价邀请已发送"})
|
||||
+616
-102
@@ -29,6 +29,7 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
import redis
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
from slowapi import Limiter
|
||||
@@ -43,7 +44,8 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler
|
||||
from app.utils.env_gating import is_production
|
||||
from app.dependencies import dep_redis, dep_wecom_service
|
||||
from app.models.approval_link import ApprovalLink
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
@@ -56,7 +58,9 @@ from app.schemas.h5 import (
|
||||
)
|
||||
from app.schemas.conversation import ConversationResponse, JoinConversationRequest
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.services.ai_handler import AIHandler
|
||||
import asyncio
|
||||
|
||||
from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -82,18 +86,18 @@ _WEWORK_UA_RE = re.compile(r"wxwork", re.IGNORECASE)
|
||||
def _require_wework_ua(request: Request) -> None:
|
||||
"""校验请求 User-Agent 是否来自企微 WebView。
|
||||
|
||||
生产环境下,非企微环境的 OAuth2 请求直接拒绝。
|
||||
本地开发(localhost / 127.0.0.1)跳过检测,方便调试。
|
||||
仅生产环境强制校验(env_gating.is_production()):
|
||||
非企微环境的 OAuth2 请求直接拒绝。
|
||||
本地开发 / dev / test 环境跳过检测,方便调试。
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 对象,用于读取 User-Agent 和 Host
|
||||
request: FastAPI Request 对象,用于读取 User-Agent
|
||||
|
||||
Raises:
|
||||
AppException: 非企微环境时抛出 403 错误
|
||||
AppException: 非企微环境且处于生产环境时抛出 4003 错误
|
||||
"""
|
||||
# 本地开发跳过检测
|
||||
host = request.headers.get("host", "")
|
||||
if host.startswith("localhost") or host.startswith("127.0.0.1"):
|
||||
# 仅生产环境强制校验;非生产环境(dev/test/本地)一律放行
|
||||
if not is_production():
|
||||
return
|
||||
|
||||
ua = request.headers.get("user-agent", "")
|
||||
@@ -169,16 +173,38 @@ async def _get_current_employee(
|
||||
# =====================================================================
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
if token and redis_client:
|
||||
try:
|
||||
employee_id_bytes = await redis_client.get(f"employee:token:{token}")
|
||||
if employee_id_bytes:
|
||||
# Redis 返回 bytes,需要解码
|
||||
return employee_id_bytes.decode("utf-8") if isinstance(employee_id_bytes, bytes) else employee_id_bytes
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 读取失败: {e}")
|
||||
if token:
|
||||
if redis_client:
|
||||
try:
|
||||
employee_id_bytes = await redis_client.get(f"employee:token:{token}")
|
||||
if employee_id_bytes:
|
||||
# Redis 返回 bytes,需要解码
|
||||
return employee_id_bytes.decode("utf-8") if isinstance(employee_id_bytes, bytes) else employee_id_bytes
|
||||
except AppException:
|
||||
raise
|
||||
except redis.exceptions.TimeoutError as e:
|
||||
logger.error(f"Redis 连接超时,无法验证 Bearer Token: {e}")
|
||||
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 超时,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 读取失败,无法验证 Bearer Token: {e}")
|
||||
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 异常,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
else:
|
||||
# redis_client 为 None:dep_redis 返回了 None(Redis 连接创建失败)
|
||||
logger.error("Redis 客户端不可用,无法验证 Bearer Token")
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 不可用,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
|
||||
# =====================================================================
|
||||
# 方式2:X-Employee-Id 明文头(仅开发环境,生产环境禁用)
|
||||
@@ -230,15 +256,16 @@ async def get_oauth_authorize_url(
|
||||
elif request_host:
|
||||
# 从 Host 头构造回调地址(支持 http 和 https)
|
||||
scheme = "https" # 企微H5应用通常使用 https
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itportal/", safe="")
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itdesk/", safe="")
|
||||
else:
|
||||
# 最终降级:使用配置中的 CORS 源地址
|
||||
default_origin = settings.cors_origins_list[0] if settings.cors_origins_list else "https://localhost"
|
||||
encoded_redirect = quote(f"{default_origin}/itportal/", safe="")
|
||||
encoded_redirect = quote(f"{default_origin}/itdesk/", safe="")
|
||||
|
||||
# 构造企微OAuth2静默授权URL(snsapi_base:用户无感知)
|
||||
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
||||
authorize_url = (
|
||||
f"https://open.weixin.qq.com/connect/oauth2/authorize"
|
||||
f"https://open.work.weixin.qq.com/connect/oauth2/authorize"
|
||||
f"?appid={corp_id}"
|
||||
f"&redirect_uri={encoded_redirect}"
|
||||
f"&response_type=code"
|
||||
@@ -305,6 +332,7 @@ async def oauth_callback(
|
||||
position = ""
|
||||
avatar = ""
|
||||
|
||||
# 2.1 获取员工详细信息(包含头像)
|
||||
try:
|
||||
detail = await wecom_service.get_user_info(employee_id)
|
||||
employee_name = detail.get("name", "")
|
||||
@@ -314,8 +342,54 @@ async def oauth_callback(
|
||||
department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
|
||||
position = detail.get("position", "")
|
||||
avatar = detail.get("avatar", "")
|
||||
except Exception:
|
||||
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}")
|
||||
|
||||
# 调试日志:检查企微API返回的完整数据
|
||||
logger.info(f"企微用户详情返回: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 2.2 将员工信息保存到数据库(包含头像)
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(Employee).where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
|
||||
if employee:
|
||||
# 更新已有记录
|
||||
employee.name = employee_name
|
||||
employee.department = department
|
||||
employee.position = position
|
||||
# 【FE-UA-005 优化】每次登录强制更新头像URL + 清缓存(统一走 avatar_service)
|
||||
# 头像更新失败不阻塞登录(sync_employee_avatar 内部已容错)
|
||||
if avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
||||
else:
|
||||
# 创建新记录
|
||||
employee = Employee(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=employee_id,
|
||||
name=employee_name,
|
||||
department=department,
|
||||
position=position,
|
||||
avatar=avatar,
|
||||
)
|
||||
db.add(employee)
|
||||
logger.info(f"创建员工记录(含头像): employee_id={employee_id}")
|
||||
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
|
||||
# 不阻塞登录流程,继续执行
|
||||
|
||||
# 3. 生成 Bearer Token(与坐席端一致:secrets.token_urlsafe(32))
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -369,6 +443,137 @@ async def oauth_callback(
|
||||
raise AppException(2007, f"OAuth2授权失败: {e}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/oauth/sns-callback — 企微 OAuth2 静默授权回调(302 重定向版)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/oauth/sns-callback")
|
||||
async def oauth_sns_callback(
|
||||
request: Request,
|
||||
code: str = Query(..., description="企微 OAuth2 授权码"),
|
||||
state: Optional[str] = Query(None, description="透传参数(保留兼容,未使用)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis_client: Optional[aioredis.Redis] = Depends(dep_redis),
|
||||
wecom_service: WecomService = Depends(dep_wecom_service),
|
||||
):
|
||||
"""企微 OAuth2 静默授权回调(snsapi_base → 302 带 ?token=)。
|
||||
|
||||
适用于 snsapi_base 静默授权:企微回调到此端点并携带 code,
|
||||
后端用 code 换取员工身份 → 生成 Bearer Token → 302 重定向到
|
||||
H5 前端页面,并在 URL 上附带 ?token=,供前端镜像到 localStorage。
|
||||
|
||||
仅生产环境强制 UA 校验(与 _require_wework_ua 一致,使用 env_gating)。
|
||||
|
||||
Args:
|
||||
code: 企微授权码
|
||||
state: 透传参数(未使用,保留兼容)
|
||||
db: 数据库会话
|
||||
redis_client: 共享 Redis 客户端(DI 注入)
|
||||
wecom_service: 共享企微服务(DI 注入)
|
||||
|
||||
Returns:
|
||||
RedirectResponse -> {scheme}://{host}/itdesk/?token={token}
|
||||
"""
|
||||
# 仅生产环境强制 UA 校验
|
||||
if is_production():
|
||||
ua = request.headers.get("user-agent", "")
|
||||
if not _WEWORK_UA_RE.search(ua):
|
||||
raise AppException(4003, "请在企业微信中访问此服务")
|
||||
|
||||
# 1. 用 code 换取员工身份
|
||||
user_info = await wecom_service.get_oauth_user_info(code)
|
||||
employee_id = user_info.get("userid", "")
|
||||
if not employee_id:
|
||||
raise AppException(2007, "OAuth2授权失败:未获取到员工ID")
|
||||
|
||||
# 2. 获取员工详细信息(姓名、部门、岗位、头像)
|
||||
employee_name = ""
|
||||
department = ""
|
||||
position = ""
|
||||
avatar = ""
|
||||
try:
|
||||
detail = await wecom_service.get_user_info(employee_id)
|
||||
employee_name = detail.get("name", "")
|
||||
dept_ids = detail.get("department", [])
|
||||
department = ",".join(str(d) for d in dept_ids) if dept_ids else ""
|
||||
position = detail.get("position", "")
|
||||
avatar = detail.get("avatar", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 3. 落库 / 更新员工信息(含头像)
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
|
||||
stmt = select(Employee).where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee:
|
||||
employee.name = employee_name
|
||||
employee.department = department
|
||||
employee.position = position
|
||||
if avatar:
|
||||
try:
|
||||
from app.services.avatar_service import sync_employee_avatar
|
||||
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
||||
else:
|
||||
employee = Employee(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=employee_id,
|
||||
name=employee_name,
|
||||
department=department,
|
||||
position=position,
|
||||
avatar=avatar,
|
||||
)
|
||||
db.add(employee)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 4. 生成 Bearer Token 并写入 Redis
|
||||
token = secrets.token_urlsafe(32)
|
||||
if redis_client:
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"employee:token:{token}",
|
||||
EMPLOYEE_TOKEN_TTL_SECONDS,
|
||||
employee_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入失败(token 不会持久化): {e}")
|
||||
|
||||
employee_info_cache = {
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name,
|
||||
"department": department,
|
||||
"position": position,
|
||||
"avatar": avatar,
|
||||
}
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"employee:info:{employee_id}",
|
||||
EMPLOYEE_TOKEN_TTL_SECONDS,
|
||||
json.dumps(employee_info_cache, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"员工信息缓存写入失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"OAuth2 sns-callback 授权成功: employee_id={employee_id}, name={employee_name}")
|
||||
|
||||
# 5. 302 重定向到 H5 前端页面,附带 ?token= 供前端镜像到 localStorage
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
host = request.headers.get("host", "")
|
||||
scheme = "https"
|
||||
landing = "/itdesk/"
|
||||
redirect_url = f"{scheme}://{host}{landing}?token={token}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/mock-login — Mock 登录(测试阶段,跳过 OAuth2)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -619,7 +824,6 @@ async def h5_send_message(
|
||||
body: dict,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
ai_handler: AIHandler = Depends(dep_ai_handler),
|
||||
):
|
||||
"""H5 用户发送消息(含 AI 回复与计数)。
|
||||
|
||||
@@ -690,47 +894,9 @@ async def h5_send_message(
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 3. 调用 AIHandler 统一处理(打招呼检测 → 呼叫人工拦截 → AI 调用)
|
||||
ai_result = await ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
)
|
||||
|
||||
# 4. 根据 AIHandler 返回结果更新会话状态
|
||||
# 更新 Dify 会话ID(多轮对话上下文)
|
||||
if ai_result.dify_conversation_id:
|
||||
conversation.dify_conversation_id = ai_result.dify_conversation_id
|
||||
|
||||
# 更新 AI 实质性回复计数(仅 AI 命中时 +1)
|
||||
if ai_result.should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
|
||||
# 更新会话状态(未命中转人工时改为 queued)
|
||||
if ai_result.should_transfer:
|
||||
conversation.status = "queued"
|
||||
|
||||
db.add(conversation)
|
||||
|
||||
# 5. 创建 AI 回复消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="AI智能助手",
|
||||
content=ai_result.content,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 6. WebSocket 广播:通知坐席端有新消息
|
||||
# 做什么:向所有在线坐席广播 new_message 事件,携带用户消息和 AI 回复
|
||||
# 为什么:坐席端需要实时看到员工的新消息和 AI 回复,
|
||||
# 仅靠3秒轮询会有延迟,WS 推送更实时
|
||||
# 3. 广播用户消息给坐席端(员工端靠乐观更新已显示自己消息)
|
||||
# 为什么:坐席端需实时看到员工新消息,仅依赖 3 秒轮询会有延迟
|
||||
try:
|
||||
# 广播用户消息
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
@@ -745,41 +911,30 @@ async def h5_send_message(
|
||||
"tags": conversation.tags,
|
||||
},
|
||||
})
|
||||
# 广播 AI 回复
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(ai_message.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "AI智能助手",
|
||||
"content": ai_result.content,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
# 如果会话状态变更(如新会话创建或转人工),也广播状态变更
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_updated",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"status": conversation.status,
|
||||
"assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
# WS 广播失败不阻塞消息存储,只记录 warning
|
||||
logger.warning(f"WS 广播新消息失败(消息已存储): {ws_err}")
|
||||
logger.warning(f"WS 广播用户消息失败(消息已存储): {ws_err}")
|
||||
|
||||
# 7. 返回用户消息 + AI 回复
|
||||
# 4. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
||||
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
||||
# 发送接口瞬时返回,前端不再卡"发送中"
|
||||
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
conversation_id=str(conversation.id),
|
||||
employee_id=employee_id,
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 立即返回用户消息(AI 回复经 WS 异步推送,不在此同步返回)
|
||||
user_msg_data = MessageResponse.model_validate(message).model_dump()
|
||||
ai_msg_data = MessageResponse.model_validate(ai_message).model_dump()
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"user_message": user_msg_data,
|
||||
"ai_reply": ai_msg_data,
|
||||
"is_guidance": ai_result.is_guidance,
|
||||
"ai_reply": None,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
@@ -788,8 +943,75 @@ async def h5_send_message(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
# GET /api/h5/conversations/current/messages — 用户获取消息列表(历史消息)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/conversations/current/messages")
|
||||
async def h5_get_messages(
|
||||
limit: int = Query(50, description="每页消息数量,默认50"),
|
||||
before: Optional[str] = Query(None, description="获取此消息ID之前的消息(向上翻页)"),
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""H5 用户获取消息列表(历史消息)。
|
||||
|
||||
前端在进入会话或切换会话时调用,获取完整的消息历史记录。
|
||||
支持分页向上翻页(通过 before 参数)。
|
||||
|
||||
Args:
|
||||
limit: 每页消息数量(默认50)
|
||||
before: 消息ID,获取此消息之前的消息(向上翻页)
|
||||
employee_id: 员工企微 UserID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含消息列表和 has_more 标志
|
||||
"""
|
||||
# 查找当前会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
return success_response(data={"items": [], "has_more": False})
|
||||
|
||||
# 查询消息列表
|
||||
msg_stmt = select(Message).where(
|
||||
Message.conversation_id == conversation.id
|
||||
).order_by(Message.created_at.desc()).limit(limit)
|
||||
|
||||
# 如果指定了 before,获取此消息之前的消息
|
||||
if before:
|
||||
try:
|
||||
from uuid import UUID as UUIDType
|
||||
UUIDType(before) # 仅校验格式
|
||||
|
||||
# 查询 before 消息的创建时间
|
||||
before_stmt = select(Message.created_at).where(
|
||||
Message.id == str(before)
|
||||
)
|
||||
before_result = await db.execute(before_stmt)
|
||||
before_time = before_result.scalar_one_or_none()
|
||||
|
||||
if before_time:
|
||||
msg_stmt = msg_stmt.where(Message.created_at < before_time)
|
||||
except ValueError:
|
||||
pass # 无效的UUID格式,忽略 before 参数
|
||||
|
||||
msg_result = await db.execute(msg_stmt)
|
||||
messages = list(msg_result.scalars().all())
|
||||
|
||||
# 反转顺序(按时间正序返回)
|
||||
messages.reverse()
|
||||
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
# 判断是否还有更多:查询的消息数是否等于 limit
|
||||
has_more = len(messages) == limit
|
||||
|
||||
return success_response(data={"items": items, "has_more": has_more})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
@@ -829,18 +1051,21 @@ async def h5_poll_messages(
|
||||
).order_by(Message.created_at.asc())
|
||||
|
||||
if after_message_id:
|
||||
# 转换为UUID类型查询,确保和数据库UUID字段类型匹配
|
||||
# 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型不匹配)
|
||||
from uuid import UUID as UUIDType
|
||||
|
||||
try:
|
||||
msg_uuid = UUIDType(after_message_id)
|
||||
UUIDType(after_message_id) # 仅校验
|
||||
except ValueError:
|
||||
# 无效的UUID格式,返回空列表
|
||||
# 无效的UUID格式,返回空列表
|
||||
items = []
|
||||
return success_response(data={"items": items, "has_more": False})
|
||||
|
||||
# 必须用字符串比较,Message.id 在 DB 里是 String(36)/VARCHAR,
|
||||
# 传 UUID 对象会被 SQLAlchemy 推断成 UUID 类型 → PostgreSQL 报
|
||||
# "operator does not exist: character varying = uuid"
|
||||
after_stmt = select(Message.created_at).where(
|
||||
Message.id == msg_uuid
|
||||
Message.id == str(after_message_id)
|
||||
)
|
||||
after_result = await db.execute(after_stmt)
|
||||
after_time = after_result.scalar_one_or_none()
|
||||
@@ -900,14 +1125,14 @@ async def shake(
|
||||
# 无活跃会话 → 拒绝,必须先与 AI 互动(前端按钮此时不应出现,这是后端兜底)
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
@@ -948,18 +1173,307 @@ async def shake(
|
||||
except Exception as e:
|
||||
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}")
|
||||
# 5. 自动分配空闲坐席
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.models.agent import Agent
|
||||
|
||||
# 5. 返回会话信息和话术
|
||||
assigned_agent_id: Optional[str] = None
|
||||
assign_result: str = "queued"
|
||||
|
||||
# 查找在线且未满负荷的坐席(按当前负载升序,取第一个)
|
||||
stmt = select(Agent).where(
|
||||
Agent.status == "online",
|
||||
Agent.current_load < Agent.max_load
|
||||
).order_by(Agent.current_load).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
available_agent = result.scalars().first()
|
||||
|
||||
if available_agent:
|
||||
# 找到空闲坐席,分配给该会话
|
||||
try:
|
||||
session_service = SessionService(db, wecom_service)
|
||||
await session_service.assign_agent(conversation.id, available_agent.user_id)
|
||||
assigned_agent_id = available_agent.user_id
|
||||
assign_result = "assigned"
|
||||
logger.info(f"自动分配坐席: conv_id={conversation.id}, agent={assigned_agent_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"自动分配坐席失败: {e}")
|
||||
assign_result = "assign_failed"
|
||||
else:
|
||||
# 无空闲坐席,进入排队(会话状态保持 queued,由 AI 未命中时自动处理)
|
||||
assign_result = "queued"
|
||||
logger.info(f"无空闲坐席,会话进入排队: conv_id={conversation.id}")
|
||||
|
||||
# 6. 广播 new_conversation 事件通知所有坐席
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_conversation",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"employee_id": employee_id,
|
||||
"employee_name": employee_name or "未知用户",
|
||||
"urgency_score": conversation.urgency_score,
|
||||
"hand_raise": True,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
"assign_result": assign_result,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}")
|
||||
|
||||
# 7. 返回会话信息和话术
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(
|
||||
data={
|
||||
"conversation": conv_data,
|
||||
"funny_phrase": phrase,
|
||||
"assign_result": assign_result,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/call-agent — 摇人按钮触发转人工
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/call-agent")
|
||||
async def call_agent(
|
||||
body: ShakeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wecom_service: Optional[WecomService] = Depends(dep_wecom_service),
|
||||
):
|
||||
"""摇人按钮 - 呼叫坐席。
|
||||
|
||||
用户点击摇人按钮后,触发转人工流程:
|
||||
1. 查找当前会话
|
||||
2. 校验AI回复次数 >= 3(与shake一致)
|
||||
3. 将会话状态改为 queued(排队中)
|
||||
4. 尝试分配空闲坐席
|
||||
5. 发送系统消息通知用户
|
||||
6. 通过企微消息通知坐席
|
||||
|
||||
Args:
|
||||
body: 呼叫坐席请求体(包含 employee_id 和 employee_name)
|
||||
db: 数据库会话
|
||||
wecom_service: 共享企微服务(DI 注入)
|
||||
|
||||
Returns:
|
||||
Dict: 包含会话信息和排队状态
|
||||
"""
|
||||
from app.services.session_service import SessionService
|
||||
|
||||
employee_id = body.employee_id
|
||||
employee_name = body.employee_name
|
||||
|
||||
# 1. 查找当前活跃会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
if employee_name and not conversation.employee_name:
|
||||
conversation.employee_name = employee_name
|
||||
|
||||
# 3. 将会话状态改为 queued(排队中)
|
||||
conversation.status = "queued"
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.updated_at = datetime.now()
|
||||
|
||||
# 设置紧急度加分
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
tags["user_called_agent"] = True # 标记用户主动呼叫
|
||||
conversation.tags = tags
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 4. 尝试分配空闲坐席
|
||||
session_service = SessionService(db)
|
||||
assigned_agent = await session_service.auto_assign_agent(conversation.id)
|
||||
|
||||
# 5. 获取趣味话术
|
||||
funny_phrase_service = FunnyPhraseService(db)
|
||||
is_vip = conversation.is_vip
|
||||
phrase = await funny_phrase_service.get_phrase("transfer", is_vip=is_vip)
|
||||
|
||||
# 6. 创建系统消息
|
||||
system_content = phrase
|
||||
if assigned_agent:
|
||||
system_content = f"{phrase}\n\n为您服务的是:{assigned_agent.name}"
|
||||
conversation.status = "serving"
|
||||
conversation.assigned_agent_id = assigned_agent.user_id
|
||||
|
||||
system_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="系统",
|
||||
content=system_content,
|
||||
msg_type="system",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(system_msg)
|
||||
|
||||
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||
if wecom_service:
|
||||
try:
|
||||
await wecom_service.send_text_message(employee_id, system_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
||||
|
||||
# 8. 如果分配了坐席,通知坐席有新会话
|
||||
if assigned_agent and wecom_service:
|
||||
try:
|
||||
notify_phrase = f"新会话:{employee_name} 呼叫人工服务,请及时接单"
|
||||
# 获取坐席的userid并发送通知(需要坐席绑定企微)
|
||||
# 此处简化处理,仅记录日志
|
||||
logger.info(f"分配坐席: agent_id={assigned_agent.id}, employee_id={employee_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"坐席通知失败: {e}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"呼叫坐席: employee_id={employee_id}, conv_id={conversation.id}, agent_id={assigned_agent.id if assigned_agent else 'None'}")
|
||||
|
||||
# 9. 返回结果
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(
|
||||
data={
|
||||
"conversation": conv_data,
|
||||
"status": conversation.status,
|
||||
"queue_position": 1 if not assigned_agent else None,
|
||||
"estimated_wait_seconds": 30 if not assigned_agent else 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/queue-status — 查询排队状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/h5/conversations/current/queue-status")
|
||||
async def get_queue_status(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询当前排队状态。
|
||||
|
||||
返回当前会话的排队位置和预计等待时间。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
Dict: 排队状态信息
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
# 1. 查找该员工的排队会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status == "queued",
|
||||
).order_by(Conversation.created_at.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
# 不在排队中,可能是已分配或无会话
|
||||
return success_response(data={
|
||||
"in_queue": False,
|
||||
"status": None,
|
||||
"queue_position": None,
|
||||
"estimated_wait_seconds": 0,
|
||||
})
|
||||
|
||||
# 2. 计算排队位置(按创建时间排序)
|
||||
count_stmt = select(func.count(Conversation.id)).where(
|
||||
Conversation.status == "queued",
|
||||
Conversation.created_at < conversation.created_at,
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
queue_position = count_result.scalar() or 0
|
||||
|
||||
# 3. 计算预计等待时间(基于平均处理时长5分钟)
|
||||
estimated_wait_seconds = queue_position * 300 # 5分钟/人
|
||||
|
||||
return success_response(data={
|
||||
"in_queue": True,
|
||||
"status": conversation.status,
|
||||
"queue_position": queue_position + 1,
|
||||
"estimated_wait_seconds": estimated_wait_seconds,
|
||||
"conversation_id": str(conversation.id),
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/cancel-queue — 取消排队
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/cancel-queue")
|
||||
async def cancel_queue(
|
||||
body: ShakeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""取消排队。
|
||||
|
||||
用户主动取消排队,释放排队位置。
|
||||
|
||||
Args:
|
||||
body: 包含 employee_id
|
||||
|
||||
Returns:
|
||||
Dict: 操作结果
|
||||
"""
|
||||
employee_id = body.employee_id
|
||||
|
||||
# 1. 查找排队中的会话
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status == "queued",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise AppException(code=1004, message="您当前不在排队中")
|
||||
|
||||
# 2. 将会话状态改回 ai_handling
|
||||
conversation.status = "ai_handling"
|
||||
conversation.updated_at = datetime.now()
|
||||
|
||||
# 移除用户主动呼叫标记
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
tags.pop("user_called_agent", None)
|
||||
conversation.tags = tags
|
||||
|
||||
db.add(conversation)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"取消排队: employee_id={employee_id}, conv_id={conversation.id}")
|
||||
|
||||
return success_response(data={
|
||||
"message": "已取消排队,会话将继续由AI服务",
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/approval-links — 获取审批流程链接
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 高危操作演示 API
|
||||
# =============================================================================
|
||||
# Phase 1.3 task #19: 高危操作路由白名单 + 中间件演示
|
||||
# 决策来源:otm-secondary-auth.md(2026-06-21)
|
||||
#
|
||||
# 设计原则:
|
||||
# 本文件只演示 require_high_risk_otp 依赖的用法,不重复实现业务。
|
||||
# 实际业务端点(admin_rbac.py / admin_api.py)在后续 worktree 中追加
|
||||
# Depends(require_high_risk_otp) 即可生效。
|
||||
#
|
||||
# 演示端点:
|
||||
# POST /api/admin/high-risk/demo/{category} — 用 5 个 category 各跑一遍
|
||||
# GET /api/admin/high-risk/whitelist — 获取白名单(前端文档化用)
|
||||
# GET /api/admin/high-risk/check — 检查当前管理员 OTP 状态
|
||||
#
|
||||
# 鉴权:
|
||||
# - demo/{category}: 需 admin 角色 + 30 分钟内 OTP 验证
|
||||
# - whitelist: 仅 admin 角色(不需要 OTP,纯查询)
|
||||
# - check: 仅 admin 角色(不需要 OTP,纯查询自己状态)
|
||||
#
|
||||
# 错误码:
|
||||
# 2001 = 高危操作需要 OTP 二次验证
|
||||
# 4003 = 仅管理员可执行此操作
|
||||
# 4000 = 未知的高危操作类别
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.dependencies import (
|
||||
HIGH_RISK_OPERATIONS,
|
||||
UserInfo,
|
||||
require_high_risk_otp,
|
||||
)
|
||||
from app.services.high_risk_guard import HighRiskGuard
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 路由器
|
||||
# -----------------------------------------------------------------------------
|
||||
# prefix: /admin/high-risk
|
||||
# 完整路径前缀: /api/admin/high-risk
|
||||
# -----------------------------------------------------------------------------
|
||||
router = APIRouter(prefix="/admin/high-risk")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 演示端点 1: POST /api/admin/high-risk/demo/{category}
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.post(
|
||||
"/demo/{category}",
|
||||
summary="演示高危操作 OTP 守卫",
|
||||
description=(
|
||||
"展示 5 类高危操作(role_change / config_change / data_export / "
|
||||
"account_disable / account_create_reset)的 OTP 守卫流程。<br><br>"
|
||||
"调用此端点时,如果当前管理员 30 分钟内没在 /api/mfa/verify 过 OTP,"
|
||||
"会返回错误码 2001,前端应弹 OTP 输入框 → 调 /api/mfa/verify → 重试。"
|
||||
),
|
||||
)
|
||||
async def demo_high_risk_op(
|
||||
category: str,
|
||||
current_user: UserInfo = Depends(require_high_risk_otp),
|
||||
) -> Dict[str, Any]:
|
||||
"""演示:展示高危操作 OTP 守卫。
|
||||
|
||||
触发流程:
|
||||
1. 前端调 POST /api/admin/high-risk/demo/role_change
|
||||
2. require_high_risk_otp 依赖先跑:
|
||||
a. 检查 admin 角色(否则 4003)
|
||||
b. 检查 Redis mfa:verified:{employee_id}(否则 2001)
|
||||
3. 通过守卫 → 返回 success
|
||||
|
||||
Args:
|
||||
category: 5 类之一 (role_change / config_change / data_export /
|
||||
account_disable / account_create_reset)
|
||||
current_user: 当前管理员(依赖自动注入)
|
||||
|
||||
Returns:
|
||||
Dict: 演示结果
|
||||
|
||||
Raises:
|
||||
AppException(4000): 未知的高危操作类别
|
||||
AppException(4003): 非 admin 角色(来自 require_high_risk_otp)
|
||||
AppException(2001): 未在 30 分钟内过 OTP(来自 require_high_risk_otp)
|
||||
"""
|
||||
# 第 1 关:类别校验
|
||||
if category not in HIGH_RISK_OPERATIONS:
|
||||
valid_categories = ", ".join(HIGH_RISK_OPERATIONS.keys())
|
||||
raise AppException(
|
||||
code=4000,
|
||||
message=f"未知的高危操作类别: {category}。合法值: {valid_categories}",
|
||||
)
|
||||
|
||||
# 第 2 关:模拟执行(不真正改数据,只演示守卫通过)
|
||||
op_meta = HIGH_RISK_OPERATIONS[category]
|
||||
|
||||
logger.info(
|
||||
f"演示高危操作 {category} 执行: "
|
||||
f"employee_id={current_user.employee_id}, "
|
||||
f"category={op_meta['category']}"
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"category": category,
|
||||
"operation": op_meta,
|
||||
"executed_by": current_user.employee_id,
|
||||
"executed_by_name": current_user.name,
|
||||
"message": (
|
||||
f"演示操作 [{op_meta['category']}/{category}] 已通过 OTP 守卫"
|
||||
),
|
||||
"note": "本端点仅演示 OTP 守卫流程,不实际修改数据",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 演示端点 2: GET /api/admin/high-risk/whitelist
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/whitelist",
|
||||
summary="获取高危操作白名单",
|
||||
description="返回 5 类高危操作的元数据,供前端文档化展示。",
|
||||
)
|
||||
async def get_whitelist(
|
||||
current_user: UserInfo = Depends(require_high_risk_otp),
|
||||
) -> Dict[str, Any]:
|
||||
"""获取 5 类高危操作白名单。
|
||||
|
||||
注意:此端点也加 require_high_risk_otp,因为白名单本身属于敏感元数据。
|
||||
实际生产中可改为仅 require_admin,降低前端文档加载的复杂度。
|
||||
这里为了演示一致性,统一加 OTP 守卫。
|
||||
|
||||
Args:
|
||||
current_user: 当前管理员(依赖自动注入)
|
||||
|
||||
Returns:
|
||||
Dict: 白名单 + 分类元数据
|
||||
"""
|
||||
return success_response(
|
||||
data={
|
||||
"whitelist": HighRiskGuard.get_whitelist(),
|
||||
"total_categories": len(HighRiskGuard.list_categories()),
|
||||
"categories": HighRiskGuard.list_categories(),
|
||||
"ttl_seconds": HighRiskGuard.DEFAULT_TTL_SECONDS,
|
||||
"ttl_human": "30 分钟",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 演示端点 3: GET /api/admin/high-risk/check
|
||||
# -----------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/check",
|
||||
summary="检查当前管理员 OTP 验证状态",
|
||||
description=(
|
||||
"查询当前管理员是否在 30 分钟内通过过 OTP。"
|
||||
"前端在弹 OTP 输入框前先调一次此端点,如果已验证就不弹。"
|
||||
),
|
||||
)
|
||||
async def check_otp_status(
|
||||
current_user: UserInfo = Depends(require_high_risk_otp),
|
||||
) -> Dict[str, Any]:
|
||||
"""检查当前管理员 OTP 验证状态。
|
||||
|
||||
用途:前端可在做高危操作前先调此端点决定要不要弹 OTP 输入框。
|
||||
|
||||
Args:
|
||||
current_user: 当前管理员(依赖自动注入)
|
||||
|
||||
Returns:
|
||||
Dict: 验证状态
|
||||
"""
|
||||
# 注:能进到这里说明 require_high_risk_otp 已经检查过 Redis,
|
||||
# 这里再用 service 查一次拿详细信息(method/verified_at)
|
||||
# 由于没有 redis_client 直接传入,这里返回简化结果
|
||||
return success_response(
|
||||
data={
|
||||
"employee_id": current_user.employee_id,
|
||||
"is_verified": True, # 已经通过守卫 = verified
|
||||
"message": "当前管理员 OTP 已验证,可以执行高危操作",
|
||||
"note": "本端点本身需要 OTP 守卫,所以必然返回 is_verified=True",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库 API
|
||||
# =============================================================================
|
||||
# 说明:知识库FAQ管理接口,包括:
|
||||
# 1. GET /api/knowledge - 获取知识库列表
|
||||
# 2. POST /api/knowledge - 创建知识条目
|
||||
# 3. PUT /api/knowledge/{id} - 更新知识条目
|
||||
# 4. DELETE /api/knowledge/{id} - 删除知识条目
|
||||
# 5. GET /api/knowledge/search - 搜索知识
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.schemas.knowledge_base import (
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseResponse,
|
||||
KnowledgeBaseUpdate,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/knowledge — 获取知识库列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/knowledge")
|
||||
async def list_knowledge(
|
||||
category: Optional[str] = Query(None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库列表。
|
||||
|
||||
支持按分类筛选和关键词搜索。
|
||||
|
||||
Args:
|
||||
category: 按分类筛选(可选)
|
||||
keyword: 关键词搜索(可选,搜索标题和内容)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含知识库列表
|
||||
"""
|
||||
stmt = select(KnowledgeBase).order_by(KnowledgeBase.view_count.desc())
|
||||
|
||||
if category:
|
||||
stmt = stmt.where(KnowledgeBase.category == category)
|
||||
|
||||
if keyword:
|
||||
# 关键词搜索:标题或内容包含关键字
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
KnowledgeBase.title.ilike(f"%{keyword}%"),
|
||||
KnowledgeBase.content.ilike(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
data = [KnowledgeBaseResponse.model_validate(t).model_dump() for t in items]
|
||||
return success_response(data={"items": data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/knowledge — 创建知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/knowledge")
|
||||
async def create_knowledge(
|
||||
body: KnowledgeBaseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建知识库条目。
|
||||
|
||||
Args:
|
||||
body: 创建请求体
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的知识条目
|
||||
"""
|
||||
knowledge = KnowledgeBase(
|
||||
category=body.category,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
tags=body.tags,
|
||||
)
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"创建知识库条目: category={body.category}, title={body.title}")
|
||||
|
||||
data = KnowledgeBaseResponse.model_validate(knowledge).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id} — 更新知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}")
|
||||
async def update_knowledge(
|
||||
knowledge_id: UUID,
|
||||
body: KnowledgeBaseUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新知识库条目。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
body: 更新请求体
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的知识条目
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 只更新传入的字段
|
||||
if body.category is not None:
|
||||
knowledge.category = body.category
|
||||
if body.title is not None:
|
||||
knowledge.title = body.title
|
||||
if body.content is not None:
|
||||
knowledge.content = body.content
|
||||
if body.tags is not None:
|
||||
knowledge.tags = body.tags
|
||||
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"更新知识库条目: id={knowledge_id}")
|
||||
|
||||
data = KnowledgeBaseResponse.model_validate(knowledge).model_dump()
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DELETE /api/knowledge/{id} — 删除知识条目
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/knowledge/{knowledge_id}")
|
||||
async def delete_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除知识库条目。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
await db.delete(knowledge)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"删除知识库条目: id={knowledge_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id}/view — 更新查看次数
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}/view")
|
||||
async def view_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""记录知识库条目被查看。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
knowledge.view_count += 1
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
return success_response(data={"view_count": knowledge.view_count})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/knowledge/{id}/use — 更新使用次数
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/knowledge/{knowledge_id}/use")
|
||||
async def use_knowledge(
|
||||
knowledge_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""记录知识库条目被使用(坐席引用)。
|
||||
|
||||
Args:
|
||||
knowledge_id: 知识ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
stmt = select(KnowledgeBase).where(KnowledgeBase.id == knowledge_id)
|
||||
result = await db.execute(stmt)
|
||||
knowledge = result.scalars().first()
|
||||
|
||||
if not knowledge:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
knowledge.use_count += 1
|
||||
db.add(knowledge)
|
||||
await db.flush()
|
||||
|
||||
return success_response(data={"use_count": knowledge.use_count})
|
||||
@@ -0,0 +1,567 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API(Tier1 扩展)
|
||||
# =============================================================================
|
||||
# 说明:知识库自动迭代相关接口(扩展版)。
|
||||
# 1. POST /api/admin/knowledge-iteration/analyze - 触发分析并生成建议
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表(支持 audience/confidence 筛选)
|
||||
# 3. GET /api/admin/knowledge-iteration/suggestions/{id} - 获取建议详情
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过(触发Neo4j写图)
|
||||
# 5. POST /api/admin/knowledge-iteration/suggestions/{id}/reject - 审核拒绝
|
||||
# 6. POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite - 改写提案(Tier1新增)
|
||||
# 7. POST /api/admin/knowledge-iteration/suggestions/{id}/queue - 放入独立队列(Tier1新增)
|
||||
# 8. POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve - 队列中审批(Tier1新增)
|
||||
# 9. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import (
|
||||
KnowledgeSuggestionListResponse,
|
||||
KnowledgeSuggestionResponse,
|
||||
KnowledgeSuggestionStatsResponse,
|
||||
KnowledgeSuggestionApprove,
|
||||
KnowledgeSuggestionReject,
|
||||
KnowledgeSuggestionRewrite,
|
||||
KnowledgeSuggestionMerge,
|
||||
)
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 触发分析
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/analyze
|
||||
@router.post("/analyze")
|
||||
@require_admin
|
||||
async def trigger_analysis(
|
||||
days: int = Query(default=7, ge=1, le=90, description="分析过去N天的数据"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""触发知识库迭代分析。
|
||||
|
||||
分析过去N天的标注数据和会话数据,调用 Dify AI 自动生成优化建议。
|
||||
|
||||
- **days**: 分析过去N天的数据(默认7天,最大90天)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(f"管理员 {current_user.name} 触发了知识库迭代分析, days={days}")
|
||||
|
||||
result = await service.analyze_and_generate_suggestions(db, days=days)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "分析完成",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议列表(Tier1 扩展:audience/confidence 筛选)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions
|
||||
@router.get("/suggestions")
|
||||
@require_admin
|
||||
async def list_suggestions(
|
||||
status: Optional[str] = Query(default=None, description="筛选状态:pending/queued/approved/rejected/applied/graph_synced/expired"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型:new_faq/update/outdated"),
|
||||
audience: Optional[str] = Query(default=None, description="筛选受众:employee_quick_reply/engineer_workguide"),
|
||||
confidence_min: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度下限"),
|
||||
confidence_max: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度上限"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议列表(Tier1 扩展:支持 audience/confidence 筛选)。
|
||||
|
||||
- **status**: 筛选状态
|
||||
- **suggestion_type**: 筛选类型
|
||||
- **audience**: 按受众类型筛选(Tier1 新增)
|
||||
- **confidence_min**: 置信度下限(Tier1 新增)
|
||||
- **confidence_max**: 置信度上限(Tier1 新增)
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
|
||||
# 构建查询
|
||||
stmt = select(KnowledgeSuggestion).order_by(
|
||||
KnowledgeSuggestion.created_at.desc()
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
stmt = stmt.where(KnowledgeSuggestion.suggestion_type == suggestion_type)
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
suggestions = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(KnowledgeSuggestion)
|
||||
if status:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
count_stmt = count_stmt.where(
|
||||
KnowledgeSuggestion.suggestion_type == suggestion_type
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
KnowledgeSuggestionResponse.model_validate(s) for s in suggestions
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议详情
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}
|
||||
@router.get("/suggestions/{suggestion_id}")
|
||||
@require_admin
|
||||
async def get_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库优化建议详情。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核通过(Tier1 扩展:串联 Neo4j 写图)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve
|
||||
@router.post("/suggestions/{suggestion_id}/approve")
|
||||
@require_admin
|
||||
async def approve_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionApprove,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""审核通过知识库优化建议(Tier1:串联 Neo4j 写图 + 五态流转)。
|
||||
|
||||
审核通过后:
|
||||
1. 状态 pending/queued → approved → applied → graph_synced
|
||||
2. 自动创建 KnowledgeBase 条目(派生视图)
|
||||
3. 触发 Neo4j 图写入(D1 解读2 合一)
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 审核通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 尝试获取 Neo4j 客户端(可选,不影响审批主流程)
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.approve_suggestion(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "审核通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核拒绝
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject
|
||||
@router.post("/suggestions/{suggestion_id}/reject")
|
||||
@require_admin
|
||||
async def reject_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionReject,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""拒绝知识库优化建议。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 拒绝建议: {suggestion_id}, "
|
||||
f"理由: {body.reject_reason}"
|
||||
)
|
||||
|
||||
suggestion = await service.reject_suggestion(
|
||||
db, suggestion_id, current_user.employee_id, body.reject_reason
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "已拒绝该建议",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 改写提案(Tier1 新增 — D7 内联审批改写)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite
|
||||
@router.post("/suggestions/{suggestion_id}/rewrite")
|
||||
@require_admin
|
||||
async def rewrite_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionRewrite,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""训练师改写知识库优化建议(Tier1 新增)。
|
||||
|
||||
改写后提案状态重置为 pending,重新走审批流程。
|
||||
可修改字段:title、content、category、tags、confidence、audience、
|
||||
issue、action、relation_type、parent_issue。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 改写建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 将非 None 的字段收集为改写数据
|
||||
rewrite_data = body.model_dump(exclude_none=True, exclude_unset=True)
|
||||
|
||||
suggestion = await service.rewrite_suggestion(
|
||||
db, suggestion_id, current_user.employee_id, rewrite_data
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "提案已改写,等待重新审批",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 放入独立队列(Tier1 新增 — D7 独立队列)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue
|
||||
@router.post("/suggestions/{suggestion_id}/queue")
|
||||
@require_admin
|
||||
async def queue_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""将建议放入独立审批队列(Tier1 新增)。
|
||||
|
||||
当会话关闭且提案仍处于 pending 时调用,将提案状态改为 queued。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 将建议放入独立队列: {suggestion_id}"
|
||||
)
|
||||
|
||||
suggestion = await service.queue_suggestion(db, suggestion_id)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议已放入独立审批队列",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增 — D7 独立队列审批)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve
|
||||
@router.post("/suggestions/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过建议(Tier1 新增)。
|
||||
|
||||
流程与 approve 一致:状态流转 + KB 落库 + Neo4j 写图。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从队列中审批通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取统计
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/stats
|
||||
@router.get("/stats")
|
||||
@require_admin
|
||||
async def get_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议统计。
|
||||
|
||||
返回各状态的建议数量统计(含 queued/graph_synced/expired)。
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
stats = await service.get_suggestion_stats(db)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionStatsResponse(**stats),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 知识图谱可视化(任务2:P2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/graph
|
||||
@router.get("/graph")
|
||||
@require_admin
|
||||
async def get_knowledge_graph(
|
||||
limit: int = Query(default=100, ge=10, le=500, description="节点数量上限"),
|
||||
issue_name: Optional[str] = Query(default=None, description="指定Issue名称查询子图"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识图谱数据(Neo4j 节点+关系 JSON 格式)。
|
||||
|
||||
返回 ECharts 力导向图兼容的节点和关系数据。
|
||||
支持全图查询(默认)和指定 Issue 的子图查询。
|
||||
|
||||
- **limit**: 节点数量上限(10-500)
|
||||
- **issue_name**: 指定 Issue 名称时查询子图(用于审批卡片预览)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
if not neo4j_client:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "Neo4j 不可用,图数据为空",
|
||||
"data": {"nodes": [], "links": []},
|
||||
}
|
||||
|
||||
if issue_name:
|
||||
graph_data = await neo4j_client.query_issue_subgraph(
|
||||
issue_name=issue_name, depth=1
|
||||
)
|
||||
else:
|
||||
graph_data = await neo4j_client.query_full_graph(limit=limit)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": graph_data,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 检查重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}/duplicates
|
||||
@router.get("/suggestions/{suggestion_id}/duplicates")
|
||||
@require_admin
|
||||
async def check_duplicates(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""检查指定建议是否存在重复(利用 Neo4j 图结构 + SQL 文本相似)。
|
||||
|
||||
在采纳建议前调用,检测是否有同名 Issue 或相似标题的已有条目。
|
||||
返回重复项列表供训练师参考。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
duplicates = await service.find_duplicates(
|
||||
db=db,
|
||||
issue_name=suggestion.issue,
|
||||
title=suggestion.title,
|
||||
suggestion_id=suggestion_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"suggestion_id": suggestion_id,
|
||||
"has_duplicates": len(duplicates) > 0,
|
||||
"duplicates": duplicates,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 合并重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/merge
|
||||
@router.post("/suggestions/{suggestion_id}/merge")
|
||||
@require_admin
|
||||
async def merge_suggestions(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionMerge,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""合并重复建议(去重操作)。
|
||||
|
||||
将 duplicate_id 的建议合并到当前建议(primary),
|
||||
标签和元数据合并,重复建议标记为 rejected(合并归入)。
|
||||
|
||||
- **suggestion_id**: 主建议ID(保留)
|
||||
- **duplicate_id**: 重复建议ID(将被合并)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 合并建议: "
|
||||
f"primary={suggestion_id}, duplicate={body.duplicate_id}"
|
||||
)
|
||||
|
||||
merged = await service.merge_suggestions(
|
||||
db=db,
|
||||
primary_id=suggestion_id,
|
||||
duplicate_id=body.duplicate_id,
|
||||
reviewer_id=current_user.employee_id,
|
||||
)
|
||||
|
||||
if not merged:
|
||||
return {"code": 404, "message": "主建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议合并完成,重复建议已标记为已驳回(合并归入)",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(merged),
|
||||
}
|
||||
+213
-33
@@ -10,17 +10,19 @@
|
||||
# 6. POST /api/conversations/{id}/mark-read — 标记已读
|
||||
# 7. POST /api/messages/image — 上传图片
|
||||
# 8. POST /api/messages/file — 上传文件
|
||||
# 9. GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04)
|
||||
# 消息发送需同时:存数据库 + 调用企微API发送给员工
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
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 import select, update, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
@@ -29,7 +31,11 @@ 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
|
||||
|
||||
# RBAC 权限装饰器
|
||||
from app.dependencies import require_permission, get_current_user, UserInfo
|
||||
|
||||
from app.services.ws_manager import manager
|
||||
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,10 +54,12 @@ RECALLABLE_WINDOW_MINUTES = 2
|
||||
# GET /api/conversations/{id}/messages — 获取会话消息列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def list_messages(
|
||||
conversation_id: str,
|
||||
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
|
||||
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话消息列表(分页)。
|
||||
@@ -129,10 +137,12 @@ async def list_messages(
|
||||
# POST /api/conversations/{id}/messages — 坐席发送消息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/messages")
|
||||
@require_permission("conversation", "create", "all")
|
||||
async def send_message(
|
||||
conversation_id: str,
|
||||
body: MessageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""坐席发送消息。
|
||||
|
||||
@@ -161,7 +171,24 @@ async def send_message(
|
||||
if conversation.status == "resolved":
|
||||
raise ERR_CONVERSATION_RESOLVED
|
||||
|
||||
# 2. 创建消息记录
|
||||
# 2. 敏感词检测(#81 v0.6.0 内容审核)
|
||||
# 只对文本消息进行敏感词检测
|
||||
flagged_words = []
|
||||
if body.msg_type == "text" and body.content:
|
||||
from app.services.content_moderation_service import ContentModerationService
|
||||
moderation = ContentModerationService()
|
||||
result = moderation.moderate(body.content)
|
||||
if result.matched_words:
|
||||
flagged_words = result.matched_words
|
||||
# 检测到敏感词,但只警告不阻止发送
|
||||
logger.warning(
|
||||
f"[ContentModeration] 坐席消息含敏感词: "
|
||||
f"conversation={conv_id_str}, words={flagged_words}"
|
||||
)
|
||||
# 返回消息的同时带上警告信息(前端可选择显示提示)
|
||||
|
||||
# 3. 创建消息记录
|
||||
# (原步骤编号顺延)
|
||||
# 从会话的 assigned_agent_id 获取坐席信息
|
||||
agent_id = conversation.assigned_agent_id or "unknown"
|
||||
|
||||
@@ -184,10 +211,11 @@ async def send_message(
|
||||
status="sending", # 初始状态为发送中
|
||||
recallable_until=recallable_until,
|
||||
is_read=True, # 坐席自己发的消息默认已读
|
||||
server_timestamp=int(time.time() * 1000), # [MSG-P0-03] 服务端时间戳(毫秒)
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# 3. 更新会话最后消息信息
|
||||
# 4. 更新会话最后消息信息
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.last_message_summary = body.content[:256]
|
||||
conversation.updated_at = datetime.now()
|
||||
@@ -195,33 +223,41 @@ async def send_message(
|
||||
|
||||
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
|
||||
# 5. 移除企微 API 调用,仅通过 WebSocket 推送到 H5
|
||||
# 企微提醒由定时任务处理(超时未回复场景)
|
||||
# 只保留 WebSocket 推送逻辑
|
||||
|
||||
redis_client = settings.create_redis_client()
|
||||
wecom_service = WecomService(redis_client)
|
||||
# 6. 更新会话的最后坐席回复时间(用于超时提醒判断)
|
||||
conversation.last_agent_reply_at = datetime.now()
|
||||
conversation.reminder_sent = False # 重置提醒标记,允许再次发送提醒
|
||||
conversation.pending_close_at = datetime.now() + timedelta(minutes=10) # 10分钟后待关闭
|
||||
db.add(conversation)
|
||||
|
||||
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. 更新消息状态为已发送
|
||||
# 7. 更新消息状态为已发送
|
||||
message.status = "sent"
|
||||
await db.flush()
|
||||
|
||||
# 7. 通过 WebSocket 推送消息给 H5 用户
|
||||
# 做什么:构建 new_message 事件,推送给会话的员工
|
||||
# 为什么:实现双通道推送(企微消息 + WebSocket),H5 用户可以实时收到消息
|
||||
try:
|
||||
# 构建消息载荷
|
||||
msg_payload = MessageResponse.model_validate(message).model_dump()
|
||||
msg_payload["message_id"] = msg_payload["id"]
|
||||
|
||||
# 构建 WebSocket 事件
|
||||
ws_event = {
|
||||
"type": "new_message",
|
||||
"data": msg_payload,
|
||||
}
|
||||
|
||||
# 推送给会话的员工(H5用户)
|
||||
await manager.send_to_employee(conversation.employee_id, ws_event)
|
||||
logger.debug(f"WebSocket消息推送成功: employee_id={conversation.employee_id}, msg_id={message.id}")
|
||||
except Exception as e:
|
||||
# WebSocket 推送失败不阻塞响应(员工可能未打开H5页面)
|
||||
logger.warning(f"WebSocket消息推送失败(H5用户可能不在线): {e}")
|
||||
|
||||
# 转换为响应格式
|
||||
response_data = MessageResponse.model_validate(message).model_dump()
|
||||
return success_response(data=response_data)
|
||||
@@ -231,9 +267,11 @@ async def send_message(
|
||||
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}/messages/poll")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def poll_messages(
|
||||
conversation_id: str,
|
||||
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
|
||||
current_agent: Agent = Depends(get_current_agent), # 添加此参数以支持权限验证
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席轮询新消息。
|
||||
@@ -293,9 +331,10 @@ async def poll_messages(
|
||||
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/messages/{message_id}/recall")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def recall_message(
|
||||
message_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""撤回消息(2分钟内)。
|
||||
@@ -309,12 +348,13 @@ async def recall_message(
|
||||
|
||||
Args:
|
||||
message_id: 消息ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
# 查询消息
|
||||
stmt = select(Message).where(Message.id == str(message_id))
|
||||
result = await db.execute(stmt)
|
||||
@@ -338,8 +378,31 @@ async def recall_message(
|
||||
# 将消息内容置为空,表示已撤回
|
||||
message.content = "[消息已撤回]"
|
||||
message.status = "recalled"
|
||||
message.is_recalled = True # MSG-P1-01: 标记为已撤回
|
||||
await db.flush()
|
||||
|
||||
# MSG-P1-01: 通过 WebSocket 广播撤回事件给所有参与者
|
||||
conv_stmt = select(Conversation).where(Conversation.id == message.conversation_id)
|
||||
conv_result = await db.execute(conv_stmt)
|
||||
conversation = conv_result.scalars().first()
|
||||
if conversation:
|
||||
participant_ids = []
|
||||
if conversation.assigned_agent_id:
|
||||
participant_ids.append(conversation.assigned_agent_id)
|
||||
if conversation.employee_id:
|
||||
participant_ids.append(conversation.employee_id)
|
||||
# 广播撤回事件
|
||||
await manager.broadcast_message_status(
|
||||
conv_id=message.conversation_id,
|
||||
msg_id=message.id,
|
||||
status="recalled",
|
||||
participant_ids=participant_ids,
|
||||
extra={
|
||||
"recall_by": agent.user_id,
|
||||
"recall_at": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return success_response(message="消息撤回成功")
|
||||
|
||||
|
||||
@@ -347,9 +410,10 @@ async def recall_message(
|
||||
# DELETE /api/messages/{id} — 删除消息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/messages/{message_id}")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def delete_message(
|
||||
message_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除坐席自己发送的消息。
|
||||
@@ -361,12 +425,13 @@ async def delete_message(
|
||||
|
||||
Args:
|
||||
message_id: 消息ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
# 查询消息
|
||||
stmt = select(Message).where(Message.id == str(message_id))
|
||||
result = await db.execute(stmt)
|
||||
@@ -390,9 +455,10 @@ async def delete_message(
|
||||
# POST /api/conversations/{id}/mark-read — 标记已读
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/mark-read")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def mark_read(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记会话中所有员工未读消息为已读。
|
||||
@@ -408,12 +474,13 @@ async def mark_read(
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
conv_id_str = str(conversation_id)
|
||||
|
||||
# P0-4 修复:先校验当前坐席有权访问此会话
|
||||
@@ -553,4 +620,117 @@ async def upload_message_file(
|
||||
"file_size": file_size,
|
||||
"content_type": file.content_type,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}/messages/search")
|
||||
async def search_messages(
|
||||
conversation_id: str,
|
||||
keyword: str = Query(..., description="搜索关键词"),
|
||||
limit: int = Query(20, ge=1, le=100, description="返回结果数量限制"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""搜索会话消息(按关键词)。
|
||||
|
||||
使用 LIKE 查询匹配消息内容,支持模糊搜索。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
keyword: 搜索关键词
|
||||
limit: 返回结果数量限制
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含匹配的消息列表
|
||||
"""
|
||||
# 校验会话存在
|
||||
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
|
||||
|
||||
# 构建搜索查询(使用 LIKE 进行模糊匹配)
|
||||
# 排除已撤回的消息
|
||||
search_pattern = f"%{keyword}%"
|
||||
stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv_id_str)
|
||||
.where(Message.is_recalled == False) # 排除已撤回的消息
|
||||
.where(Message.content.ilike(search_pattern)) # 不区分大小写匹配
|
||||
.order_by(Message.created_at.desc()) # 最新消息在前
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 转换为响应格式
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"keyword": keyword,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/typing — 发送 typing 事件(MSG-P1-03)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/typing")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def send_typing_event(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发送 typing 事件,通知对方正在输入。
|
||||
|
||||
通过 WebSocket 广播 typing 事件给会话参与者。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 校验会话存在
|
||||
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
|
||||
|
||||
# 构建参与者列表
|
||||
participant_ids = []
|
||||
if conversation.assigned_agent_id:
|
||||
participant_ids.append(conversation.assigned_agent_id)
|
||||
if conversation.employee_id:
|
||||
participant_ids.append(conversation.employee_id)
|
||||
|
||||
# 广播 typing 事件(排除发送者本人)
|
||||
payload = {
|
||||
"type": "typing",
|
||||
"conv_id": conv_id_str,
|
||||
"sender_id": agent.user_id,
|
||||
"sender_name": agent.name or "坐席",
|
||||
}
|
||||
|
||||
for pid in participant_ids:
|
||||
if pid != agent.user_id: # 不发给自己
|
||||
if pid in manager.active_connections:
|
||||
await manager.send_to_agent(pid, payload)
|
||||
elif pid in manager.employee_connections:
|
||||
await manager.send_to_employee(pid, payload)
|
||||
|
||||
return success_response(message="typing 事件已发送")
|
||||
@@ -0,0 +1,436 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 统一 OTP 二次认证 API(三端认证重构 AUTH-03)
|
||||
# =============================================================================
|
||||
# 说明:三端(H5 员工端 / 坐席端 / 管理端)统一的 OTP(TOTP) 二次认证路由。
|
||||
#
|
||||
# 端点列表(前缀 /auth,nginx 会剥离 /api 前缀,对外即 /api/auth/otp-*):
|
||||
# 1. GET /auth/otp-status — 查询绑定状态(路由守卫用)
|
||||
# 2. POST /auth/otp-bind — 生成 secret + 二维码(尚未启用)
|
||||
# 3. POST /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# 4. POST /auth/otp-unbind — 用户主动关闭 MFA
|
||||
# 5. POST /auth/otp-admin-reset/{id} — 管理员重置(员工丢手机兜底)
|
||||
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 MFA 绑定状态
|
||||
#
|
||||
# 设计要点(与 system_design.md 对齐):
|
||||
# - 复用 MFAService(pyotp + qrcode)与 agents 表的 mfa_* 字段
|
||||
# - 验证通过后在 Redis 写 mfa:verified:{employee_id},TTL=1800s
|
||||
# - 与 dependencies.require_high_risk_otp 共用同一 Redis key(契约不变)
|
||||
# - 取代原 /mfa/* 与 /admin/mfa/* 以及 agents 内联 /agents/otp-* 端点
|
||||
#
|
||||
# 鉴权:
|
||||
# - 1-4 用 get_current_user(任意已登录用户)
|
||||
# - 5-6 用 require_role("admin")(管理员)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import UserInfo, get_current_user, require_role
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.mfa import (
|
||||
MFABindConfirmRequest,
|
||||
MFABindStartResponse,
|
||||
MFADisableRequest,
|
||||
MFADisableResponse,
|
||||
MFAStatusResponse,
|
||||
MFAVerifyRequest,
|
||||
MFAVerifyResponse,
|
||||
)
|
||||
from app.services.mfa_service import MFA_VERIFIED_TTL_SECONDS, MFAService
|
||||
from app.utils.error_codes import ErrorCode
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 路由配置:统一前缀 /auth
|
||||
# -----------------------------------------------------------------------------
|
||||
router = APIRouter(prefix="/auth", tags=["OTP二次认证"])
|
||||
|
||||
|
||||
def _get_redis() -> aioredis.Redis:
|
||||
"""获取 Redis 客户端(模块级 helper,便于测试 patch)。
|
||||
|
||||
Returns:
|
||||
aioredis.Redis: Redis 异步客户端
|
||||
"""
|
||||
return settings.create_redis_client()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 通用工具:根据 user_id(employee_id) 查 Agent 记录
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _get_agent_by_employee_id(
|
||||
db: AsyncSession, employee_id: str
|
||||
) -> Optional[Agent]:
|
||||
"""按 user_id(employee_id)查询 Agent 行。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 用户标识(企微 userid)
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 找不到返回 None
|
||||
"""
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 通用工具:验证当前用户是否已登录 + 取得 Agent 行
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _require_agent(
|
||||
db: AsyncSession, current_user: UserInfo
|
||||
) -> Agent:
|
||||
"""根据当前 token 取出对应的 Agent 行,不存在则 404。
|
||||
|
||||
MFA 状态 / secret 都存放在 agents 表,不是 employees 表。
|
||||
|
||||
Raises:
|
||||
AppException: 坐席不存在(E4001)
|
||||
"""
|
||||
agent = await _get_agent_by_employee_id(db, current_user.employee_id)
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.AGENT_NOT_FOUND, "坐席不存在,无法进行 OTP 操作")
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. GET /auth/otp-status — 查询绑定状态
|
||||
# =============================================================================
|
||||
@router.get("/otp-status", response_model=None)
|
||||
async def get_otp_status(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""查询当前用户的 OTP 绑定状态。
|
||||
|
||||
前端路由守卫使用:
|
||||
- bound=false → 强制走绑定流程
|
||||
- bound=true → 跳到"输入 OTP 验证"或继续业务
|
||||
|
||||
Returns:
|
||||
success_response({bound, enabled, last_verified_at, verified})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
# 是否处于 30 分钟验证窗口内(与 require_high_risk_otp 共用 key)
|
||||
verified = await MFAService.is_verified(redis, agent.user_id)
|
||||
|
||||
return success_response(data=MFAStatusResponse(
|
||||
bound=bool(agent.mfa_enabled and agent.mfa_secret),
|
||||
enabled=bool(agent.mfa_enabled),
|
||||
last_verified_at=agent.mfa_last_verified_at,
|
||||
).model_dump(mode="json") | {"verified": verified})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. POST /auth/otp-bind — 生成 secret + 二维码
|
||||
# =============================================================================
|
||||
@router.post("/otp-bind", response_model=None)
|
||||
async def bind_otp(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生成 TOTP 密钥和二维码。
|
||||
|
||||
行为:
|
||||
- 生成 32 位 base32 secret
|
||||
- 把 secret 写入 agents.mfa_secret(mfa_enabled=False, mfa_bound_at=None)
|
||||
- 返回 otpauth URI + base64 二维码 PNG(给前端展示)
|
||||
|
||||
重复调用策略:
|
||||
- 已 enabled=True → 拒绝,要求先 unbind 再重新绑定
|
||||
- 仅 secret 存在但 enabled=False → 复用旧 secret(支持"刷新二维码")
|
||||
|
||||
Returns:
|
||||
success_response({secret, otpauth_url, qr_code_base64})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
# 已启用则拒绝重新绑定(必须先 unbind)
|
||||
if agent.mfa_enabled:
|
||||
raise AppException(
|
||||
ErrorCode.INVALID_PARAMETER,
|
||||
"已绑定 OTP,如需重新绑定请先关闭",
|
||||
)
|
||||
|
||||
# 复用旧 secret 还是新生成?
|
||||
if agent.mfa_secret:
|
||||
secret = agent.mfa_secret
|
||||
else:
|
||||
secret = MFAService.generate_secret()
|
||||
agent.mfa_secret = secret
|
||||
# mfa_enabled 保持 False,mfa_bound_at 等首次验证通过再写
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
otpauth_url = MFAService.build_provisioning_uri(secret, agent.user_id)
|
||||
qr_base64 = MFAService.render_qrcode_base64(otpauth_url)
|
||||
|
||||
logger.info(f"OTP bind: agent={agent.user_id}, secret_prefix={secret[:4]}...")
|
||||
|
||||
return success_response(data=MFABindStartResponse(
|
||||
secret=secret,
|
||||
otpauth_url=otpauth_url,
|
||||
qr_code_base64=qr_base64,
|
||||
).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. POST /auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# =============================================================================
|
||||
@router.post("/otp-verify", response_model=None)
|
||||
async def verify_otp(
|
||||
body: MFAVerifyRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
|
||||
|
||||
两种场景(三端认证重构 AUTH-05):
|
||||
- 已绑定(mfa_enabled=True) → 常规验证,写 Redis 标记
|
||||
- 首次绑定(mfa_enabled=False 但有 mfa_secret)→ 验证后启用 MFA 并直接签发 token
|
||||
- 未初始化(无 mfa_secret)→ 返回 verified=false
|
||||
|
||||
Returns:
|
||||
success_response({verified, expires_in, token?})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
# 场景1: 未初始化 OTP(无 secret)→ 无法验证
|
||||
if not agent.mfa_secret:
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
# 校验 OTP 码(两种场景共用)
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
logger.warning(f"OTP verify 验证码错误: agent={agent.user_id}")
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=False,
|
||||
expires_in=0,
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
# OTP 码校验通过
|
||||
|
||||
# 场景2: 首次绑定(mfa_enabled=False 但有 secret)
|
||||
# 行为:启用 MFA + 记录绑定时间 + 写 Redis 标记 + 直接签发 token
|
||||
# 避免前端还需要二次调用 login
|
||||
is_first_bind = not agent.mfa_enabled
|
||||
now = datetime.now()
|
||||
|
||||
if is_first_bind:
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = now
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 写 Redis 复用标记
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 签发 token(直接复用 agents 登录的 token 创建逻辑)
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
token_service = TokenService(redis)
|
||||
token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name or current_user.name,
|
||||
roles=current_user.roles,
|
||||
avatar=getattr(current_user, "avatar", None),
|
||||
login_source=getattr(current_user, "login_source", "agent"),
|
||||
)
|
||||
|
||||
logger.info(f"OTP 首次绑定成功并签发 token: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
**MFAVerifyResponse(
|
||||
verified=True,
|
||||
expires_in=MFA_VERIFIED_TTL_SECONDS,
|
||||
).model_dump(),
|
||||
"token": token,
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name or current_user.name,
|
||||
"role": agent.role,
|
||||
"is_first_bind": True,
|
||||
})
|
||||
|
||||
# 场景3: 已绑定常规验证(mfa_enabled=True)
|
||||
# 写 Redis 复用标记(与 require_high_risk_otp 共用 key)
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 更新最后验证时间
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"OTP verify 通过: agent={agent.user_id}")
|
||||
|
||||
return success_response(data=MFAVerifyResponse(
|
||||
verified=True,
|
||||
expires_in=MFA_VERIFIED_TTL_SECONDS,
|
||||
).model_dump(exclude={"token"}))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. POST /auth/otp-unbind — 用户主动关闭 OTP
|
||||
# =============================================================================
|
||||
@router.post("/otp-unbind", response_model=None)
|
||||
async def unbind_otp(
|
||||
body: MFADisableRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""关闭 OTP(清空 secret + disabled 标记)。
|
||||
|
||||
安全要求:必须先校验当前 OTP,防止误操作或被劫持后恶意关闭。
|
||||
|
||||
Returns:
|
||||
success_response({success: true})
|
||||
"""
|
||||
agent = await _require_agent(db, current_user)
|
||||
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
# 没绑定过,直接幂等成功
|
||||
return success_response(data=MFADisableResponse(success=True).model_dump())
|
||||
|
||||
# 必须先验证 OTP
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
raise AppException(ErrorCode.INVALID_PARAMETER, "OTP 验证码错误,无法关闭 OTP")
|
||||
|
||||
# 清空字段
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
# mfa_last_verified_at 保留,作为历史记录
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 顺手清掉 Redis 验证标记(避免遗留)
|
||||
await MFAService.clear_verified(redis, agent.user_id)
|
||||
|
||||
logger.info(f"OTP unbind: agent={agent.user_id}")
|
||||
|
||||
return success_response(data=MFADisableResponse(success=True).model_dump())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. POST /auth/otp-admin-reset/{employee_id} — 管理员重置(丢手机兜底)
|
||||
# =============================================================================
|
||||
# 注意:此端点不要求 otp_code(员工已无法提供),只校验 admin 角色
|
||||
# 鉴权:@require_role("admin") 装饰器强制
|
||||
# =============================================================================
|
||||
@router.post("/otp-admin-reset/{employee_id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_reset_otp(
|
||||
employee_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
"""管理员重置指定员工的 OTP 绑定(无 OTP 验证)。
|
||||
|
||||
使用场景:
|
||||
- 员工丢手机 / 换手机 → 管理员后台"重置 OTP"按钮
|
||||
|
||||
Returns:
|
||||
success_response({success: true})
|
||||
"""
|
||||
stmt = select(Agent).where(Agent.user_id == employee_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.AGENT_NOT_FOUND, f"坐席 {employee_id} 不存在")
|
||||
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
# mfa_last_verified_at 保留,作为审计
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
# 顺手清 Redis 标记
|
||||
await MFAService.clear_verified(redis, employee_id)
|
||||
|
||||
logger.info(f"OTP admin reset: employee_id={employee_id} by={current_user.employee_id}")
|
||||
|
||||
return success_response(data={"success": True})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. GET /auth/otp-admin-users — 管理员查看全部坐席 OTP 绑定状态
|
||||
# =============================================================================
|
||||
@router.get("/otp-admin-users", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_list_otp_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
keyword: str = None,
|
||||
bound: str = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
"""管理员查看全部坐席的 OTP 绑定状态(支持搜索/过滤/分页)。
|
||||
|
||||
Query params:
|
||||
keyword: 搜索姓名或 employee_id(模糊匹配)
|
||||
bound: "true"=已绑定, "false"=未绑定, 空=全部
|
||||
page: 页码(默认 1)
|
||||
page_size: 每页条数(默认 20)
|
||||
|
||||
Returns:
|
||||
success_response({total, items: [{employee_id, name, mfa_enabled,
|
||||
mfa_bound_at, mfa_last_verified_at}, ...]})
|
||||
"""
|
||||
# 构建查询
|
||||
stmt = select(Agent)
|
||||
|
||||
# 搜索过滤
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
Agent.user_id.ilike(f"%{keyword}%") |
|
||||
Agent.name.ilike(f"%{keyword}%")
|
||||
)
|
||||
if bound == "true":
|
||||
stmt = stmt.where(Agent.mfa_enabled == True)
|
||||
elif bound == "false":
|
||||
stmt = stmt.where(Agent.mfa_enabled == False)
|
||||
|
||||
# 先查总数
|
||||
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
stmt = stmt.order_by(Agent.user_id).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
agents = result.scalars().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"employee_id": a.user_id,
|
||||
"name": getattr(a, "name", "") or "",
|
||||
"mfa_enabled": bool(a.mfa_enabled),
|
||||
"mfa_bound_at": a.mfa_bound_at.isoformat() if a.mfa_bound_at else None,
|
||||
"mfa_last_verified_at": (
|
||||
a.mfa_last_verified_at.isoformat() if a.mfa_last_verified_at else None
|
||||
),
|
||||
}
|
||||
for a in agents
|
||||
]
|
||||
|
||||
return success_response(data={"total": total, "items": items})
|
||||
@@ -1,249 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微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/")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 (
|
||||
QuickReplyApprove,
|
||||
QuickReplyCreate,
|
||||
QuickReplyResponse,
|
||||
QuickReplyUpdate,
|
||||
@@ -254,3 +255,84 @@ async def delete_quick_reply(
|
||||
logger.info(f"删除快速回复模板: id={template_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/quick-replies/{id}/approve — 审核通过
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/quick-replies/{template_id}/approve")
|
||||
async def approve_quick_reply(
|
||||
template_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""审核通过快速回复模板。
|
||||
|
||||
将模板状态从 pending_review 改为 approved,版本号 +1。
|
||||
|
||||
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
|
||||
|
||||
# 审核通过:状态改为 approved,版本号 +1
|
||||
template.status = "approved"
|
||||
template.version += 1
|
||||
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"审核通过快速回复模板: id={template_id}, version={template.version}")
|
||||
|
||||
template_data = QuickReplyResponse.model_validate(template).model_dump()
|
||||
return success_response(data=template_data, message="审核通过")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/quick-replies/{id}/reject — 驳回
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/quick-replies/{template_id}/reject")
|
||||
async def reject_quick_reply(
|
||||
template_id: UUID,
|
||||
body: QuickReplyApprove, # 使用 QuickReplyApprove 作为请求体(驳回不需要额外参数)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""驳回快速回复模板。
|
||||
|
||||
将模板状态从 pending_review 改为 rejected。
|
||||
|
||||
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
|
||||
|
||||
# 驳回:状态改为 rejected
|
||||
template.status = "rejected"
|
||||
|
||||
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, message="已驳回")
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RAGFlow 文档摄入 API(Tier1 新增 / P1-5 / 通道 C)
|
||||
# =============================================================================
|
||||
# 说明:RAGFlow 文档摄入接口,训练师上传非标准格式文档,
|
||||
# 经 RAGFlow ETL 整理/结构化后生成 KnowledgeSuggestion 进审批队列。
|
||||
#
|
||||
# 1. POST /api/ragflow/ingest — 上传文档触发 RAGFlow 处理
|
||||
# 2. GET /api/ragflow/tasks/{task_id} — 查询处理任务状态
|
||||
#
|
||||
# P1-5 硬约束:
|
||||
# - 触发方式:训练师手动上传(非定时扫描)
|
||||
# - 支持格式:.docx/.pdf/.txt/.png/.jpg
|
||||
# - source_type=document_ragflow, audience=engineer_workguide
|
||||
# - 产出走 D7 审批流
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.enums import (
|
||||
AudienceEnum,
|
||||
GraphSyncStatusEnum,
|
||||
SourceTypeEnum,
|
||||
SuggestionStatusEnum,
|
||||
)
|
||||
from app.services.ragflow_ingestion_service import RagflowIngestionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 支持的文件格式
|
||||
ALLOWED_EXTENSIONS = {".docx", ".pdf", ".txt", ".png", ".jpg", ".jpeg"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
||||
"application/pdf", # .pdf
|
||||
"text/plain", # .txt
|
||||
"image/png", # .png
|
||||
"image/jpeg", # .jpg/.jpeg
|
||||
}
|
||||
|
||||
# 文件大小上限(20MB)
|
||||
MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
# 内存中的任务状态缓存(生产环境应迁移到 Redis)
|
||||
_task_cache: dict = {}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 上传文档触发 RAGFlow 处理(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/ragflow/ingest
|
||||
@router.post("/ingest")
|
||||
@require_admin
|
||||
async def ingest_document(
|
||||
file: UploadFile = File(..., description="文档文件(.docx/.pdf/.txt/.png/.jpg)"),
|
||||
category_hint: str = Form(
|
||||
default="其他",
|
||||
description="分类提示(可选,帮助RAGFlow归类):硬件/软件/网络/安全/账号/其他",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""上传非标准格式文档到 RAGFlow 进行 ETL 处理。
|
||||
|
||||
训练师上传文档后,RAGFlow 自动整理/筛选/结构化内容,
|
||||
生成 KnowledgeSuggestion 提案进入 D7 审批队列。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **file**: 文档文件(必填,支持 .docx/.pdf/.txt/.png/.jpg)
|
||||
- **category_hint**: 分类提示(可选,默认"其他")
|
||||
|
||||
**文件大小限制**: 最大 20MB
|
||||
**处理时间**: 最长等待 5 分钟,超时返回 pending 状态
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 校验文件扩展名
|
||||
file_name = file.filename or "unknown"
|
||||
ext = "." + file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的文件格式: {ext},仅支持 {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 校验 MIME 类型(如可获取)
|
||||
if file.content_type and file.content_type not in ALLOWED_MIME_TYPES:
|
||||
logger.warning(
|
||||
f"文件 MIME 类型不在白名单中: {file.content_type},仍允许上传"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
file_data = await file.read()
|
||||
|
||||
# 校验文件大小
|
||||
if len(file_data) > MAX_FILE_SIZE:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"文件过大({len(file_data) / 1024 / 1024:.1f}MB),最大支持 20MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
if len(file_data) == 0:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": "文件内容为空",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用 RAGFlow Ingestion 服务
|
||||
service = RagflowIngestionService()
|
||||
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 上传文档到 RAGFlow: "
|
||||
f"file_name={file_name}, category_hint={category_hint}, size={len(file_data)}"
|
||||
)
|
||||
|
||||
result = await service.upload_and_process(file_data, file_name, category_hint)
|
||||
|
||||
# 将生成的 suggestions 写入数据库(pending 状态)
|
||||
saved_suggestions = []
|
||||
if result.get("suggestions"):
|
||||
for sug_data in result["suggestions"]:
|
||||
suggestion = KnowledgeSuggestion(
|
||||
suggestion_type=sug_data.get("suggestion_type", "new_faq"),
|
||||
status=SuggestionStatusEnum.pending.value,
|
||||
title=sug_data.get("title", ""),
|
||||
content=sug_data.get("content", ""),
|
||||
category=sug_data.get("category", category_hint),
|
||||
tags=sug_data.get("tags", []),
|
||||
source_type=SourceTypeEnum.document_ragflow.value,
|
||||
source_data=sug_data.get("source_data", []),
|
||||
reason=sug_data.get("reason", ""),
|
||||
confidence=sug_data.get("confidence", 0.85),
|
||||
audience=AudienceEnum.engineer_workguide.value, # 通道 C 默认
|
||||
issue=sug_data.get("issue", ""),
|
||||
action=sug_data.get("action", ""),
|
||||
relation_type=sug_data.get("relation_type", "LEADS_TO"),
|
||||
parent_issue=sug_data.get("parent_issue", ""),
|
||||
graph_meta=sug_data.get("graph_meta", {}),
|
||||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||||
source_failed=sug_data.get("source_failed", False),
|
||||
)
|
||||
db.add(suggestion)
|
||||
saved_suggestions.append({
|
||||
"title": suggestion.title,
|
||||
"category": suggestion.category,
|
||||
"confidence": suggestion.confidence,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"RAGFlow 生成 {len(saved_suggestions)} 条 KnowledgeSuggestion 待审批")
|
||||
|
||||
# 缓存任务状态
|
||||
task_id = result["task_id"]
|
||||
_task_cache[task_id] = {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"created_at": __import__("datetime").datetime.now().isoformat(),
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "文档已提交 RAGFlow 处理",
|
||||
"data": {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
"suggestions": saved_suggestions,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 查询处理任务状态(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/ragflow/tasks/{task_id}
|
||||
@router.get("/tasks/{task_id}")
|
||||
@require_admin
|
||||
async def get_ingestion_task_status(
|
||||
task_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""查询 RAGFlow 文档处理任务状态。
|
||||
|
||||
- **task_id**: 任务ID(来自 ingest 接口返回值)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
task = _task_cache.get(task_id)
|
||||
if not task:
|
||||
return {
|
||||
"code": 404,
|
||||
"message": "任务不存在或已过期",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": task,
|
||||
}
|
||||
+163
-8
@@ -13,6 +13,9 @@ 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.knowledge_base import router as knowledge_base_router
|
||||
from app.api.conversation_annotation import router as annotation_router
|
||||
from app.api.statistics import router as statistics_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
|
||||
@@ -21,9 +24,15 @@ 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_api import router as admin_router
|
||||
from app.api.admin_roles import router as admin_roles_router
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
from app.api.approval import router as approval_router
|
||||
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
|
||||
from app.api.knowledge_iteration import router as knowledge_iteration_router # Tier1 知识库自动迭代
|
||||
from app.api.approval_queue import router as approval_queue_router # Tier1 独立审批队列
|
||||
from app.api.vision import router as vision_router # Tier1 视觉理解
|
||||
from app.api.ragflow_ingestion import router as ragflow_router # Tier1 RAGFlow文档摄入
|
||||
|
||||
# 创建 API 路由器
|
||||
# 所有子路由都会挂载到这个路由器上
|
||||
@@ -70,6 +79,31 @@ api_router.include_router(agents_router, tags=["坐席管理"])
|
||||
# DELETE /api/quick-replies/{id} — 删除模板
|
||||
api_router.include_router(quick_replies_router, tags=["快速回复"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 知识库 API
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/knowledge — 获取知识库列表
|
||||
# POST /api/knowledge — 创建知识条目
|
||||
# PUT /api/knowledge/{id} — 更新知识条目
|
||||
# DELETE /api/knowledge/{id} — 删除知识条目
|
||||
api_router.include_router(knowledge_base_router, tags=["知识库"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 会话标注 API
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/annotations — 创建标注
|
||||
# GET /api/annotations/{conversation_id} — 获取会话标注列表
|
||||
api_router.include_router(annotation_router, tags=["会话标注"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 数据看板统计 API
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/overview — 整体统计概览
|
||||
# GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# GET /api/admin/stats/satisfaction — 满意度统计
|
||||
api_router.include_router(statistics_router, tags=["数据看板"])
|
||||
|
||||
# H5 用户端 API
|
||||
# POST /api/h5/oauth/callback — OAuth2回调
|
||||
# GET /api/h5/user — 获取用户信息
|
||||
@@ -141,12 +175,6 @@ api_router.include_router(upload_router, tags=["文件上传"])
|
||||
# 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 — 分配角色
|
||||
@@ -155,3 +183,130 @@ api_router.include_router(portal_router, tags=["统一入口"])
|
||||
# POST /api/admin/roles/mapping-rules — 创建映射规则
|
||||
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
|
||||
api_router.include_router(admin_roles_router, tags=["角色管理"])
|
||||
|
||||
# 终端安全对比 API
|
||||
# GET /api/admin/security/comparison/summary — 比对汇总
|
||||
# GET /api/admin/security/comparison/no-huorong — 未安装火绒清单
|
||||
# POST /api/admin/security/comparison/trigger — 手动触发
|
||||
# GET /api/admin/security/comparison/tasks — 任务列表
|
||||
# POST /api/admin/security/comparison/tasks — 创建定时任务
|
||||
api_router.include_router(security_comparison_router, tags=["终端安全对比"])
|
||||
|
||||
# 审批流程 API
|
||||
# GET /api/approval/templates — 获取审批模板列表
|
||||
# GET /api/approval/templates/{id} — 获取审批模板详情
|
||||
# POST /api/approval/jump — 生成跳转审批链接
|
||||
# POST /api/approval/submit — API提交审批
|
||||
# GET /api/approval/keywords — 获取审批关键词
|
||||
api_router.include_router(approval_router, tags=["审批流程"])
|
||||
|
||||
# 企微 JS-SDK 签名 API (v0.5.4 应急页身份检测用)
|
||||
# GET /api/wecom/jsapi-config?url=xxx — 返回 corp_id/agent_id/timestamp/nonce_str/signature
|
||||
api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])
|
||||
|
||||
# 扫码登录 API (Phase 1.1 task #14)
|
||||
# POST /api/auth_qrcode/create — 创建扫码登录票据
|
||||
# GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态
|
||||
# POST /api/auth_qrcode/scan — 企微 OAuth2 回调
|
||||
# POST /api/auth_qrcode/confirm — 已登录坐席确认授权
|
||||
from app.api.auth_qrcode import router as auth_qrcode_router
|
||||
api_router.include_router(auth_qrcode_router, tags=["扫码登录"])
|
||||
|
||||
# 高危操作演示 API (Phase 1.3 task #19)
|
||||
# POST /api/admin/high-risk/demo/{category} — 5 类高危操作演示端点
|
||||
# GET /api/admin/high-risk/whitelist — 获取高危操作白名单
|
||||
# GET /api/admin/high-risk/check — 检查当前管理员 OTP 状态
|
||||
from app.api.high_risk_routes import router as high_risk_routes_router
|
||||
api_router.include_router(high_risk_routes_router, tags=["高危操作"])
|
||||
|
||||
from app.api.otp import router as otp_router # 三端认证重构 AUTH-03
|
||||
|
||||
# 统一 OTP 二次认证 API(三端共用,取代原 /mfa/* 与 /admin/mfa/*)
|
||||
# GET /api/auth/otp-status — 查询绑定状态
|
||||
# POST /api/auth/otp-bind — 生成 secret + 二维码
|
||||
# POST /api/auth/otp-verify — 输入 OTP 通过验证(写 Redis 30 分钟)
|
||||
# POST /api/auth/otp-unbind — 用户主动关闭 OTP
|
||||
# POST /api/auth/otp-admin-reset/{id} — 管理员重置指定员工 OTP
|
||||
# GET /api/auth/otp-admin-users — 管理员查看全部坐席 OTP 绑定状态
|
||||
api_router.include_router(otp_router, tags=["OTP二次认证"])
|
||||
|
||||
# 企微 SSO (v0.7.1 task #85)
|
||||
# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO
|
||||
# GET /api/auth_wecom/sso/callback — 企微 OAuth2 回调,用 code 换 userid → 跳端点
|
||||
# GET /api/auth_wecom/sso/verify — 前端用 SSO token 换用户身份(一次性)
|
||||
from app.api.auth_wecom_sso import router as auth_wecom_sso_router
|
||||
api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||
|
||||
# 审计日志 API (v0.7.1 task #89)
|
||||
# GET /api/admin/audit-logs — 分页 + 多维过滤(给 auditor / admin 角色用)
|
||||
# 权限要求: audit_log:read:all (RBAC 装饰器强制)
|
||||
from app.api.audit_logs import router as audit_logs_router
|
||||
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||
|
||||
# 运行期日志 API (standard SOP 第三阶段 — 管理后台日志体系)
|
||||
# GET /api/admin/runtime-logs — 分页 + 多条件筛选(级别/时间/关键字)
|
||||
# GET /api/admin/runtime-logs?download=true — 命中行文本下载(附件)
|
||||
from app.api.runtime_logs import router as runtime_logs_router
|
||||
api_router.include_router(runtime_logs_router, tags=["运行期日志"])
|
||||
|
||||
# 阶段5 自动化闭环 API
|
||||
# POST /itportal/automation/sessions — 创建自动化会话
|
||||
# GET /itportal/automation/sessions — 会话列表
|
||||
# GET /itportal/automation/sessions/{id} — 会话详情
|
||||
# POST /itportal/automation/sessions/{id}/approve — 坐席审批
|
||||
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
|
||||
# POST /itportal/automation/sessions/by-employee — 员工创建会话
|
||||
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 确认
|
||||
# POST /itportal/automation/sessions/{id}/feedback — 员工反馈
|
||||
# GET /itportal/automation/admin/scenarios — 场景配置列表
|
||||
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景(OTP)
|
||||
# GET /itportal/automation/admin/rule-versions — 规则版本
|
||||
# GET /itportal/automation/admin/metrics — 看板指标
|
||||
from app.api.automation import router as automation_router
|
||||
api_router.include_router(automation_router, tags=["自动化闭环"])
|
||||
|
||||
# 管理员用户管理 API
|
||||
# GET /api/admin/users — 获取管理员列表
|
||||
# POST /api/admin/users — 创建管理员
|
||||
# GET /api/admin/users/{id} — 获取管理员详情
|
||||
# PUT /api/admin/users/{id} — 更新管理员
|
||||
# DELETE /api/admin/users/{id} — 删除管理员
|
||||
# POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
from app.api.admin_users import router as admin_users_router
|
||||
api_router.include_router(admin_users_router, tags=["管理员用户管理"])
|
||||
|
||||
# 满意度评价 API (P1-25)
|
||||
# POST /api/conversation/{id}/evaluate — 提交评价
|
||||
# GET /api/conversation/{id}/evaluation — 获取会话评价
|
||||
# GET /api/evaluations/stats — 评价统计
|
||||
# POST /api/conversations/{id}/send-evaluation-invite — 发送评价邀请
|
||||
from app.api.evaluations import router as evaluations_router
|
||||
api_router.include_router(evaluations_router, tags=["满意度评价"])
|
||||
|
||||
# 知识库自动迭代 API (Tier1 挂载)
|
||||
# POST /api/admin/knowledge-iteration/analyze — 触发分析
|
||||
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表(支持audience/confidence筛选)
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id} — 获取建议详情
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve — 审核通过
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject — 审核拒绝
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite — 改写提案
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue — 放入队列
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve — 队列中审批
|
||||
# GET /api/admin/knowledge-iteration/stats — 获取统计
|
||||
api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"])
|
||||
|
||||
# 独立审批队列 API (Tier1)
|
||||
# GET /api/admin/approval-queue/queued — 队列列表
|
||||
# GET /api/admin/approval-queue/queued/stats — 队列统计
|
||||
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve — 队列中审批通过
|
||||
api_router.include_router(approval_queue_router, prefix="/admin/approval-queue", tags=["独立审批队列"])
|
||||
|
||||
# 视觉理解 API (Tier1)
|
||||
# POST /api/vision/analyze — 分析截图(multipart: image + conversation_id)
|
||||
# GET /api/vision/models — 可用视觉模型列表
|
||||
api_router.include_router(vision_router, prefix="/api/vision", tags=["视觉理解"])
|
||||
|
||||
# RAGFlow 文档摄入 API (Tier1)
|
||||
# POST /api/ragflow/ingest — 上传文档触发RAGFlow处理
|
||||
# GET /api/ragflow/tasks/{task_id} — 查询处理状态
|
||||
api_router.include_router(ragflow_router, prefix="/api/ragflow", tags=["RAGFlow文档摄入"])
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 运行期日志 API (standard SOP 第三阶段)
|
||||
# =============================================================================
|
||||
# 说明:管理后台查看后端运行期日志
|
||||
# GET /admin/runtime-logs 正常查询(分页 + 级别/时间/关键字筛选)
|
||||
# GET /admin/runtime-logs?download=true 下载命中行(text/plain 附件)
|
||||
# 权限:require_admin(非 admin 返回 403,由依赖装饰器强制)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.dependencies import require_admin, get_current_user, UserInfo
|
||||
from app.services.runtime_log_service import (
|
||||
iter_runtime_log_lines,
|
||||
query_runtime_logs,
|
||||
)
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/runtime-logs", tags=["运行期日志"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
@require_admin
|
||||
async def get_runtime_logs(
|
||||
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
|
||||
keyword: Optional[str] = Query(None, description="按消息关键字子串筛选(大小写不敏感)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
download: bool = Query(False, description="true 时返回命中行文本下载"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""查询后端运行期日志(分页 + 级别/时间/关键字筛选)。
|
||||
|
||||
权限:仅 admin 角色可访问,非 admin 由 require_admin 装饰器返回 403。
|
||||
"""
|
||||
# 下载模式:流式返回命中行文本,触发浏览器文件下载
|
||||
if download:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="runtime-logs-{ts}.log"'
|
||||
}
|
||||
return StreamingResponse(
|
||||
iter_runtime_log_lines(
|
||||
level=level,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
keyword=keyword,
|
||||
),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# 普通查询:分页返回结构化条目
|
||||
result = await query_runtime_logs(
|
||||
level=level,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return success_response(data=result)
|
||||
@@ -0,0 +1,194 @@
|
||||
# =============================================================================
|
||||
# DEPRECATED · 企微IT智能服务台 — 服务路由定义
|
||||
# =============================================================================
|
||||
# ⚠️ 此文件已废弃(2026-07-08),实际路由由 router.py 统一注册。
|
||||
# 旧 MFA 路由已被 /auth/otp-* 替代。
|
||||
# 仅保留用于 test_service_routes.py 测试参考,生产环境不使用。
|
||||
# 不设置 SERVICE_NAME 时加载全部路由(向后兼容)
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
from fastapi import APIRouter
|
||||
|
||||
# 路由模块导入
|
||||
from app.api import (
|
||||
wecom_callback,
|
||||
conversations,
|
||||
messages,
|
||||
agents,
|
||||
quick_replies,
|
||||
h5,
|
||||
agent_notes,
|
||||
system,
|
||||
wingman,
|
||||
todo_items,
|
||||
troubleshooting_templates,
|
||||
employees,
|
||||
upload,
|
||||
admin_api,
|
||||
portal,
|
||||
admin_roles,
|
||||
approval,
|
||||
wecom_jsapi,
|
||||
auth_qrcode,
|
||||
high_risk_routes,
|
||||
mfa,
|
||||
auth_wecom_sso,
|
||||
audit_logs,
|
||||
)
|
||||
# admin 子目录的路由需要单独导入
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
|
||||
# MFA 有两个 router,需要特殊处理
|
||||
_MFA_ROUTER = mfa.router
|
||||
_MFA_ADMIN_ROUTER = mfa.admin_router
|
||||
|
||||
|
||||
# 路由定义:模块名 -> (router对象, tags, prefix)
|
||||
# prefix 为空时使用路由对象默认的 prefix
|
||||
_ROUTE_MODULES = {
|
||||
# 企微回调
|
||||
"wecom_callback": (wecom_callback.router, ["企微回调"], None),
|
||||
# 会话管理
|
||||
"conversations": (conversations.router, ["会话管理"], None),
|
||||
"messages": (messages.router, ["消息管理"], None),
|
||||
# 坐席管理
|
||||
"agents": (agents.router, ["坐席管理"], None),
|
||||
"quick_replies": (quick_replies.router, ["快速回复"], None),
|
||||
# H5 用户端
|
||||
"h5": (h5.router, ["H5用户端"], None),
|
||||
# 坐席备注
|
||||
"agent_notes": (agent_notes.router, ["坐席备注"], None),
|
||||
# 系统管理
|
||||
"system": (system.router, ["系统管理"], None),
|
||||
# AI Wingman
|
||||
"wingman": (wingman.router, ["AI Wingman"], None),
|
||||
# 待办事项
|
||||
"todo_items": (todo_items.router, ["待办事项"], None),
|
||||
# 排查模板
|
||||
"troubleshooting_templates": (troubleshooting_templates.router, ["排查模板"], None),
|
||||
# 员工管理
|
||||
"employees": (employees.router, ["员工管理"], None),
|
||||
# 文件上传
|
||||
"upload": (upload.router, ["文件上传"], None),
|
||||
# 管理后台
|
||||
"admin_api": (admin_api.router, ["管理后台"], None),
|
||||
# Portal 统一入口
|
||||
"portal": (portal.router, ["统一入口"], None),
|
||||
# 角色管理
|
||||
"admin_roles": (admin_roles.router, ["角色管理"], None),
|
||||
# 审批流程
|
||||
"approval": (approval.router, ["审批流程"], None),
|
||||
# 企微 JS-SDK
|
||||
"wecom_jsapi": (wecom_jsapi.router, ["企微JS-SDK"], None),
|
||||
# 扫码登录
|
||||
"auth_qrcode": (auth_qrcode.router, ["扫码登录"], None),
|
||||
# 高危操作
|
||||
"high_risk_routes": (high_risk_routes.router, ["高危操作"], None),
|
||||
# MFA 二次认证(用户端和管理端分开)
|
||||
"mfa": (_MFA_ROUTER, ["MFA二次认证"], None),
|
||||
"mfa_admin": (_MFA_ADMIN_ROUTER, ["MFA管理(管理员)"], None),
|
||||
# 企微 SSO
|
||||
"auth_wecom_sso": (auth_wecom_sso.router, ["企微SSO"], None),
|
||||
# 审计日志
|
||||
"audit_logs": (audit_logs.router, ["审计日志"], None),
|
||||
# 终端安全对比
|
||||
"security_comparison": (security_comparison_router, ["终端安全对比"], None),
|
||||
}
|
||||
|
||||
|
||||
# 服务路由映射:服务名 -> 需要加载的路由模块列表
|
||||
SERVICE_ROUTE_MAP: Dict[str, List[str]] = {
|
||||
# Core 服务:核心服务(鉴权、员工、角色)
|
||||
"core": [
|
||||
"employees", # 员工管理
|
||||
"auth_qrcode", # 扫码登录
|
||||
"mfa", # MFA 二次认证
|
||||
"auth_wecom_sso", # 企微 SSO
|
||||
"portal", # 角色切换
|
||||
],
|
||||
# Conversation 服务:会话服务(会话、消息、H5、WebSocket)
|
||||
"conversation": [
|
||||
"wecom_callback", # 企微消息接收
|
||||
"conversations", # 会话管理
|
||||
"messages", # 消息管理
|
||||
"h5", # H5 用户端
|
||||
"todo_items", # 待办事项
|
||||
"troubleshooting_templates", # 排查模板
|
||||
],
|
||||
# Agent 服务:坐席服务
|
||||
"agent": [
|
||||
"agents", # 坐席管理
|
||||
"quick_replies", # 快速回复
|
||||
"agent_notes", # 坐席备注
|
||||
"approval", # 审批流程
|
||||
],
|
||||
# AI 服务:AI 服务(Dify 调用、Wingman)
|
||||
"ai": [
|
||||
"wingman", # AI Wingman
|
||||
],
|
||||
# Admin 服务:管理服务(仪表盘、配置、集成、审核)
|
||||
"admin": [
|
||||
"admin_api", # 管理后台 API(必须放第一个,因为 security_comparison 依赖它)
|
||||
"admin_roles", # 角色管理
|
||||
"audit_logs", # 审计日志
|
||||
"high_risk_routes", # 高危操作
|
||||
"system", # 系统管理
|
||||
"upload", # 文件上传
|
||||
"wecom_jsapi", # 企微 JS-SDK
|
||||
"security_comparison", # 终端安全对比
|
||||
"mfa_admin", # MFA 管理端
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_routes_for_service(service_name: str) -> List[Tuple]:
|
||||
"""根据服务名获取需要加载的路由列表
|
||||
|
||||
Args:
|
||||
service_name: 服务名 (core/conversation/agent/ai/admin) 或 None/空
|
||||
|
||||
Returns:
|
||||
路由元组列表:[(router, tags, prefix), ...]
|
||||
"""
|
||||
# 不设置 SERVICE_NAME 或设置为 "all" 时,加载全部路由(向后兼容)
|
||||
if not service_name or service_name.lower() == "all":
|
||||
return [
|
||||
(_ROUTE_MODULES[name][0], _ROUTE_MODULES[name][1], _ROUTE_MODULES[name][2])
|
||||
for name in _ROUTE_MODULES.keys()
|
||||
]
|
||||
|
||||
# 根据服务名获取路由列表
|
||||
route_names = SERVICE_ROUTE_MAP.get(service_name.lower(), [])
|
||||
|
||||
# 查找并返回路由对象
|
||||
result = []
|
||||
for name in route_names:
|
||||
if name in _ROUTE_MODULES:
|
||||
result.append((
|
||||
_ROUTE_MODULES[name][0],
|
||||
_ROUTE_MODULES[name][1],
|
||||
_ROUTE_MODULES[name][2],
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_current_service_name() -> str:
|
||||
"""获取当前服务名称
|
||||
|
||||
Returns:
|
||||
SERVICE_NAME 环境变量,或空字符串
|
||||
"""
|
||||
return os.getenv("SERVICE_NAME", "")
|
||||
|
||||
|
||||
def is_service_mode() -> bool:
|
||||
"""判断是否处于服务模式(非单体模式)
|
||||
|
||||
Returns:
|
||||
True 如果设置了有效的 SERVICE_NAME
|
||||
"""
|
||||
name = get_current_service_name()
|
||||
return bool(name and name.lower() != "all")
|
||||
@@ -0,0 +1,398 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 数据看板 API
|
||||
# =============================================================================
|
||||
# 说明:数据统计接口,为管理后台数据看板提供数据支持
|
||||
# 1. GET /api/admin/stats/overview — 获取整体统计概览
|
||||
# 2. GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# 3. GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# 4. GET /api/admin/stats/satisfaction — 满意度统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, and_, or_
|
||||
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.conversation_evaluation import ConversationEvaluation
|
||||
from app.models.conversation_annotation import ConversationAnnotation
|
||||
from app.models.message import Message
|
||||
from app.utils.response import success_response
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def get_date_range(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""解析日期范围参数。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
|
||||
Returns:
|
||||
tuple: (开始时间, 结束时间)
|
||||
"""
|
||||
if end_date:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
end_dt = datetime.now() + timedelta(days=1)
|
||||
|
||||
if start_date:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
else:
|
||||
start_dt = end_dt - timedelta(days=30) # 默认30天
|
||||
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/overview — 整体统计概览
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/overview")
|
||||
async def get_overview_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取整体统计概览。
|
||||
|
||||
包含:总会话数、待处理会话数、已解决会话数、平均响应时间、满意度等。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 整体统计数据
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 总会话数
|
||||
stmt_total = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_total)
|
||||
total_conversations = result.scalar() or 0
|
||||
|
||||
# 待处理会话数(状态为 queued 或 serving)
|
||||
stmt_pending = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.status.in_(["queued", "serving"]),
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_pending)
|
||||
pending_conversations = result.scalar() or 0
|
||||
|
||||
# 已解决会话数(状态为 resolved)
|
||||
stmt_resolved = select(func.count(Conversation.id)).where(
|
||||
and_(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_resolved)
|
||||
resolved_conversations = result.scalar() or 0
|
||||
|
||||
# 计算满意度(已评价会话的平均评分)
|
||||
stmt_satisfaction = select(
|
||||
func.avg(ConversationEvaluation.score),
|
||||
func.count(ConversationEvaluation.id),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_satisfaction)
|
||||
satisfaction_row = result.first()
|
||||
avg_satisfaction = float(satisfaction_row[0]) if satisfaction_row[0] else 0.0
|
||||
evaluated_count = satisfaction_row[1] or 0
|
||||
|
||||
# 计算平均响应时间(第一条坐席消息与第一条消息的时间差)
|
||||
# 简化计算:resolved会话的平均解决时长
|
||||
stmt_duration = select(func.avg(
|
||||
func.extract('epoch', Conversation.updated_at) - func.extract('epoch', Conversation.created_at)
|
||||
)).where(
|
||||
and_(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_duration)
|
||||
avg_duration_seconds = result.scalar() or 0
|
||||
avg_duration_minutes = avg_duration_seconds / 60 if avg_duration_seconds else 0
|
||||
|
||||
data = {
|
||||
"total_conversations": total_conversations,
|
||||
"pending_conversations": pending_conversations,
|
||||
"resolved_conversations": resolved_conversations,
|
||||
"resolution_rate": round(resolved_conversations / total_conversations * 100, 1) if total_conversations > 0 else 0,
|
||||
"avg_satisfaction": round(avg_satisfaction, 2),
|
||||
"evaluated_count": evaluated_count,
|
||||
"avg_duration_minutes": round(avg_duration_minutes, 1),
|
||||
}
|
||||
|
||||
return success_response(data=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/conversations — 会话趋势统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/conversations")
|
||||
async def get_conversation_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取会话趋势统计。
|
||||
|
||||
按天统计每日会话数、解决数。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 趋势数据列表
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 按天统计会话数
|
||||
stmt = select(
|
||||
func.date(Conversation.created_at).label("date"),
|
||||
func.count(Conversation.id).label("total"),
|
||||
).where(
|
||||
and_(
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
func.date(Conversation.created_at)
|
||||
).order_by(
|
||||
func.date(Conversation.created_at)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 转换为日期+统计的格式
|
||||
trend_data = []
|
||||
for row in rows:
|
||||
date_val = row.date
|
||||
if isinstance(date_val, datetime):
|
||||
date_str = date_val.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_str = str(date_val)
|
||||
|
||||
trend_data.append({
|
||||
"date": date_str,
|
||||
"total": row.total,
|
||||
})
|
||||
|
||||
return success_response(data={"items": trend_data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/agents — 坐席绩效统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/agents")
|
||||
async def get_agent_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取坐席绩效统计。
|
||||
|
||||
统计各坐席的处理会话数、解决数、平均响应时间。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 坐席绩效列表
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 统计各坐席的会话数
|
||||
stmt = select(
|
||||
Conversation.assigned_agent_id,
|
||||
func.count(Conversation.id).label("total"),
|
||||
func.sum(
|
||||
func.case((Conversation.status == "resolved", 1), else_=0)
|
||||
).label("resolved"),
|
||||
).where(
|
||||
and_(
|
||||
Conversation.assigned_agent_id.isnot(None),
|
||||
Conversation.created_at >= start_dt,
|
||||
Conversation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
Conversation.assigned_agent_id
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 获取坐席信息
|
||||
agent_ids = [row[0] for row in rows if row[0]]
|
||||
agent_stmt = select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids))
|
||||
agent_result = await db.execute(agent_stmt)
|
||||
agent_map = {a.id: a.name for a in agent_result.scalars().all()}
|
||||
|
||||
# 转换为坐席绩效数据
|
||||
agent_data = []
|
||||
for row in rows:
|
||||
if not row[0]:
|
||||
continue
|
||||
agent_id = row[0]
|
||||
agent_data.append({
|
||||
"agent_id": agent_id,
|
||||
"agent_name": agent_map.get(agent_id, "未知"),
|
||||
"total_conversations": row[1],
|
||||
"resolved_conversations": row[2] or 0,
|
||||
"resolution_rate": round((row[2] or 0) / row[1] * 100, 1) if row[1] > 0 else 0,
|
||||
})
|
||||
|
||||
# 按处理数排序
|
||||
agent_data.sort(key=lambda x: x["total_conversations"], reverse=True)
|
||||
|
||||
return success_response(data={"items": agent_data})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/admin/stats/satisfaction — 满意度统计
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/admin/stats/satisfaction")
|
||||
async def get_satisfaction_stats(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取满意度统计。
|
||||
|
||||
统计评分分布、各表情占比。
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
db: 数据库会话
|
||||
admin: 当前管理员
|
||||
|
||||
Returns:
|
||||
Dict: 满意度统计数据
|
||||
"""
|
||||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||||
|
||||
# 评分分布统计
|
||||
stmt = select(
|
||||
ConversationEvaluation.score,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
).group_by(
|
||||
ConversationEvaluation.score
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 评分分布
|
||||
score_distribution = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
for row in rows:
|
||||
if row[0] in score_distribution:
|
||||
score_distribution[row[0]] = row[1]
|
||||
|
||||
# 表情分布
|
||||
stmt_emoji = select(
|
||||
ConversationEvaluation.emoji,
|
||||
func.count(ConversationEvaluation.id).label("count"),
|
||||
).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
ConversationEvaluation.emoji.isnot(None),
|
||||
)
|
||||
).group_by(
|
||||
ConversationEvaluation.emoji
|
||||
)
|
||||
|
||||
result = await db.execute(stmt_emoji)
|
||||
emoji_rows = result.all()
|
||||
|
||||
emoji_distribution = {}
|
||||
for row in emoji_rows:
|
||||
if row[0]:
|
||||
emoji_distribution[row[0]] = row[1]
|
||||
|
||||
# 计算平均分
|
||||
stmt_avg = select(func.avg(ConversationEvaluation.score)).join(
|
||||
Conversation,
|
||||
ConversationEvaluation.conversation_id == Conversation.id,
|
||||
).where(
|
||||
and_(
|
||||
ConversationEvaluation.created_at >= start_dt,
|
||||
ConversationEvaluation.created_at < end_dt,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt_avg)
|
||||
avg_score = result.scalar() or 0
|
||||
|
||||
data = {
|
||||
"avg_score": round(float(avg_score), 2),
|
||||
"total_evaluated": sum(score_distribution.values()),
|
||||
"score_distribution": [
|
||||
{"score": k, "count": v} for k, v in sorted(score_distribution.items())
|
||||
],
|
||||
"emoji_distribution": [
|
||||
{"emoji": k, "count": v} for k, v in emoji_distribution.items()
|
||||
],
|
||||
}
|
||||
|
||||
return success_response(data=data)
|
||||
@@ -0,0 +1,163 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 视觉理解 API(Tier1 新增 / D5 / P1-3)
|
||||
# =============================================================================
|
||||
# 说明:截图视觉理解接口,调用本地 Qwen-VL(经 Dify vision workflow)
|
||||
# 分析员工截图,返回结构化描述文本。
|
||||
#
|
||||
# 1. POST /api/vision/analyze — 分析截图(multipart: image + conversation_id)
|
||||
# 2. GET /api/vision/models — 可用的视觉模型列表
|
||||
#
|
||||
# D5 硬约束:
|
||||
# - 视觉理解经 Dify 后端调用本地 Qwen-VL(Qwen3-VL-8B-Instruct)
|
||||
# - 预留 vision_model 参数以便后续升级
|
||||
# - 截图隐私仅保留接口(D6),不阻断消息
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
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_any_user, UserInfo
|
||||
from app.services.vision_service import VisionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 分析截图(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/vision/analyze
|
||||
@router.post("/analyze")
|
||||
@require_any_user
|
||||
async def analyze_screenshot(
|
||||
image: UploadFile = File(..., description="截图文件(支持 PNG/JPG/GIF)"),
|
||||
conversation_id: str = Form(..., description="会话ID(用于上下文关联)"),
|
||||
vision_model: str = Form(
|
||||
default="",
|
||||
description="视觉模型名称(可选,默认使用配置中的模型)",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分析截图,返回 AI 视觉理解的结构化描述。
|
||||
|
||||
员工发送截图后,前端调用此接口将图片交给 Qwen-VL 视觉模型分析。
|
||||
分析结果将自动注入到对应会话的上下文中,参与后续 AI 推理。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **image**: 截图文件(必填)
|
||||
- **conversation_id**: 会话ID(必填)
|
||||
- **vision_model**: 视觉模型名称(可选,默认使用 Qwen3-VL-8B-Instruct)
|
||||
|
||||
**支持的文件格式**: PNG、JPG、GIF、WebP
|
||||
**文件大小限制**: 最大 10MB
|
||||
|
||||
**D5 隐私说明**: 截图分析结果仅供 AI 理解上下文使用,
|
||||
隐私检测接口已预留(D6),当前不阻断消息。
|
||||
"""
|
||||
# 校验文件类型
|
||||
allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
if image.content_type and image.content_type not in allowed_types:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的图片格式: {image.content_type},仅支持 PNG/JPG/GIF/WebP",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 读取图片字节流
|
||||
image_bytes = await image.read()
|
||||
|
||||
# 校验文件大小(最大 10MB)
|
||||
max_size = 10 * 1024 * 1024
|
||||
if len(image_bytes) > max_size:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"图片过大({len(image_bytes) / 1024 / 1024:.1f}MB),最大支持 10MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用视觉理解服务
|
||||
service = VisionService(
|
||||
model=vision_model if vision_model else None,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await service.analyze_screenshot(image_bytes, conversation_id)
|
||||
|
||||
# 将视觉描述注入会话上下文
|
||||
if result.get("description"):
|
||||
injected = await service.inject_to_conversation_context(
|
||||
result["description"], conversation_id
|
||||
)
|
||||
if injected:
|
||||
logger.info(
|
||||
f"视觉描述已注入会话 {conversation_id}: "
|
||||
f"confidence={result.get('confidence', 0):.2f}"
|
||||
)
|
||||
|
||||
await service.close()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "视觉分析完成",
|
||||
"data": {
|
||||
"description": result.get("description", ""),
|
||||
"confidence": result.get("confidence", 0.0),
|
||||
"metadata": result.get("metadata", {}),
|
||||
"injected": result.get("description", "") != "",
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await service.close()
|
||||
logger.error(f"视觉分析异常: {e}")
|
||||
return {
|
||||
"code": 500,
|
||||
"message": f"视觉分析失败: {str(e)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 可用的视觉模型列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/vision/models
|
||||
@router.get("/models")
|
||||
async def list_vision_models():
|
||||
"""获取当前可用的视觉模型列表。
|
||||
|
||||
返回系统配置的视觉模型信息,包括当前默认模型和可升级选项。
|
||||
|
||||
**无需鉴权(公开查询)。**
|
||||
"""
|
||||
models: List[dict] = [
|
||||
{
|
||||
"id": "Qwen3-VL-8B-Instruct",
|
||||
"name": "Qwen3-VL-8B-Instruct(默认)",
|
||||
"provider": "Qwen",
|
||||
"description": "本地部署的千问视觉模型,8B 参数,适用于一般截图理解",
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-VL-32B-Instruct",
|
||||
"name": "Qwen3-VL-32B-Instruct",
|
||||
"provider": "Qwen",
|
||||
"description": "千问视觉模型 32B 版本,精度更高但需要更多显存(≥48GB)",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"models": models,
|
||||
"default_model": settings.qwen_vl_model,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微 JS-SDK 签名 API (v0.5.4 应急页用)
|
||||
# =============================================================================
|
||||
# 说明:提供前端 wx.config / wx.agentConfig 所需的鉴权签名。
|
||||
# 对应企微文档:https://developer.work.weixin.qq.com/document/path/90506
|
||||
#
|
||||
# 流程:
|
||||
# 1. 前端调 GET /api/wecom/jsapi-config?url=xxx 拿签名
|
||||
# 2. 后端用 jsapi_ticket + url 算 sha1 签名
|
||||
# 3. 前端用 wx.config({...}) 鉴权后,即可调企微 JS-SDK(如 wx.agentConfig)
|
||||
#
|
||||
# BC/DR 设计:不依赖 session/auth,公开访问(只返回签名,不返回敏感数据)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_shared_wecom_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/wecom/jsapi-config")
|
||||
async def get_jsapi_config(
|
||||
url: str = Query(..., description="当前页面 URL(不含 # 及其后)"),
|
||||
):
|
||||
"""获取企微 JS-SDK 鉴权配置。
|
||||
|
||||
供前端 wx.config 和 wx.agentConfig 使用。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"corp_id": "wwa8c87970b2011f41",
|
||||
"agent_id": "1000133",
|
||||
"timestamp": 1718500000,
|
||||
"nonce_str": "5K8264ILTKCH...",
|
||||
"signature": "f7c8e9..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
wecom_service = get_shared_wecom_service()
|
||||
|
||||
# 1. 获取 jsapi_ticket
|
||||
ticket = await wecom_service.get_jsapi_ticket()
|
||||
|
||||
# 2. 生成时间戳和随机串
|
||||
timestamp = int(time.time())
|
||||
nonce_str = secrets.token_hex(8) # 16 字符
|
||||
|
||||
# 3. 计算签名
|
||||
signature = wecom_service.generate_jsapi_signature(
|
||||
ticket=ticket,
|
||||
nonce_str=nonce_str,
|
||||
timestamp=timestamp,
|
||||
url=url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}"
|
||||
)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"corp_id": settings.wecom_corp_id,
|
||||
"agent_id": str(settings.wecom_agent_id),
|
||||
"timestamp": timestamp,
|
||||
"nonce_str": nonce_str,
|
||||
"signature": signature,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True)
|
||||
raise AppException(
|
||||
code=5001,
|
||||
message=f"生成 JS-SDK 签名失败: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 应急页身份检测 (v0.5.4)
|
||||
# =============================================================================
|
||||
# 流程:
|
||||
# 1. 前端用 wx.agentConfig 拿到当前 userid
|
||||
# 2. 前端调 GET /api/wecom/check-role?userid=xxx
|
||||
# 3. 后端用企微通讯录 API 查 userid 是否在"IT支持-咨询坐席"标签里
|
||||
# 4. 返回 "user" 或 "agent"
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/wecom/check-role")
|
||||
async def check_emergency_role(
|
||||
userid: str = Query(..., description="企微 userid"),
|
||||
):
|
||||
"""检测当前账号在应急页场景下的角色。
|
||||
|
||||
实现方式(优先级递减):
|
||||
1. 企微通讯录标签检测(若配置 WECOM_AGENT_TAG_ID)
|
||||
2. 后台硬编码名单(若配置 WECOM_AGENT_USERIDS 环境变量)
|
||||
3. 默认 "user" (兜底)
|
||||
|
||||
Args:
|
||||
userid: 企微 userid(从 wx.agentConfig 拿)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"role": "user" | "agent",
|
||||
"userid": "...",
|
||||
"method": "tag" | "hardcoded" | "default"
|
||||
}
|
||||
}
|
||||
"""
|
||||
wecom_service = get_shared_wecom_service()
|
||||
|
||||
# 方式 1:企微标签检测
|
||||
tag_id = getattr(settings, "wecom_agent_tag_id", None)
|
||||
user_info = None
|
||||
role = "user"
|
||||
method = "default"
|
||||
|
||||
if tag_id:
|
||||
try:
|
||||
access_token = await wecom_service.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}"
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode", 0) == 0:
|
||||
user_list = result.get("userlist", [])
|
||||
# userlist 元素可能是 str(老版)或 dict(新版带 name)
|
||||
user_ids = [
|
||||
u if isinstance(u, str) else u.get("userid", "")
|
||||
for u in user_list
|
||||
]
|
||||
if userid in user_ids:
|
||||
logger.info(f"标签检测: userid={userid} 是坐席")
|
||||
role = "agent"
|
||||
method = "tag"
|
||||
else:
|
||||
logger.info(f"标签检测: userid={userid} 是员工")
|
||||
role = "user"
|
||||
method = "tag"
|
||||
else:
|
||||
logger.warning(
|
||||
f"标签 API 失败: errcode={result.get('errcode')}, "
|
||||
f"errmsg={result.get('errmsg')}, 降级到硬编码"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"标签检测失败(降级): {e}")
|
||||
|
||||
# 方式 2:硬编码名单
|
||||
hardcoded = getattr(settings, "wecom_agent_userids", None)
|
||||
if hardcoded:
|
||||
agent_ids = [x.strip() for x in hardcoded.split(",") if x.strip()]
|
||||
if userid in agent_ids:
|
||||
logger.info(f"硬编码名单: userid={userid} 是坐席")
|
||||
role = "agent"
|
||||
method = "hardcoded"
|
||||
else:
|
||||
role = "user"
|
||||
method = "hardcoded"
|
||||
|
||||
# 获取用户详细信息(名称、头像)- 添加超时,避免长时间阻塞
|
||||
user_info = None
|
||||
try:
|
||||
import asyncio
|
||||
import httpx
|
||||
# 设置获取 access_token 的超时时间
|
||||
access_token = await asyncio.wait_for(
|
||||
wecom_service.get_access_token(),
|
||||
timeout=2.0 # 2秒超时
|
||||
)
|
||||
user_url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
user_resp = await client.get(user_url)
|
||||
user_result = user_resp.json()
|
||||
if user_result.get("errcode", 0) == 0:
|
||||
user_info = {
|
||||
"name": user_result.get("name", ""),
|
||||
"avatar": user_result.get("avatar", ""),
|
||||
"department": "" # 简化:暂不获取部门名称
|
||||
}
|
||||
logger.info(f"获取用户信息成功: userid={userid}, name={user_info['name']}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"获取用户信息超时: userid={userid}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户信息失败: {e}")
|
||||
|
||||
# 构建返回数据
|
||||
response_data = {
|
||||
"role": role,
|
||||
"userid": userid,
|
||||
"method": method
|
||||
}
|
||||
# 添加用户信息(如果有)
|
||||
if user_info:
|
||||
response_data.update(user_info)
|
||||
|
||||
# 方式 3:默认 user(当未配置检测方式时)
|
||||
if method == "default":
|
||||
logger.info(f"未配置检测方式, userid={userid} 默认 user")
|
||||
|
||||
return success_response(response_data)
|
||||
+16
-15
@@ -20,7 +20,6 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.cache_service import cache_service
|
||||
@@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
|
||||
|
||||
@@ -61,10 +59,12 @@ async def websocket_endpoint(
|
||||
- 兼容从 ?token= URL 参数获取(向后兼容)
|
||||
- 不再将 token 暴露在 URL 中,避免 access_log 泄露
|
||||
|
||||
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
|
||||
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
agent_id: 坐席ID(从 URL 路径参数获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# WS-01: Token 认证(从 subprotocol / header / query 获取)
|
||||
@@ -74,17 +74,17 @@ async def websocket_endpoint(
|
||||
# 格式: Sec-WebSocket-Protocol: bearer.{token}
|
||||
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
|
||||
# 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取
|
||||
subprotocol = request.headers.get("sec-websocket-protocol", "")
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:] # 去掉 "bearer." 前缀
|
||||
else:
|
||||
# 其次从 Authorization header 获取
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # 去掉 "Bearer " 前缀
|
||||
else:
|
||||
# 向后兼容:从 query param 获取(即将废弃)
|
||||
token = request.query_params.get("token", "")
|
||||
token = websocket.query_params.get("token", "")
|
||||
|
||||
# 步骤2: 检查 token 是否为空
|
||||
if not token:
|
||||
@@ -132,8 +132,8 @@ async def websocket_endpoint(
|
||||
# 认证通过,建立连接
|
||||
# ======================================================================
|
||||
|
||||
# 注册连接(内部会调用 websocket.accept())
|
||||
await ws_manager.connect(agent_id, websocket)
|
||||
# 注册连接(内部会调用 websocket.accept(),并回显协商的 subprotocol)
|
||||
await ws_manager.connect(agent_id, websocket, subprotocol=subprotocol)
|
||||
logger.info(f"坐席 WebSocket 连接已认证: agent_id={agent_id}")
|
||||
|
||||
try:
|
||||
@@ -197,7 +197,6 @@ async def websocket_endpoint(
|
||||
async def h5_websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
employee_id: str,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""H5员工 WebSocket 端点主循环(含 token 认证)。
|
||||
|
||||
@@ -223,10 +222,12 @@ async def h5_websocket_endpoint(
|
||||
- (与H5登录 API /api/h5/mock-login 存储格式一致)
|
||||
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
|
||||
|
||||
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
|
||||
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
employee_id: 员工企微 UserID(从 URL 路径参数获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# Token 认证(从 subprotocol / header / query 获取)
|
||||
@@ -234,17 +235,17 @@ async def h5_websocket_endpoint(
|
||||
|
||||
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
|
||||
# 格式: Sec-WebSocket-Protocol: bearer.{token}
|
||||
subprotocol = request.headers.get("sec-websocket-protocol", "")
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:] # 去掉 "bearer." 前缀
|
||||
else:
|
||||
# 其次从 Authorization header 获取
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # 去掉 "Bearer " 前缀
|
||||
else:
|
||||
# 向后兼容:从 query param 获取(即将废弃)
|
||||
token = request.query_params.get("token", "")
|
||||
token = websocket.query_params.get("token", "")
|
||||
|
||||
# 步骤2: 检查 token 是否为空
|
||||
if not token:
|
||||
@@ -288,8 +289,8 @@ async def h5_websocket_endpoint(
|
||||
# 认证通过,建立连接
|
||||
# ======================================================================
|
||||
|
||||
# 注册员工连接(内部会调用 websocket.accept())
|
||||
await ws_manager.connect_employee(employee_id, websocket)
|
||||
# 注册员工连接(内部会调用 websocket.accept(),并回显协商的 subprotocol)
|
||||
await ws_manager.connect_employee(employee_id, websocket, subprotocol=subprotocol)
|
||||
logger.info(f"H5员工 WebSocket 连接已认证: employee_id={employee_id}")
|
||||
|
||||
try:
|
||||
|
||||
+255
-2
@@ -6,6 +6,7 @@
|
||||
# 所有配置项集中管理,避免散落在代码各处
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -40,6 +41,8 @@ class Settings(BaseSettings):
|
||||
wecom_agent_id: str = "1000002"
|
||||
# 应用Secret(在企微管理后台 > 应用管理 > 自建应用 中查看)
|
||||
wecom_secret: str = "your-agent-secret"
|
||||
# 审批应用Secret(在企微管理后台 > 应用管理 > 审批 > 查看Secret)
|
||||
wecom_approval_secret: str = ""
|
||||
# 回调Token(在企微管理后台 > 应用管理 > 接收消息 中设置)
|
||||
wecom_token: str = "your-callback-token"
|
||||
# 回调EncodingAESKey(43位字符串,用于消息加解密)
|
||||
@@ -57,7 +60,8 @@ class Settings(BaseSettings):
|
||||
# ----------------------------------------------------------------------
|
||||
# Redis 连接地址
|
||||
# Docker 环境使用容器名 redis,本地开发使用 localhost
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
# 从环境变量 REDIS_URL 读取,格式: redis://:password@host:port/db
|
||||
redis_url: str = "" # 默认为空,由环境变量 REDIS_URL 提供
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 服务配置
|
||||
@@ -69,6 +73,14 @@ class Settings(BaseSettings):
|
||||
# CORS 允许的源地址(逗号分隔的字符串)
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:5175"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 运行期日志目录(供"运行期日志"管理页面读取)
|
||||
# ----------------------------------------------------------------------
|
||||
# 后端运行期日志(JSON 格式,由 utils.logging_config.setup_logging 写入)
|
||||
# 所在目录。容器内默认 /app/logs,可通过环境变量 RUNTIME_LOG_DIR 覆盖;
|
||||
# 宿主机需将真实日志目录挂载到该路径(见 docker-compose.yml backend 卷)。
|
||||
RUNTIME_LOG_DIR: str = os.getenv("RUNTIME_LOG_DIR", "/app/logs")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AI 服务配置(Dify)
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -99,6 +111,194 @@ class Settings(BaseSettings):
|
||||
# 是否启用 Mock 登录(默认 false,生产环境必须关闭)
|
||||
mock_login_enabled: bool = False
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 开发模式配置(本地 docker-compose.dev.yml 用)
|
||||
# ----------------------------------------------------------------------
|
||||
# 是否启用开发模式(本地开发环境,启用后挂载 /api/dev/* Mock OAuth 路由)
|
||||
# ⚠️ 生产环境必须为 false / 不设置
|
||||
# 启用的副作用:
|
||||
# 1. 后端启动时挂载 /api/dev/login /users /health 三个 Mock 端点
|
||||
# 2. /api/dev/login 跳过企微 OAuth 直接生成 token
|
||||
# 3. 启动日志会大声警告 "🧪 DEV_MODE enabled"
|
||||
dev_mode: bool = False
|
||||
# 开发模式默认 userid(本地前端兜底用,实际由前端 /api/dev/login 传入)
|
||||
dev_default_userid: str = "dev-user-001"
|
||||
# 开发模式默认姓名
|
||||
dev_default_name: str = "开发测试用户"
|
||||
# 开发模式默认部门
|
||||
dev_default_dept: str = "信息技术部"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 运行环境 & 管理后台 IP 白名单(三端认证重构 AUTH-01)
|
||||
# ----------------------------------------------------------------------
|
||||
# 应用运行环境:dev / test / production
|
||||
# 控制 UA 校验 / IP 白名单 / 真实企微 OAuth 的启用(仅 production 启用)
|
||||
# 通过环境变量 APP_ENV 控制(默认 dev,避免本地误触发强校验)
|
||||
app_env: str = "dev"
|
||||
# 管理后台登录 IP 白名单(逗号分隔,支持 CIDR,如 10.240.0.0/16)
|
||||
# 仅允许白名单内的 IP 访问管理后台登录;其余 IP 返回 4004(无权限)
|
||||
# 通过环境变量 ADMIN_ALLOWED_IPS 覆盖
|
||||
admin_allowed_ips: str = "117.147.35.138,218.75.34.87,10.240.0.0/16"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 审批模板配置(企微审批应用)
|
||||
# ----------------------------------------------------------------------
|
||||
# 资源申请审批模板ID(在企微审批应用设置中获取)
|
||||
approval_template_resource: str = ""
|
||||
# 设备申请审批模板ID(在企微审批应用设置中获取)
|
||||
approval_template_device: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.7.1 企微 SSO 入口配置 (task #85)
|
||||
# ----------------------------------------------------------------------
|
||||
# 是否启用企微 SSO(true = 优先用企微 OAuth2 静默授权,失败时降级扫码)
|
||||
# 通过环境变量 WECOM_SSO_ENABLED 控制(默认 false,避免老用户被打扰)
|
||||
wecom_sso_enabled: bool = False
|
||||
# SSO OAuth 回调 base URL(企微要求 redirect_uri 必须用可信域名)
|
||||
# 生产: https://itsupport.servyou.com.cn 开发: http://localhost:5176
|
||||
wecom_sso_callback_base: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.5.4 应急页身份检测配置
|
||||
# ----------------------------------------------------------------------
|
||||
# IT支持-咨询坐席 通讯录标签 ID(在企微管理后台 > 通讯录管理 > 标签管理 中查看)
|
||||
# 配置后,应急页会通过此标签判断当前用户是否为坐席
|
||||
# 留空则降级到下面的硬编码名单
|
||||
wecom_agent_tag_id: str = ""
|
||||
# 硬编码坐席 userid 列表(逗号分隔),作为标签检测的降级方案
|
||||
# 例:"zhangsan,lisi,wangwu"(生产环境建议用标签方案)
|
||||
wecom_agent_userids: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.6.0 内容审核报警配置(占位,后续完善)
|
||||
# ----------------------------------------------------------------------
|
||||
# 合规通知企微群机器人 webhook
|
||||
content_audit_webhook: str = ""
|
||||
# 主管接收报警的 userid(多个用逗号分隔)
|
||||
content_audit_supervisor_userids: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 阶段5 自动化闭环配置(环境变量前缀 AUTOMATION_*)
|
||||
# ----------------------------------------------------------------------
|
||||
# 说明:自动化引擎连接的外部系统基址与密钥占位。
|
||||
# 优先级:环境变量 AUTOMATION_* > 阶段1-4 既有的 system_configs 集成配置
|
||||
# (huorong/lianruan/ragflow 在 app/integrations/*/config.py 中已有 getter)
|
||||
# 注意:密钥均为占位,生产环境必须通过环境变量注入,切勿硬编码真实密钥。
|
||||
# ----------------------------------------------------------------------
|
||||
# Dify(意图识别 / AI 编排)
|
||||
automation_dify_base_url: str = ""
|
||||
automation_dify_api_key: str = ""
|
||||
|
||||
# RAGFlow(知识库检索,默认内网 :9380)
|
||||
automation_ragflow_base_url: str = "http://10.80.0.85:9380"
|
||||
automation_ragflow_api_key: str = ""
|
||||
|
||||
# 火绒终端安全(HRESS HMAC-SHA1 签名)
|
||||
automation_huorong_base_url: str = ""
|
||||
automation_huorong_access_key_id: str = ""
|
||||
automation_huorong_access_key_secret: str = ""
|
||||
|
||||
# 联软 LV7000(三层认证:IP白名单 + 账号密码 + Token)
|
||||
automation_lianruan_base_url: str = ""
|
||||
automation_lianruan_api_account: str = ""
|
||||
automation_lianruan_api_password: str = ""
|
||||
automation_lianruan_validate_key: str = ""
|
||||
|
||||
# 北森 EHR(静态映射兜底)
|
||||
automation_ehr_base_url: str = ""
|
||||
automation_ehr_api_key: str = ""
|
||||
|
||||
# 自动化阈值(JSON 字符串):置信度下限 / 超时秒 / 连续未解决次数 / 高危必转
|
||||
# 管理后台可配(见 ScenarioConfig + 全局阈值),此处为默认值。
|
||||
automation_thresholds: str = '{"confidence_min":0.6,"timeout_seconds":60,"unresolved_threshold":2,"high_risk_force_handoff":true}'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Neo4j 图数据库配置(知识图谱存储 — Tier0 / T01)
|
||||
# ----------------------------------------------------------------------
|
||||
# Neo4j bolt 协议连接地址(默认本地开发容器)
|
||||
neo4j_uri: str = "bolt://localhost:7687"
|
||||
# Neo4j 用户名
|
||||
neo4j_user: str = "neo4j"
|
||||
# Neo4j 密码(⚠️ 仅从环境变量注入,不设默认值)
|
||||
neo4j_password: str = ""
|
||||
# Neo4j 默认数据库名
|
||||
neo4j_database: str = "neo4j"
|
||||
# Neo4j 连接最大存活时间(秒)
|
||||
neo4j_max_connection_lifetime: int = 3600
|
||||
# Neo4j 连接池上限
|
||||
neo4j_max_connection_pool_size: int = 50
|
||||
# Neo4j 连接获取超时(秒)
|
||||
neo4j_connection_acquisition_timeout: int = 30
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 置信门控配置(D3 — 全局置信阈值)
|
||||
# ----------------------------------------------------------------------
|
||||
# AI 回复置信度低于此阈值时,前端渲染"转人工"入口
|
||||
# 可通过环境变量 CONFIDENCE_GATE_THRESHOLD 覆盖
|
||||
confidence_gate_threshold: float = 0.7
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# RAGFlow Ingestion 开关(通道 C — 文档→KB)
|
||||
# ----------------------------------------------------------------------
|
||||
# 是否启用 RAGFlow 文档 ingestion 功能(默认关闭,需部署 RAGFlow 服务后开启)
|
||||
ragflow_ingestion_enabled: bool = False
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Qwen-VL 视觉理解配置(D5 — 截图理解)
|
||||
# ----------------------------------------------------------------------
|
||||
# 本地 Qwen-VL 模型名称(Dify vision workflow 中配置的模型标识)
|
||||
qwen_vl_model: str = "Qwen3-VL-8B-Instruct"
|
||||
# Dify Vision Workflow API 端点(独立于 Wingman Agent)
|
||||
dify_vision_api_url: str = ""
|
||||
# Dify Vision Workflow API Key
|
||||
dify_vision_api_key: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 阶段5 自动化闭环外部系统配置(简化命名,供管理后台展示)
|
||||
# ----------------------------------------------------------------------
|
||||
# Dify(意图识别 / AI 编排)
|
||||
dify_base_url: str = ""
|
||||
dify_key: str = ""
|
||||
|
||||
# RAGFlow(知识库检索)
|
||||
ragflow_base_url: str = ""
|
||||
|
||||
# 火绒终端安全(HRESS HMAC-SHA1 签名)
|
||||
huorong_base_url: str = ""
|
||||
huorong_key: str = ""
|
||||
huorong_secret: str = ""
|
||||
|
||||
# 联软 LV7000(三层认证:IP白名单 + 账号密码 + Token)
|
||||
lianruan_base_url: str = ""
|
||||
lianruan_username: str = ""
|
||||
lianruan_password: str = ""
|
||||
lianruan_api_key: str = ""
|
||||
|
||||
# 北森 EHR(静态映射兜底)
|
||||
ehr_base_url: str = ""
|
||||
|
||||
def get_automation_thresholds(self) -> dict:
|
||||
"""解析自动化阈值配置,返回带默认值的字典。
|
||||
|
||||
为什么单独成方法:阈值是 JSON 字符串(便于通过环境变量整体注入),
|
||||
解析失败时回退到代码内默认值,避免单点配置错误导致引擎不可用。
|
||||
"""
|
||||
default = {
|
||||
"confidence_min": 0.6,
|
||||
"timeout_seconds": 60,
|
||||
"unresolved_threshold": 2,
|
||||
"high_risk_force_handoff": True,
|
||||
}
|
||||
try:
|
||||
import json as _json
|
||||
if self.automation_thresholds:
|
||||
parsed = _json.loads(self.automation_thresholds)
|
||||
if isinstance(parsed, dict):
|
||||
default.update(parsed)
|
||||
except Exception as e: # 解析失败仅记日志,不中断启动
|
||||
logger.warning(f"自动化阈值解析失败,使用默认值: {e}")
|
||||
return default
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Pydantic-settings 配置
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -130,6 +330,9 @@ class Settings(BaseSettings):
|
||||
def create_redis_client(self) -> aioredis.Redis:
|
||||
"""创建 Redis 异步客户端实例。
|
||||
|
||||
使用单独的 host/port/password 参数,避免 URL 解析问题
|
||||
(特别是密码中包含特殊字符 ! @ # 时)。
|
||||
|
||||
自动附加 protocol=2 参数,强制使用 RESP2 协议。
|
||||
原因:Windows 版 Redis 3.x 不支持 RESP3 协议(HELLO 命令),
|
||||
而 redis-py 8.0+ 默认使用 RESP3,会导致连接失败。
|
||||
@@ -138,7 +341,57 @@ class Settings(BaseSettings):
|
||||
Returns:
|
||||
aioredis.Redis: 配置好的 Redis 异步客户端
|
||||
"""
|
||||
return aioredis.from_url(self.redis_url, protocol=2)
|
||||
# 连接超时保护:防止 Redis 不可达时请求无限挂起
|
||||
# (历史事故:REDIS_URL 密码含 @ # 导致 urlparse 解析到错误 host,
|
||||
# 连接一直挂起,最终表现为登录接口超时 / 502 / 浏览器"网络连接失败")
|
||||
socket_connect_timeout = 5
|
||||
socket_timeout = 5
|
||||
|
||||
# 如果 redis_url 为空,使用默认值
|
||||
if not self.redis_url:
|
||||
# 默认值:本地 Redis
|
||||
return aioredis.Redis(
|
||||
host="localhost",
|
||||
port=6379,
|
||||
protocol=2,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=socket_connect_timeout,
|
||||
socket_timeout=socket_timeout,
|
||||
)
|
||||
|
||||
# 解析 REDIS_URL 提取连接参数
|
||||
# 格式: redis://:password@host:port/db
|
||||
# ⚠️ 密码可能含 URL 保留字符(@ # ! 等),部署时必须用 URL-encode:
|
||||
# @ → %40, # → %23, ! → %21
|
||||
# 例: R3d!s@2026#Secure → R3d%21s%402026%23Secure
|
||||
# urlparse 不会自动解码百分号编码,这里用 unquote 还原真实密码/主机
|
||||
from urllib.parse import urlparse, unquote
|
||||
parsed = urlparse(self.redis_url)
|
||||
|
||||
# 提取密码(先尝试标准 urlparse 字段,失败则从 netloc 兜底)
|
||||
password = parsed.password
|
||||
if not password:
|
||||
# 尝试从 netloc 中提取(格式 :password@host)
|
||||
netloc = parsed.netloc
|
||||
if "@" in netloc:
|
||||
password = netloc.split("@")[0].split(":")[-1]
|
||||
if password:
|
||||
password = unquote(password)
|
||||
|
||||
hostname = unquote(parsed.hostname) if parsed.hostname else "localhost"
|
||||
port = parsed.port or 6379
|
||||
db = parsed.path and int(parsed.path.lstrip("/")) or 0
|
||||
|
||||
return aioredis.Redis(
|
||||
host=hostname,
|
||||
port=port,
|
||||
password=password,
|
||||
db=db,
|
||||
protocol=2,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=socket_connect_timeout,
|
||||
socket_timeout=socket_timeout,
|
||||
)
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化闭环 常量定义
|
||||
# =============================================================================
|
||||
# 说明:集中定义自动化引擎的 WS 事件名、执行模式、风险等级、会话/动作状态、
|
||||
# 映射源优先级、以及自动化专用错误码。
|
||||
#
|
||||
# 约定说明(重要,与架构设计的一致性取舍):
|
||||
# 1. 统一响应格式沿用项目既有 {code: int, data: {}, message: str},
|
||||
# 因此架构文档中的错误码段 AUT-001~AUT-0xx 在此以数值形式落地为 4001~40xx,
|
||||
# WS 的 automation.error 事件同样携带该数值 code。
|
||||
# 2. WS 事件名统一以 automation. 为前缀(架构约定)。
|
||||
# =============================================================================
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WebSocket 事件名(前缀 automation.)
|
||||
# --------------------------------------------------------------------------
|
||||
AUTOMATION_WS_PROGRESS = "automation.progress" # 进度推送(步骤开始/完成)
|
||||
AUTOMATION_WS_ACTION_REQUIRED = "automation.action_required" # 需要坐席审批 / 员工二次确认
|
||||
AUTOMATION_WS_RESOLVED = "automation.resolved" # 处置成功,等待/已关单
|
||||
AUTOMATION_WS_TAKEOVER = "automation.takeover" # 转人工接管
|
||||
AUTOMATION_WS_ERROR = "automation.error" # 异常(转人工 + 通知)
|
||||
|
||||
AUTOMATION_WS_EVENTS = {
|
||||
"progress": AUTOMATION_WS_PROGRESS,
|
||||
"action_required": AUTOMATION_WS_ACTION_REQUIRED,
|
||||
"resolved": AUTOMATION_WS_RESOLVED,
|
||||
"takeover": AUTOMATION_WS_TAKEOVER,
|
||||
"error": AUTOMATION_WS_ERROR,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 执行模式(双模式执行引擎)
|
||||
# --------------------------------------------------------------------------
|
||||
AUTOMATION_MODE_PLAN_ONLY = "plan_only" # 仅生成处置方案,不真正执行外部动作
|
||||
AUTOMATION_MODE_REAL_EXEC = "real_exec" # 真正执行外部系统动作
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 风险等级
|
||||
# --------------------------------------------------------------------------
|
||||
AUTOMATION_RISK_READ = "read" # 只读:默认可自动执行
|
||||
AUTOMATION_RISK_LOW = "low" # 低风险写操作:默认可自动执行
|
||||
AUTOMATION_RISK_HIGH = "high" # 高危:必须审批或员工 H5 二次确认
|
||||
|
||||
# 默认可自动执行的风险等级集合(其余必须走审批/确认)
|
||||
AUTOMATION_AUTO_EXECUTABLE_RISKS = {AUTOMATION_RISK_READ, AUTOMATION_RISK_LOW}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 会话状态机
|
||||
# --------------------------------------------------------------------------
|
||||
AUTOMATION_SESSION_CREATED = "created"
|
||||
AUTOMATION_SESSION_RUNNING = "running"
|
||||
AUTOMATION_SESSION_PAUSED = "paused" # 等待审批/确认时挂起
|
||||
AUTOMATION_SESSION_RESOLVED = "resolved" # 处置成功,等待静默关单/员工确认
|
||||
AUTOMATION_SESSION_CLOSED = "closed" # 已关单
|
||||
AUTOMATION_SESSION_HANDOFF = "handoff" # 已转人工
|
||||
AUTOMATION_SESSION_ERROR = "error"
|
||||
|
||||
# 终态集合(不再流转)
|
||||
AUTOMATION_SESSION_TERMINAL_STATES = {
|
||||
AUTOMATION_SESSION_CLOSED,
|
||||
AUTOMATION_SESSION_HANDOFF,
|
||||
AUTOMATION_SESSION_ERROR,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 动作状态机
|
||||
# --------------------------------------------------------------------------
|
||||
AUTOMATION_ACTION_PENDING = "pending"
|
||||
AUTOMATION_ACTION_RUNNING = "running"
|
||||
AUTOMATION_ACTION_SUCCESS = "success"
|
||||
AUTOMATION_ACTION_FAILED = "failed"
|
||||
AUTOMATION_ACTION_SKIPPED = "skipped"
|
||||
AUTOMATION_ACTION_AWAIT_APPROVAL = "await_approval"
|
||||
AUTOMATION_ACTION_APPROVED = "approved"
|
||||
AUTOMATION_ACTION_REJECTED = "rejected"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 映射源与优先级(联软主,eHR 兜底;aTrust 密钥未到后置)
|
||||
# --------------------------------------------------------------------------
|
||||
MAPPING_SOURCES = ["lianruan", "atrust", "ehr"]
|
||||
MAPPING_SOURCE_PRIORITY = {
|
||||
"lianruan": 0, # 联软终端安全管理(主,支持 strusername 直接映射)
|
||||
"atrust": 1, # 零信任 / aTrust(密钥未到,预留占位)
|
||||
"ehr": 2, # 北森 EHR 静态映射(兜底)
|
||||
}
|
||||
|
||||
# 静默关单 Redis TTL(秒):处置成功后 10 分钟内员工无异议则自动关单
|
||||
AUTOMATION_SILENT_CLOSE_TTL = 600
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 自动化错误码(数值,沿用项目 {code:int} 约定)
|
||||
# 架构文档 AUT-001~AUT-0xx → 4001~40xx
|
||||
# --------------------------------------------------------------------------
|
||||
class AutomationErrorCode:
|
||||
SCENARIO_NOT_FOUND = 4001 # 场景未启用或未配置
|
||||
INTENT_FAILED = 4002 # 意图识别失败
|
||||
EXTERNAL_CALL_FAILED = 4003 # 外部系统调用失败
|
||||
ACTION_REJECTED = 4004 # 动作被拒绝(风险/需审批未通过)
|
||||
SESSION_NOT_FOUND = 4005 # 自动化会话不存在
|
||||
APPROVAL_REJECTED = 4006 # 审批被驳回
|
||||
TIMEOUT_HANDOFF = 4007 # 超时自动接管
|
||||
EMPLOYEE_DECLINED = 4008 # 员工拒绝/未二次确认
|
||||
CONFIG_ERROR = 4009 # 配置错误(阈值/场景/映射)
|
||||
MAPPING_FAILED = 4010 # 终端/员工映射失败
|
||||
INVALID_MODE = 4011 # 非法执行模式
|
||||
ROLLBACK_FAILED = 4012 # 回滚补偿失败
|
||||
|
||||
|
||||
AUTOMATION_ERROR_MESSAGES = {
|
||||
AutomationErrorCode.SCENARIO_NOT_FOUND: "场景未启用或未配置",
|
||||
AutomationErrorCode.INTENT_FAILED: "意图识别失败",
|
||||
AutomationErrorCode.EXTERNAL_CALL_FAILED: "外部系统调用失败",
|
||||
AutomationErrorCode.ACTION_REJECTED: "动作被拒绝(需审批或高危)",
|
||||
AutomationErrorCode.SESSION_NOT_FOUND: "自动化会话不存在",
|
||||
AutomationErrorCode.APPROVAL_REJECTED: "审批被驳回",
|
||||
AutomationErrorCode.TIMEOUT_HANDOFF: "处置超时,已自动转人工",
|
||||
AutomationErrorCode.EMPLOYEE_DECLINED: "员工未确认或已拒绝",
|
||||
AutomationErrorCode.CONFIG_ERROR: "自动化配置错误",
|
||||
AutomationErrorCode.MAPPING_FAILED: "终端/员工映射失败",
|
||||
AutomationErrorCode.INVALID_MODE: "非法的执行模式",
|
||||
AutomationErrorCode.ROLLBACK_FAILED: "回滚补偿失败",
|
||||
}
|
||||
|
||||
|
||||
def automation_error_message(code: int) -> str:
|
||||
"""根据自动化错误码返回默认中文消息。"""
|
||||
return AUTOMATION_ERROR_MESSAGES.get(code, "自动化处理异常")
|
||||
@@ -0,0 +1,9 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 核心模块包
|
||||
# =============================================================================
|
||||
# 说明:存放自动化引擎的「基础设施层」代码(外部客户端、审计、重试等)。
|
||||
# 与 app/integrations/* 的区别:
|
||||
# - app/integrations/* 由管理后台在 system_configs 配置,供管理端功能使用;
|
||||
# - app/core/clients/* 由环境变量 AUTOMATION_* 配置,供自动化闭环引擎使用,
|
||||
# 未配置时返回 None,引擎自动降级(关键词兜底 / EHR 兜底 / 转人工)。
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 外部客户端包
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎专用外部系统客户端集合(环境变量 AUTOMATION_* 驱动)。
|
||||
# 导出基类、异常、各系统客户端及对应 get_*_client 工厂函数。
|
||||
# =============================================================================
|
||||
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
ClientConnectionError,
|
||||
)
|
||||
from app.core.clients.huorong import HuorongClient, get_huorong_client
|
||||
from app.core.clients.lianruan import LianruanClient, get_lianruan_client
|
||||
from app.core.clients.dify import DifyClient, get_dify_client
|
||||
from app.core.clients.ragflow import RagFlowClient, get_ragflow_client
|
||||
from app.core.clients.ehr import BeisenEHRClient, get_ehr_client
|
||||
|
||||
__all__ = [
|
||||
"BaseClient",
|
||||
"BaseClientError",
|
||||
"ClientConfigError",
|
||||
"ClientConnectionError",
|
||||
"ClientAuthError",
|
||||
"ClientAPIError",
|
||||
"HuorongClient",
|
||||
"get_huorong_client",
|
||||
"LianruanClient",
|
||||
"get_lianruan_client",
|
||||
"DifyClient",
|
||||
"get_dify_client",
|
||||
"RagFlowClient",
|
||||
"get_ragflow_client",
|
||||
"BeisenEHRClient",
|
||||
"get_ehr_client",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user