Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c5169531a | |||
| 502551ad9c | |||
| a8da4da0db | |||
| f9c25147c9 | |||
| 1f6a2936f5 | |||
| 01e070c8ef | |||
| 21830d507d | |||
| 15be4963a1 | |||
| 189458912c | |||
| 0e54312b5c | |||
| aaebe31bd8 | |||
| 53b7bbcff7 | |||
| 7864ab404b | |||
| 44e77dcb0e | |||
| 3a44141eac | |||
| 969f524962 | |||
| 5adcb4852a | |||
| 22909fb3bd | |||
| 566bb46cd4 | |||
| a20606f328 | |||
| b321e3dabd | |||
| 7886f2f523 | |||
| 28bc4bda3b | |||
| 05304aec9c | |||
| 928f4bb337 | |||
| b997a683ce | |||
| 6a5f01ff90 | |||
| 6f545fe0b6 | |||
| 1bfc00559a | |||
| bfac494b01 | |||
| 2681e7b0bf | |||
| 6cbee00dc0 | |||
| f9be3547d9 | |||
| 59609b1d8d | |||
| 9cbf2650e1 | |||
| 6e74631ecb | |||
| 555a16c310 | |||
| 5dd8436cde | |||
| 2a4db59f02 | |||
| 873f3176e8 | |||
| aeb4e0cf39 | |||
| af392f2bf0 | |||
| a6518bff03 | |||
| 54481525f4 | |||
| 18a639919c | |||
| 6932e5a449 | |||
| 939de70e48 | |||
| 3ed86d5fb3 | |||
| 5a77a89ab1 | |||
| 449c6d4875 | |||
| 4052e19ff8 | |||
| 6db1c0eef0 |
@@ -142,6 +142,12 @@ wecom-it-desk-server-deploy.zip
|
||||
.workbuddy/*.log.err
|
||||
# workbuddy 记忆目录(个人上下文,不 入仓)
|
||||
.workbuddy/memory/
|
||||
# workbuddy 工作区产物(2026-08-03 补充: 之前 git add . 误入 M)
|
||||
.workbuddy/outputs/
|
||||
.workbuddy/artifacts/
|
||||
.workbuddy/automations/
|
||||
.workbuddy/tmp/
|
||||
.workbuddy/deploy-temp/
|
||||
|
||||
# =============================================================================
|
||||
# 工作树清理 (2026-07-09): 产物 / 临时 / 上传 / 调试 dump 不入仓
|
||||
@@ -233,3 +239,50 @@ backend/scripts/create_test_agent.py
|
||||
# 补充忽略 (2026-07-09 WIP 提交): 新增构建产物
|
||||
dist-deploy/
|
||||
dist-v2/
|
||||
|
||||
# 补充忽略 (2026-07-13): 临时目录 / 备份 / 截图
|
||||
.workbuddy/tmp/
|
||||
.workbuddy/automations/
|
||||
deploy-staging-ki/
|
||||
dist-old-*/
|
||||
dist_deploy/
|
||||
screenshots/
|
||||
test-screenshots/
|
||||
tools/
|
||||
chat_export/
|
||||
deliverables/
|
||||
02meiti/
|
||||
data/
|
||||
|
||||
# 补充忽略 (2026-08-03 仓库重组): 历史 dist 备份 + node_modules_old
|
||||
# 这些是早期部署流程误将 dist 目录 commit 的残留,每个 50MB+,必须不入仓
|
||||
dist.bak/
|
||||
dist.bak.*/
|
||||
dist.old/
|
||||
dist_bak*/
|
||||
dist-clean/
|
||||
dist_bak_*/
|
||||
dist_old/
|
||||
node_modules_old/
|
||||
|
||||
# 补充忽略 (2026-08-03 仓库重组): 用户运行时上传的二进制文件(src/ 前缀)
|
||||
# 原 .gitignore 只有 backend/media/* 规则,重组后需补充 src/backend/* 对应规则
|
||||
src/backend/uploads/
|
||||
src/backend/media/
|
||||
|
||||
# 补充忽略 (2026-08-03 仓库重组): 前端部署脚本生成的 bin chunk
|
||||
# agent.p[0-9].bin (坐席端部署脚本产物) + h5-v4-part[0-9].bin (H5端部署脚本产物)
|
||||
*-part*.bin
|
||||
p[0-9].bin
|
||||
*.p[0-9].bin
|
||||
|
||||
# 补充忽略 (2026-08-03 仓库重组): 前端部署脚本生成的 part 拆分文件
|
||||
# 部署脚本将大 tar/zip 拆分为 part0/part1/part2 上传 (变体多, 通配匹配)
|
||||
*.part*
|
||||
|
||||
# 补充忽略 (2026-08-03 收尾): archives/ 临时备份 + 02meiti hilo 应用数据
|
||||
# archives/ 是早期未跟踪的临时备份目录(85 个文件)
|
||||
# 02meiti/.hilo/ 是 hilo 多媒体应用数据,原 .gitignore 254 行 02meiti/ 已覆盖,
|
||||
# 但之前有 3 个文件被误加入 index (index.sqlite-shm/wal/storage.json),已 git rm --cached
|
||||
archives/
|
||||
02meiti/.hilo/
|
||||
|
||||
@@ -1,5 +1,184 @@
|
||||
# 早班巡检自动化 - 执行记录
|
||||
|
||||
## 2026-07-18 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档第四章 v2.8 (2026-07-14) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 上次巡检记忆 (07-17)
|
||||
**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第10次不存在。独立看板v1.0已更新(#76/#82 07-17入完成区),但主文档第四章未同步
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(阻塞14天,约07-21到期需启动)、#104结构化日志查看页(无阻塞已4天未启动);#117 Neo4j已完成但仍列P0区未清理(数据不一致持续2次巡检)
|
||||
2. **P1待办3项**:#80坐席图片预览(数据不一致持续2次——已完成区07-16 vs P1清单仍列"待排查")、#73后端文件覆盖、#86流程图review
|
||||
3. **等用户决策2项,均超3天阈值**:企微会议室Secret(自07-11,7天)、ITSM API授权(自07-11,7天)— 需PM立即关注;联软网络不通标记"暂不处理"不视为卡点
|
||||
4. **进行中0项**:主文档和独立看板均为空。上次#76已于07-17完成
|
||||
5. **数据质量问题持续**:#81编号冲突(P0敏感词 vs 已完成粘贴图片)、#80/#117双重列出、主文档"已完成"区滞后(07-17 #76/#82未入区)
|
||||
6. **07-17完成2项**:#76 ITSM工单卡片跳转(桥接页+扫码登录)、#82 H5右侧栏布局调整 — 已入独立看板v1.0
|
||||
7. **看板路径第10次缺失**:指定路径连续10次巡检不存在,建议统一看板源
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项(#81约07-21到期、#104未启动4天)
|
||||
- P1待办:3项(#80可能已完成待确认)
|
||||
- 等决策:2项(均超3天阈值,7天)
|
||||
- 进行中:0项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-17 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档第四章 v2.7+ (含07-16更新) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 记忆文件 (07-16)
|
||||
**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第9次不存在。发现独立看板文件在 `docs/10-项目管理/项目状态看板.md` (v1.0, 07-17新建),与主文档第四章存在数据不一致
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(延后至约07-21到期)、#104结构化日志查看页(无阻塞可启动);#117 Neo4j已完成但仍列P0区未清理
|
||||
2. **P1待办3项**:#80坐席图片预览(已在完成区07-16但P1仍列出,数据不一致)、#73后端文件覆盖、#86流程图review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(≥6天自07-11)、ITSM API授权(≥6天自07-11)— 需PM立即关注
|
||||
4. **进行中1项**:#76零信任VPN卡片免登录修复(P1,今日新建计划今日完成)——仅独立看板有记录,主文档"正在做"为空
|
||||
5. **#81编号冲突**:P0"敏感词检测+语气优化"与已完成"粘贴图片边框问题"共用#81
|
||||
6. **两份看板数据不一致**:独立看板v1.0(74完成/1进行中) vs 主文档(P0/P1/等决策分区仍含已完成项)
|
||||
7. **07-16完成4项未入独立看板已完成区**:#117 Neo4j、#82 坐席500错误、#81粘贴图片边框、#80企微图片预览
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项(#81延后中、#104可启动;#117已完成未清理)
|
||||
- P1待办:3项(#80可能已完成待确认)
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:1项(#76,仅独立看板有记录)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档 v2.8 (2026-07-14) + `.workbuddy/memory/2026-07-15.md` + `.workbuddy/memory/2026-07-14.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在(连续8次),状态看板在主文档第四章
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(07-14 PM决定延后1周,原阻塞自07-04共12天,约07-21到期需启动)、#104结构化日志查看页(无阻塞可直接启动)
|
||||
2. **P1待办2项**:#73后端文件覆盖、#86流程图零依赖review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(阻塞≥5天自07-11)、ITSM API授权(阻塞≥5天自07-11)— 需PM立即关注
|
||||
4. **进行中0项**:看板"正在做"区为空
|
||||
5. **07-14/07-15新产出未入看板**:/h5/ 404错误修复(nginx配置)、Token多IP异常检测功能部署(T001-T003测试通过)、审批模板ID不正确两轮修复(RecommendCard+ApprovalCardModal+DB)
|
||||
6. **看板路径持续缺失**:连续8次巡检不存在
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项
|
||||
- P1待办:2项
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:0项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-15 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档 v2.8 (2026-07-14) + `.workbuddy/memory/2026-07-14.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在,状态看板在主文档第四章
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(07-14 PM决定延后1周,原阻塞自07-04共11天)、#104结构化日志查看页(07-14新排入,无阻塞可直接启动)
|
||||
2. **P1待办2项**:#73后端文件覆盖、#86流程图零依赖review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(阻塞≥4天自07-11)、ITSM API授权(阻塞≥4天自07-11)— 需PM立即关注
|
||||
4. **进行中0项**:看板"正在做"区为空
|
||||
5. **07-14新产出未入看板**:/h5/ 404错误修复(nginx配置)、Token多IP异常检测功能部署(T001-T003测试通过)
|
||||
6. **看板路径持续缺失**:指定路径连续7次巡检不存在
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项
|
||||
- P1待办:2项
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:0项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-14 09:30 执行结果(已更新看板 v2.6)
|
||||
|
||||
**数据来源**:主文档 v2.5 + 实际代码检测 + PM确认
|
||||
|
||||
### 关键发现(经实际检测确认)
|
||||
1. **#48 IP白名单**:✅ 已完成。检测nginx.conf确认已配置精确内网网段(10.0.0.0/8等)
|
||||
2. **#105 摇人Bug**:✅ 已完成。代码确认不再推送企微通知栏
|
||||
3. **#107 卷挂载**:✅ 已完成。docker-compose.yml确认./app:/app/app已配置
|
||||
4. **#88 RBAC**:✅ 粗粒度已满足需求,无需细粒度。PM确认
|
||||
5. **#81 敏感词**:⏸️ 延后1周
|
||||
6. **#104 日志页**:🆕 排入本期
|
||||
7. **#75 头像**:🔄 需重新测试
|
||||
8. **火绒AccessKey**:⚠️ 07-13测试时被假值覆盖
|
||||
|
||||
### 看板更新(v2.6)
|
||||
- #48/#107/#88 移至已完成区
|
||||
- #105 从P1移除
|
||||
- 新增"等用户决策"区块
|
||||
|
||||
### 全局状态(更新后)
|
||||
- P0待办:2项(#81/#104)
|
||||
- P1待办:3项
|
||||
- 等决策:4项
|
||||
- 进行中:1项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-13 09:30 执行结果
|
||||
|
||||
**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.5, 2026-07-13) + `.workbuddy/memory/2026-07-13.md` + `.workbuddy/memory/2026-07-12.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在,状态看板在主文档第四章;v2.5已更新至07-13,07-12/07-13产出已纳入
|
||||
|
||||
### 关键发现
|
||||
1. **P0阻塞2项持续未推进**:#48 IP白名单收窄(阻塞≈31天,自06-13)、#81 敏感词检测+语气优化(阻塞≈10天,自07-04,但隐私正则修复已完成)— 均>3天,需PM立即关注
|
||||
2. **#105数据不一致持续4次巡检**:已完成区(07-10)+P1清单双重列出,07-10/07-11/07-12/07-13四次巡检指出至今未修正 ⚠️数据质量
|
||||
3. **#107可能已完成未更新**:07-12/07-13日志显示bind mount方案已实际使用(`./app:/app/app`),但看板仍标为in_progress
|
||||
4. **#75/#88可能已完成**:#75头像同步07-08已交付12/12测试通过;#88 RBAC v0.7.1已完成6处装饰器修复(但细粒度权限可能未完成)
|
||||
5. **07-12布局优化v2.0的8个待明确事项已清除**(07-12日志确认),从等决策清单移除
|
||||
6. **Portal /itportal/ 500错误**:07-13测试发现,nginx静态文件问题,非后端错误,未入看板
|
||||
7. **等决策项从5项减至3项**:企微会议室Secret、ITSM API授权、ITSM代办API抓包仍在阻塞
|
||||
|
||||
### 全局状态
|
||||
- P0待办:3项(2项长期阻塞,#81部分完成)
|
||||
- P1待办:5项(#105应移除,#75/#88可能已完成)
|
||||
- 等决策:3项(布局优化事项已清除)
|
||||
- 进行中:2项(#107可能已完成)
|
||||
|
||||
### PM行动项
|
||||
1. 联系网络组确认代理IP段(#48阻塞31天)⚠️紧急
|
||||
2. 确认#81敏感词"语气优化"部分是否仍需开发(隐私正则已完成)⚠️紧急
|
||||
3. 从P1清单移除#105(连续4次巡检指出)⚠️数据质量
|
||||
4. 确认#107卷挂载改造是否已完成(bind mount已实际使用)
|
||||
5. 确认#75头像同步是否已完成(07-08已交付12/12测试)
|
||||
6. 确认#88 RBAC细粒度权限是否仍需开发(6处装饰器已修复)
|
||||
7. 企微管理后台申请会议室Secret
|
||||
8. 向ITSM平台方申请app_id/app_secret
|
||||
9. 排查Portal /itportal/ 500错误并入看板
|
||||
10. #104结构化日志查看页待启动(无阻塞,可排入sprint)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-12 09:30 执行结果
|
||||
|
||||
**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md` + `.workbuddy/memory/2026-07-12.md`
|
||||
**说明**:指定看板路径仍不存在,状态看板在主文档第四章;看板版本滞后2天,07-11/07-12大量产出未入看板
|
||||
|
||||
### 关键发现
|
||||
1. **P0阻塞2项持续未推进**:#48 IP白名单收窄(阻塞≈29天,自06-13)、#81 敏感词检测(阻塞≈8天,自07-04)— 均>3天,需PM立即关注
|
||||
2. **#105数据不一致持续3次巡检**:已完成区+P1清单双重列出,07-04/07-10/07-11三次巡检指出至今未修正
|
||||
3. **07-11/07-12大量产出未入看板**:代办事项集成(8bug修复链)、IT资产审批推送、语音识别、截图拍照、复杂场景重构、会议室预定系统(全栈部署)、知识库迭代3功能(44文件43测试通过)、知识迭代3Bug修复、坐席v9/v10部署修复、AI辅助消息框+布局优化技术设计文档
|
||||
4. **进行中2项**:#91 忘记密码 + #107 后端卷挂载改造(后者07-11已恢复卷挂载,可能已完成需确认)
|
||||
5. **等用户决策5项**:企微会议室Secret未申请、ITSM API授权待申请、ITSM代办API待抓包、布局优化v2.0的8个待明确事项、知识库迭代待确认
|
||||
6. **看板版本严重滞后**:v2.4截止07-10,07-11全日+07-12产出均未入看板
|
||||
|
||||
### 全局状态
|
||||
- P0待办:3项(2项长期阻塞)
|
||||
- P1待办:5项(1项#105已完成未清理,1项#75可能已完成)
|
||||
- 等决策:5项
|
||||
- 进行中:2项(1项可能已完成)
|
||||
|
||||
### PM行动项
|
||||
1. 联系网络组确认代理IP段(#48阻塞29天)⚠️紧急
|
||||
2. 确认敏感词库来源/语气优化范围(#81阻塞8天)⚠️紧急
|
||||
3. 从P1清单移除#105(连续3次巡检指出)⚠️数据质量
|
||||
4. 确认#75头像同步是否已完成(07-08已交付12/12测试)
|
||||
5. 确认#107卷挂载改造是否已完成(07-11已恢复卷挂载)
|
||||
6. 企微管理后台申请会议室Secret
|
||||
7. 向ITSM平台方申请app_id/app_secret
|
||||
8. 确认布局优化v2.0的8个待明确事项
|
||||
9. 更新看板至v2.5+,将07-11/07-12产出纳入已完成区
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-11 09:30 执行结果
|
||||
|
||||
**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md`
|
||||
@@ -59,10 +238,10 @@
|
||||
## 2026-07-04 09:30 执行结果
|
||||
|
||||
**数据来源**:`.taskboard-cache/任务执行状态看板_cache.json`(缓存时间 2026-07-03T08:44:12)
|
||||
**⚠️ 原始看板文件缺失**:`docs/小组任务书/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析
|
||||
**⚠️ 原始看板文件缺失**:`docs/小组任务/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析
|
||||
|
||||
### 关键发现
|
||||
1. **看板源文件丢失**:`docs/小组任务书/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失
|
||||
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已发现,至今未修正)
|
||||
@@ -76,7 +255,7 @@
|
||||
- 整体:39/55 (71%) — 若计入🟢则41/55 (75%)
|
||||
|
||||
### PM行动项
|
||||
1. **恢复看板源文件** — `docs/小组任务书/任务执行状态看板.md` 缺失,需重建
|
||||
1. **恢复看板源文件** — `docs/小组任务/任务执行状态看板.md` 缺失,需重建
|
||||
2. 通知B组激活B-T10(依赖已完成 + 评审已通过)
|
||||
3. 优先解决3个逾期阻塞(BLOCK-17/19/20),已逾期2天
|
||||
4. 确认BLOCK-18/B-T17真实状态并校正看板
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# IT智能服务台 - 项目记忆
|
||||
|
||||
## 设计决策(锁定)
|
||||
- AI交互:小段多回合;术语:"人工"=用户呼叫坐席,"摇人"=坐席呼叫坐席
|
||||
- 原型:坐席v5.3 + H5 v1.1;UI:企微浅色扁平,accent=#07C160
|
||||
- 统一入口 `/itportal/` → user/agent/admin;admin需OTP
|
||||
|
||||
## 技术架构
|
||||
- 前端:坐席(Vue3+Element Plus) / H5(Vue3+Vant4) / 管理后台(Vue3+Element+Tailwind)
|
||||
- 后端:FastAPI + SQLAlchemy + PostgreSQL + Redis(代码在 `app/`)
|
||||
- 字段映射:后端`id`/`sender_type` → 前端`message_id`/`message_type`(`conversation.ts` 的 `mapMessage()`)
|
||||
- WS双连接池:`active_connections`(agent) + `employee_connections`(H5)
|
||||
|
||||
## 部署
|
||||
- 正式服务器:itsupport.servyou.com.cn (10.90.5.110),出口IP `218.75.34.87`
|
||||
- 堡垒机:sxn@10.212.189.210:2222 (OTP),脚本 `C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py`
|
||||
- 后端卷挂载:`./app:/app/app`,`.py`变更→`docker compose restart backend`;env变更→`up -d backend`
|
||||
- 前端:rm旧→tar解压dist/→`restart nginx`;Nginx: H5→`/html/h5` / Agent→`/html/itagent` / Admin→`/html/itadmin`
|
||||
- Docker bind mount铁律:rm后重建必须重启容器
|
||||
- 部署工具:`jms_ops.py pack-upload <local_dir> <remote_dir>`(≥100KB用此)
|
||||
- httpx.Timeout须含default:`httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)`
|
||||
|
||||
## 外部集成
|
||||
- 企微通讯录:Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8`,Redis key=`wecom:contact_access_token`
|
||||
- Dify:`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`
|
||||
- RAGFlow:生产 `http://10.80.0.85:8080/` / API `:9380`
|
||||
- 映射策略:联软(主) > aTrust(VPN) > eHR(静态)
|
||||
|
||||
## 群聊系统
|
||||
- 摇人(`collaborating_agent_ids`) / 邀请(`participants`);四角色权限
|
||||
- 参与者展开/缩略双模式;头像代理 `/api/avatar/proxy` 解决 COEP/CSP
|
||||
|
||||
## 审批流程系统(2026-07-10上线)
|
||||
- 12种审批类型 / 18个流程;意图识别三级链路:关键词→Dify→降级
|
||||
- 卡片导航用 `window.location.href`(同窗口)
|
||||
|
||||
## 代办事项真实数据源集成(2026-07-11部署+修复完成)
|
||||
- **PRD**:`docs/02-产品需求/prd_todo_integration.md`
|
||||
- **后端**:`todo_source_service.py` / `itsm_service.py` / `todo_aggregator_service.py`(Redis缓存45s)
|
||||
- **前端**:`TodoPanel.vue` / `todo.ts`(API+Store)
|
||||
- **企微审批API**:`getapprovalinfo`(旧`getapprovaldata`已废弃404)→ Semaphore(10)并发`getapprovaldetail` → 代码层过滤template_id
|
||||
- **Token方案**:统一用 `TokenManager`(IT支持应用Secret,IP已在白名单)
|
||||
- **缓存策略**:初始+60s自动=用缓存(无_force) / 手动🔄=跳过缓存(_force=1) / TTL=45s
|
||||
- **8个Bug修复链**:env未注入→Redis密码→bind mount丢失→API废弃→token decode→IP白名单60020→filter参数301025→**_extract_current_approver字段名全错**
|
||||
- **企微API实际字段名**(dump确认):`sp_record[].sp_status`(非status)/ `details[].approver.userid`(非approver[].userid)
|
||||
- **验证**:sxn名下2条审批单正确返回(IT资产外修申请)
|
||||
- **ITSM**:工单详情API已实现,代办列表API待抓包;签名 app_id+app_secret+SHA1
|
||||
- **测试**:40/40通过
|
||||
|
||||
## IT资产升级审批推送(待部署)
|
||||
- 模板ID:`Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS`
|
||||
- 新建 `asset_service.py`;修改 `approval.py`(+urge端点) / `config.py`(+asset_excel_path)
|
||||
- 资产Excel:12月度sheet,编码列(2)匹配,日期列(41)算年限
|
||||
|
||||
## 语音识别转文字(2026-07-11部署)
|
||||
- 手机企微=JS-SDK / PC企微=百度ASR(AppID=123947532) / Mac=隐藏
|
||||
- H5内联录音:🎤→⏹→⏳(disabled)
|
||||
|
||||
## 坐席端截图+拍照(2026-07-11部署)
|
||||
- 截图编辑器(马赛克/文字/箭头/矩形/撤销);拍照(getUserMedia→兜底input capture)
|
||||
- 铁律:`.selection-box`必须`pointer-events:none`;文字工具CSS类切换非v-if;`getDisplayMedia()`必须同步调用
|
||||
|
||||
## 复杂场景重构(2026-07-11部署)
|
||||
- P0~P3:任务中断恢复/信息更正/上下文压缩/多轮纠错
|
||||
- 路由前缀`/itportal/automation/`;迁移链修复041→028_merge_heads
|
||||
|
||||
## 知识库迭代(待确认)
|
||||
- 技术方案:`docs/03-技术架构/增量设计-知识库迭代与痛点缓解-20260711.md`
|
||||
- 原型图:`docs/01-产品设计/知识库迭代-未实现功能原型设计-20260711.md`
|
||||
|
||||
## 近期修复 (2026-07)
|
||||
- H5非企微环境→扫码登录页;Vue版本不一致白屏→重装+`ElMessage._context`
|
||||
- 消息重复/头像COEP/OAuth残留→已修
|
||||
|
||||
## 运维工具
|
||||
- SOP:`docs/10-项目管理/IT智能服务台-标准作业流程SOP.md`
|
||||
- 故障排查:`docs/09-部署运维/00-标准故障排查手册.md`
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"_version": 5,
|
||||
"preferences": {
|
||||
"loadUserMemory": true
|
||||
},
|
||||
"recentFiles": [],
|
||||
"assetPanel": {
|
||||
"typeFilters": [],
|
||||
"dateFilter": {
|
||||
"kind": "all"
|
||||
},
|
||||
"sortOrder": "desc"
|
||||
},
|
||||
"timelinePanel": {
|
||||
"openTimelineIds": [],
|
||||
"activeTimelineId": null
|
||||
},
|
||||
"lastUsedModelParams": {}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Append remaining bytes to partially uploaded file."""
|
||||
import base64
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PYTHON = r"C:\Users\simon\.workbuddy\binaries\python\versions\3.13.12\python.exe"
|
||||
JMS = r"C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py"
|
||||
LOCAL_FILE = r"D:\资料\03-项目开发\wecom_it_smart_desk\deploy_agent_v8.tar.gz"
|
||||
REMOTE_FILE = "/tmp/deploy_agent_v8.tar.gz"
|
||||
CHUNK_SIZE = 12 * 1024 # 12KB
|
||||
|
||||
def run_jms(*args, timeout=30):
|
||||
cmd = [PYTHON, JMS] + list(args)
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
||||
out = result.stdout.decode('utf-8', errors='replace') if result.stdout else ''
|
||||
err = result.stderr.decode('utf-8', errors='replace') if result.stderr else ''
|
||||
return out + err
|
||||
|
||||
def main():
|
||||
with open(LOCAL_FILE, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
local_md5 = hashlib.md5(data).hexdigest()
|
||||
local_size = len(data)
|
||||
|
||||
# Check current remote file size
|
||||
result = run_jms("exec", "-c", f"stat -c %s {REMOTE_FILE}", "--cmd-timeout", "15")
|
||||
# Parse the number from output
|
||||
remote_size = 0
|
||||
for line in result.split('\n'):
|
||||
line = line.strip()
|
||||
if line.isdigit():
|
||||
remote_size = int(line)
|
||||
break
|
||||
|
||||
print(f"Local size: {local_size}")
|
||||
print(f"Remote size: {remote_size}")
|
||||
|
||||
if remote_size >= local_size:
|
||||
# File already complete, just verify MD5
|
||||
print("File already complete, verifying MD5...")
|
||||
result = run_jms("exec", "-c", f"md5sum {REMOTE_FILE}", "--cmd-timeout", "15")
|
||||
print(f"Remote MD5: {result.strip()}")
|
||||
print(f"Local MD5: {local_md5}")
|
||||
if local_md5 in result:
|
||||
print("\n✅ MD5 verified!")
|
||||
else:
|
||||
print("\n❌ MD5 mismatch, need to re-upload!")
|
||||
return
|
||||
|
||||
# Upload remaining bytes
|
||||
remaining = data[remote_size:]
|
||||
total_chunks = (len(remaining) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
print(f"Remaining: {len(remaining)} bytes ({total_chunks} chunks)")
|
||||
|
||||
for i in range(total_chunks):
|
||||
chunk = remaining[i * CHUNK_SIZE : (i + 1) * CHUNK_SIZE]
|
||||
b64 = base64.b64encode(chunk).decode("ascii")
|
||||
cmd = f'echo -n "{b64}" | base64 -d >> {REMOTE_FILE}'
|
||||
run_jms("exec", "-c", cmd, "--cmd-timeout", "15")
|
||||
print(f" Appended chunk {i + 1}/{total_chunks}")
|
||||
|
||||
# Verify
|
||||
print("\nVerifying MD5...")
|
||||
result = run_jms("exec", "-c", f"md5sum {REMOTE_FILE}", "--cmd-timeout", "15")
|
||||
print(f"Remote MD5: {result.strip()}")
|
||||
print(f"Local MD5: {local_md5}")
|
||||
|
||||
if local_md5 in result:
|
||||
print("\n✅ MD5 verified - upload complete!")
|
||||
else:
|
||||
print("\n❌ MD5 mismatch!")
|
||||
# Check final size
|
||||
result = run_jms("exec", "-c", f"stat -c %s {REMOTE_FILE}", "--cmd-timeout", "15")
|
||||
for line in result.split('\n'):
|
||||
if line.strip().isdigit():
|
||||
print(f"Final remote size: {line.strip()}")
|
||||
break
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,71 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RBAC 角色种子数据 (v0.7.1 task #86)
|
||||
# =============================================================================
|
||||
# 启动时调用,把 5 角色 + 权限矩阵写入 roles 表
|
||||
# 兼容"角色已存在"的场景: 不重复插入,但更新 permissions
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.role import Role
|
||||
from app.services.rbac_service import (
|
||||
ROLE_METADATA,
|
||||
get_role_default_permissions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def seed_rbac_roles(db: AsyncSession) -> int:
|
||||
"""种子 RBAC 5 角色。
|
||||
|
||||
行为:
|
||||
1. 遍历 ROLE_METADATA
|
||||
2. 角色不存在 → 创建(UUID + 默认 permissions)
|
||||
3. 角色存在 → 更新 display_name / description / permissions
|
||||
(不动 is_default,避免影响手动设置)
|
||||
|
||||
Returns:
|
||||
int: 新建角色数
|
||||
"""
|
||||
created_count = 0
|
||||
|
||||
for role_name, meta in ROLE_METADATA.items():
|
||||
# 查询是否已存在
|
||||
stmt = select(Role).where(Role.name == role_name)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalars().first()
|
||||
|
||||
permissions = get_role_default_permissions(role_name)
|
||||
|
||||
if role:
|
||||
# 更新现有角色(不动 is_default,防止覆盖手动设置)
|
||||
role.display_name = meta["display_name"]
|
||||
role.description = meta["description"]
|
||||
role.permissions = permissions
|
||||
role.updated_at = datetime.now()
|
||||
logger.debug(f"更新角色: {role_name} ({len(permissions)} 项权限)")
|
||||
else:
|
||||
# 创建新角色
|
||||
role = Role(
|
||||
id=str(uuid.uuid4()),
|
||||
name=role_name,
|
||||
display_name=meta["display_name"],
|
||||
description=meta["description"],
|
||||
permissions=permissions,
|
||||
is_default=(meta["is_default"] == "true"),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
db.add(role)
|
||||
created_count += 1
|
||||
logger.info(f"创建角色: {role_name} ({len(permissions)} 项权限)")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"RBAC 角色种子完成: 新建 {created_count} 个")
|
||||
return created_count
|
||||
@@ -1,98 +0,0 @@
|
||||
# 联软LV7000配置管理
|
||||
"""
|
||||
从system_configs表读取联软API配置,构建LianruanClient实例。
|
||||
|
||||
联软配置键(前缀 integration_lianruan_):
|
||||
- integration_lianruan_base_url: 联软API地址(如 http://192.168.x.x:30098)
|
||||
- integration_lianruan_api_account: API账号
|
||||
- integration_lianruan_api_password: API密码
|
||||
- integration_lianruan_validate_key: 验证密钥(可选)
|
||||
|
||||
配置方式:管理后台 → 系统集成 → 联软LV7000 → 填入账号密码
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.integrations.lianruan.client import LianruanClient
|
||||
from app.integrations.lianruan.exceptions import LianruanConfigError
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 联软配置键前缀(与 admin_service INTEGRATION_DEFINITIONS 中的 key_prefix 一致)
|
||||
_PREFIX = "integration_lianruan_"
|
||||
|
||||
|
||||
async def _get_lianruan_config_value(db: AsyncSession, key_suffix: str) -> str:
|
||||
"""读取单个联软配置值。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
key_suffix: 配置键后缀(如 base_url / api_account)
|
||||
|
||||
Returns:
|
||||
str: 配置值,不存在返回空字符串
|
||||
"""
|
||||
full_key = f"{_PREFIX}{key_suffix}"
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == full_key))
|
||||
config_row = result.scalar_one_or_none()
|
||||
return config_row.value if config_row else ""
|
||||
|
||||
|
||||
async def get_lianruan_config(db: AsyncSession) -> dict:
|
||||
"""从system_configs表读取联软配置。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
dict: 包含 base_url / api_account / api_password / validate_key
|
||||
|
||||
Raises:
|
||||
LianruanConfigError: 配置缺失
|
||||
"""
|
||||
base_url = await _get_lianruan_config_value(db, "base_url")
|
||||
api_account = await _get_lianruan_config_value(db, "api_account")
|
||||
api_password = await _get_lianruan_config_value(db, "api_password")
|
||||
validate_key = await _get_lianruan_config_value(db, "validate_key")
|
||||
|
||||
if not base_url:
|
||||
raise LianruanConfigError("联软API未配置:缺少Base URL")
|
||||
if not api_account:
|
||||
raise LianruanConfigError("联软API未配置:缺少API账号")
|
||||
if not api_password:
|
||||
raise LianruanConfigError("联软API未配置:缺少API密码")
|
||||
|
||||
return {
|
||||
"base_url": base_url,
|
||||
"api_account": api_account,
|
||||
"api_password": api_password,
|
||||
"validate_key": validate_key,
|
||||
}
|
||||
|
||||
|
||||
async def get_lianruan_client(db: AsyncSession) -> LianruanClient:
|
||||
"""构建联软API客户端实例。
|
||||
|
||||
从system_configs表读取配置,创建LianruanClient。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
LianruanClient: 已配置的联软客户端
|
||||
|
||||
Raises:
|
||||
LianruanConfigError: 配置缺失
|
||||
"""
|
||||
cfg = await get_lianruan_config(db)
|
||||
|
||||
return LianruanClient(
|
||||
base_url=cfg["base_url"],
|
||||
api_account=cfg["api_account"],
|
||||
api_password=cfg["api_password"],
|
||||
validate_key=cfg.get("validate_key", ""),
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
# =============================================================================
|
||||
# RAGFlow 集成模块
|
||||
# =============================================================================
|
||||
|
||||
from .client import RagflowClient
|
||||
from .config import get_ragflow_client
|
||||
from .exceptions import (
|
||||
RagflowApiError,
|
||||
RagflowAuthError,
|
||||
RagflowConfigError,
|
||||
RagflowConnectionError,
|
||||
RagflowError,
|
||||
)
|
||||
from .models import (
|
||||
DatasetInfo,
|
||||
DocAggregate,
|
||||
DocumentInfo,
|
||||
RetrievalChunk,
|
||||
RetrievalResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RagflowClient",
|
||||
"get_ragflow_client",
|
||||
"RagflowError",
|
||||
"RagflowConfigError",
|
||||
"RagflowAuthError",
|
||||
"RagflowApiError",
|
||||
"RagflowConnectionError",
|
||||
"RetrievalChunk",
|
||||
"DocAggregate",
|
||||
"RetrievalResult",
|
||||
"DatasetInfo",
|
||||
"DocumentInfo",
|
||||
]
|
||||
@@ -1,449 +0,0 @@
|
||||
# =============================================================================
|
||||
# RAGFlow API 客户端
|
||||
# =============================================================================
|
||||
# 说明:封装 RAGFlow 知识检索引擎的 API 调用
|
||||
# 核心功能:
|
||||
# 1. 知识检索 — POST /api/v1/retrieval(核心接口)
|
||||
# 2. 数据集管理 — 列出/创建/删除知识库
|
||||
# 3. 文档管理 — 上传/列出/删除文档
|
||||
# 4. 测试连接 — 验证 API Key 是否有效
|
||||
# 认证方式:Authorization: Bearer <API_KEY>
|
||||
# 参考文档:https://ragflow.io/docs/http_api_reference
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .exceptions import (
|
||||
RagflowApiError,
|
||||
RagflowAuthError,
|
||||
RagflowConfigError,
|
||||
RagflowConnectionError,
|
||||
RagflowError,
|
||||
)
|
||||
from .models import (
|
||||
DatasetInfo,
|
||||
DocAggregate,
|
||||
DocumentInfo,
|
||||
RetrievalChunk,
|
||||
RetrievalResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认请求超时(秒)
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
# 默认分页大小
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
|
||||
class RagflowClient:
|
||||
"""RAGFlow API 客户端。
|
||||
|
||||
封装 RAGFlow 知识检索引擎的 API 调用,支持:
|
||||
- 知识检索(核心功能)
|
||||
- 数据集(知识库)管理
|
||||
- 文档管理
|
||||
- 连接测试
|
||||
|
||||
使用方式:
|
||||
client = RagflowClient(
|
||||
api_key="sk-xxx",
|
||||
base_url="http://10.80.0.85:9380"
|
||||
)
|
||||
result = await client.retrieval("VPN怎么连?", dataset_ids=["xxx"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "http://10.80.0.85:9380",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
):
|
||||
"""初始化 RAGFlow 客户端。
|
||||
|
||||
Args:
|
||||
api_key: RAGFlow API Key(Bearer Token)
|
||||
base_url: RAGFlow API 基础地址(不含尾部斜杠)
|
||||
timeout: 默认请求超时(秒)
|
||||
|
||||
Raises:
|
||||
RagflowConfigError: API Key 为空
|
||||
"""
|
||||
if not api_key:
|
||||
raise RagflowConfigError("RAGFlow API Key 不能为空")
|
||||
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
"""构建请求头。
|
||||
|
||||
Returns:
|
||||
Dict: 包含 Authorization 和 Content-Type 的请求头
|
||||
"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""统一请求封装。
|
||||
|
||||
Args:
|
||||
method: HTTP 方法(GET/POST/PUT/DELETE)
|
||||
path: API 路径(如 /api/v1/retrieval)
|
||||
json_data: JSON 请求体
|
||||
params: 查询参数
|
||||
timeout: 覆盖默认超时
|
||||
|
||||
Returns:
|
||||
Dict: API 响应的 JSON 数据
|
||||
|
||||
Raises:
|
||||
RagflowAuthError: 认证失败(401)
|
||||
RagflowApiError: API 返回错误
|
||||
RagflowConnectionError: 网络连接失败
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
req_timeout = timeout or self.timeout
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=self._headers(),
|
||||
json=json_data,
|
||||
params=params,
|
||||
timeout=req_timeout,
|
||||
)
|
||||
|
||||
# 处理 HTTP 错误
|
||||
if response.status_code == 401:
|
||||
raise RagflowAuthError("RAGFlow API Key 无效或已过期")
|
||||
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
err_body = response.json()
|
||||
err_msg = err_body.get("message", response.text)
|
||||
except Exception:
|
||||
err_msg = response.text
|
||||
raise RagflowApiError(
|
||||
code=response.status_code,
|
||||
message=f"RAGFlow API 错误 ({response.status_code}): {err_msg}",
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# RAGFlow 统一响应格式:{code: 0, data: ..., message: ...}
|
||||
if result.get("code") != 0:
|
||||
raise RagflowApiError(
|
||||
code=result.get("code", -1),
|
||||
message=result.get("message", "未知错误"),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise RagflowConnectionError(f"RAGFlow 请求超时 ({req_timeout}s): {path}")
|
||||
except httpx.ConnectError:
|
||||
raise RagflowConnectionError(f"RAGFlow 连接失败: {self.base_url}")
|
||||
except (RagflowAuthError, RagflowApiError, RagflowConnectionError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RagflowError(f"RAGFlow 请求异常: {str(e)}")
|
||||
|
||||
# ==========================================================================
|
||||
# 测试连接
|
||||
# ==========================================================================
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""测试 RAGFlow API 连接。
|
||||
|
||||
通过列出数据集(limit=1)验证 API Key 是否有效。
|
||||
|
||||
Returns:
|
||||
Dict: {success: bool, message: str}
|
||||
"""
|
||||
try:
|
||||
result = await self.list_datasets(page=1, page_size=1)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功,共 {result.get('total', 0)} 个知识库",
|
||||
}
|
||||
except RagflowAuthError:
|
||||
return {"success": False, "message": "API Key 无效或已过期"}
|
||||
except RagflowConnectionError as e:
|
||||
return {"success": False, "message": f"连接失败: {e.message}"}
|
||||
except RagflowError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
# ==========================================================================
|
||||
# 知识检索(核心接口)
|
||||
# ==========================================================================
|
||||
|
||||
async def retrieval(
|
||||
self,
|
||||
question: str,
|
||||
dataset_ids: Optional[List[str]] = None,
|
||||
document_ids: Optional[List[str]] = None,
|
||||
similarity_threshold: float = 0.2,
|
||||
vector_similarity_weight: float = 0.3,
|
||||
top_k: int = 1024,
|
||||
keyword: bool = False,
|
||||
highlight: bool = False,
|
||||
) -> RetrievalResult:
|
||||
"""知识检索 — 从知识库中搜索相关文档片段。
|
||||
|
||||
这是 RAGFlow 的核心接口,用于根据用户问题检索最相关的文本块。
|
||||
|
||||
Args:
|
||||
question: 用户查询问题
|
||||
dataset_ids: 要搜索的数据集ID列表(与 document_ids 二选一)
|
||||
document_ids: 要搜索的文档ID列表
|
||||
similarity_threshold: 最小相似度阈值(0-1),默认 0.2
|
||||
vector_similarity_weight: 向量相似度权重(0-1),默认 0.3
|
||||
top_k: 参与计算的块数量,默认 1024
|
||||
keyword: 是否启用关键词匹配,默认 False
|
||||
highlight: 是否高亮匹配术语,默认 False
|
||||
|
||||
Returns:
|
||||
RetrievalResult: 检索结果(含文本块、文档聚合、总数)
|
||||
|
||||
Raises:
|
||||
RagflowError: 检索失败
|
||||
"""
|
||||
body: Dict[str, Any] = {
|
||||
"question": question,
|
||||
"similarity_threshold": similarity_threshold,
|
||||
"vector_similarity_weight": vector_similarity_weight,
|
||||
"top_k": top_k,
|
||||
"keyword": keyword,
|
||||
"highlight": highlight,
|
||||
}
|
||||
|
||||
if dataset_ids:
|
||||
body["dataset_ids"] = dataset_ids
|
||||
if document_ids:
|
||||
body["document_ids"] = document_ids
|
||||
|
||||
result = await self._request("POST", "/api/v1/retrieval", json_data=body)
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
# 解析文本块
|
||||
chunks = [
|
||||
RetrievalChunk.model_validate(chunk)
|
||||
for chunk in data.get("chunks", [])
|
||||
]
|
||||
|
||||
# 解析文档聚合
|
||||
doc_aggs = [
|
||||
DocAggregate.model_validate(agg)
|
||||
for agg in data.get("doc_aggs", [])
|
||||
]
|
||||
|
||||
return RetrievalResult(
|
||||
chunks=chunks,
|
||||
doc_aggs=doc_aggs,
|
||||
total=data.get("total", 0),
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 数据集(知识库)管理
|
||||
# ==========================================================================
|
||||
|
||||
async def list_datasets(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
) -> Dict[str, Any]:
|
||||
"""列出所有数据集(知识库)。
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页条数
|
||||
|
||||
Returns:
|
||||
Dict: {items: List[DatasetInfo], total: int}
|
||||
"""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
"/api/v1/datasets",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
items = [
|
||||
DatasetInfo.model_validate(ds)
|
||||
for ds in data.get("datasets", [])
|
||||
]
|
||||
|
||||
return {"items": items, "total": data.get("total", 0)}
|
||||
|
||||
async def create_dataset(
|
||||
self,
|
||||
name: str,
|
||||
embedding_model: str = "BAAI/bge-m3@BAAI",
|
||||
chunk_method: str = "naive",
|
||||
permission: str = "me",
|
||||
) -> DatasetInfo:
|
||||
"""创建数据集(知识库)。
|
||||
|
||||
Args:
|
||||
name: 数据集名称
|
||||
embedding_model: 向量模型
|
||||
chunk_method: 分块方法(naive/qa/book/laws 等)
|
||||
permission: 权限(me/team)
|
||||
|
||||
Returns:
|
||||
DatasetInfo: 创建的数据集信息
|
||||
"""
|
||||
body = {
|
||||
"name": name,
|
||||
"embedding_model": embedding_model,
|
||||
"chunk_method": chunk_method,
|
||||
"permission": permission,
|
||||
}
|
||||
|
||||
result = await self._request("POST", "/api/v1/datasets", json_data=body)
|
||||
return DatasetInfo.model_validate(result.get("data", {}))
|
||||
|
||||
async def delete_dataset(self, dataset_ids: List[str]) -> bool:
|
||||
"""删除数据集。
|
||||
|
||||
Args:
|
||||
dataset_ids: 要删除的数据集ID列表
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
await self._request(
|
||||
"DELETE",
|
||||
"/api/v1/datasets",
|
||||
json_data={"ids": dataset_ids},
|
||||
)
|
||||
return True
|
||||
|
||||
# ==========================================================================
|
||||
# 文档管理
|
||||
# ==========================================================================
|
||||
|
||||
async def list_documents(
|
||||
self,
|
||||
dataset_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
) -> Dict[str, Any]:
|
||||
"""列出数据集中的文档。
|
||||
|
||||
Args:
|
||||
dataset_id: 数据集ID
|
||||
page: 页码
|
||||
page_size: 每页条数
|
||||
|
||||
Returns:
|
||||
Dict: {items: List[DocumentInfo], total: int}
|
||||
"""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/datasets/{dataset_id}/documents",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
items = [
|
||||
DocumentInfo.model_validate(doc)
|
||||
for doc in data.get("documents", [])
|
||||
]
|
||||
|
||||
return {"items": items, "total": data.get("total", 0)}
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
dataset_id: str,
|
||||
file_path: str,
|
||||
file_name: Optional[str] = None,
|
||||
) -> DocumentInfo:
|
||||
"""上传文档到数据集。
|
||||
|
||||
Args:
|
||||
dataset_id: 数据集ID
|
||||
file_path: 本地文件路径
|
||||
file_name: 文件名(可选,默认取 file_path 的文件名)
|
||||
|
||||
Returns:
|
||||
DocumentInfo: 上传的文档信息
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise RagflowError(f"文件不存在: {file_path}")
|
||||
|
||||
fname = file_name or os.path.basename(file_path)
|
||||
|
||||
url = f"{self.base_url}/api/v1/datasets/{dataset_id}/documents"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(file_path, "rb") as f:
|
||||
response = await client.post(
|
||||
url=url,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
files={"file": (fname, f)},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise RagflowAuthError()
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise RagflowApiError(
|
||||
code=result.get("code", -1),
|
||||
message=result.get("message", "上传失败"),
|
||||
)
|
||||
|
||||
docs = result.get("data", {}).get("documents", [])
|
||||
if docs:
|
||||
return DocumentInfo.model_validate(docs[0])
|
||||
return DocumentInfo(name=fname)
|
||||
|
||||
except (RagflowAuthError, RagflowApiError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RagflowError(f"文档上传失败: {str(e)}")
|
||||
|
||||
async def delete_documents(
|
||||
self,
|
||||
dataset_id: str,
|
||||
document_ids: List[str],
|
||||
) -> bool:
|
||||
"""删除文档。
|
||||
|
||||
Args:
|
||||
dataset_id: 数据集ID
|
||||
document_ids: 要删除的文档ID列表
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/api/v1/datasets/{dataset_id}/documents",
|
||||
json_data={"ids": document_ids},
|
||||
)
|
||||
return True
|
||||
@@ -1,61 +0,0 @@
|
||||
# =============================================================================
|
||||
# RAGFlow 配置加载器
|
||||
# =============================================================================
|
||||
# 说明:从数据库 system_configs 表加载 RAGFlow 配置,创建客户端实例
|
||||
# 配置项:integration_ragflow_api_url + integration_ragflow_api_key
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
from .client import RagflowClient
|
||||
from .exceptions import RagflowConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认 RAGFlow API 地址(生产环境)
|
||||
DEFAULT_RAGFLOW_BASE_URL = "http://10.80.0.85:9380"
|
||||
|
||||
|
||||
async def _get_config(db: AsyncSession, key: str) -> str:
|
||||
"""从数据库读取单个配置值。"""
|
||||
result = await db.execute(
|
||||
select(SystemConfig.config_value).where(SystemConfig.config_key == key)
|
||||
)
|
||||
row = result.scalar()
|
||||
return row if row else ""
|
||||
|
||||
|
||||
async def get_ragflow_client(db: AsyncSession) -> RagflowClient:
|
||||
"""从数据库配置创建 RAGFlow 客户端实例。
|
||||
|
||||
读取 system_configs 表中的:
|
||||
- integration_ragflow_api_url: RAGFlow API 地址
|
||||
- integration_ragflow_api_key: RAGFlow API Key
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
RagflowClient: 客户端实例
|
||||
|
||||
Raises:
|
||||
RagflowConfigError: 配置缺失
|
||||
"""
|
||||
api_url = await _get_config(db, "integration_ragflow_api_url")
|
||||
api_key = await _get_config(db, "integration_ragflow_api_key")
|
||||
|
||||
# 如果数据库没有配置,使用默认地址
|
||||
if not api_url:
|
||||
api_url = DEFAULT_RAGFLOW_BASE_URL
|
||||
|
||||
if not api_key:
|
||||
raise RagflowConfigError(
|
||||
"RAGFlow API Key 未配置,请在管理后台 → 集成管理 → RAGFlow 中设置"
|
||||
)
|
||||
|
||||
return RagflowClient(api_key=api_key, base_url=api_url)
|
||||
@@ -1,35 +0,0 @@
|
||||
# =============================================================================
|
||||
# RAGFlow API 异常定义
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RagflowError(Exception):
|
||||
"""RAGFlow 基础异常。"""
|
||||
def __init__(self, message: str = "RAGFlow 错误"):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class RagflowConfigError(RagflowError):
|
||||
"""配置错误(缺少 API Key 或 Base URL)。"""
|
||||
def __init__(self, message: str = "RAGFlow 配置缺失"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RagflowAuthError(RagflowError):
|
||||
"""认证失败(API Key 无效)。"""
|
||||
def __init__(self, message: str = "RAGFlow 认证失败"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RagflowApiError(RagflowError):
|
||||
"""API 调用失败(非 200 响应)。"""
|
||||
def __init__(self, code: int = 0, message: str = "RAGFlow API 错误"):
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RagflowConnectionError(RagflowError):
|
||||
"""网络连接失败。"""
|
||||
def __init__(self, message: str = "RAGFlow 连接失败"):
|
||||
super().__init__(message)
|
||||
@@ -1,110 +0,0 @@
|
||||
# =============================================================================
|
||||
# RAGFlow API 数据模型
|
||||
# =============================================================================
|
||||
# 说明:定义 RAGFlow API 请求/响应的 Pydantic 数据模型
|
||||
# 参考:https://ragflow.io/docs/http_api_reference
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RetrievalChunk(BaseModel):
|
||||
"""检索返回的单个文本块。
|
||||
|
||||
Attributes:
|
||||
id: 块唯一ID
|
||||
content: 块内容文本
|
||||
document_id: 所属文档ID
|
||||
document_keyword: 所属文档名称
|
||||
similarity: 综合相似度分数
|
||||
term_similarity: 关键词相似度
|
||||
vector_similarity: 向量相似度
|
||||
highlight: 高亮标记的内容(可选)
|
||||
"""
|
||||
id: str = Field(default="", description="块唯一ID")
|
||||
content: str = Field(default="", description="块内容文本")
|
||||
document_id: str = Field(default="", description="所属文档ID")
|
||||
document_keyword: str = Field(default="", description="所属文档名称")
|
||||
similarity: float = Field(default=0.0, description="综合相似度分数")
|
||||
term_similarity: float = Field(default=0.0, description="关键词相似度")
|
||||
vector_similarity: float = Field(default=0.0, description="向量相似度")
|
||||
highlight: Optional[str] = Field(default=None, description="高亮标记的内容")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocAggregate(BaseModel):
|
||||
"""文档聚合统计。
|
||||
|
||||
Attributes:
|
||||
doc_id: 文档ID
|
||||
doc_name: 文档名称
|
||||
count: 命中的块数量
|
||||
"""
|
||||
doc_id: str = Field(default="", description="文档ID")
|
||||
doc_name: str = Field(default="", description="文档名称")
|
||||
count: int = Field(default=0, description="命中块数量")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RetrievalResult(BaseModel):
|
||||
"""检索结果。
|
||||
|
||||
Attributes:
|
||||
chunks: 命中的文本块列表
|
||||
doc_aggs: 按文档聚合统计
|
||||
total: 命中总数
|
||||
"""
|
||||
chunks: List[RetrievalChunk] = Field(default_factory=list, description="命中文本块列表")
|
||||
doc_aggs: List[DocAggregate] = Field(default_factory=list, description="文档聚合统计")
|
||||
total: int = Field(default=0, description="命中总数")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DatasetInfo(BaseModel):
|
||||
"""数据集(知识库)信息。
|
||||
|
||||
Attributes:
|
||||
id: 数据集ID
|
||||
name: 数据集名称
|
||||
chunk_method: 分块方法
|
||||
permission: 权限
|
||||
document_count: 文档数量
|
||||
embedding_model: 向量模型
|
||||
create_time: 创建时间
|
||||
update_time: 更新时间
|
||||
"""
|
||||
id: str = Field(default="", description="数据集ID")
|
||||
name: str = Field(default="", description="数据集名称")
|
||||
chunk_method: str = Field(default="naive", description="分块方法")
|
||||
permission: str = Field(default="me", description="权限")
|
||||
document_count: int = Field(default=0, description="文档数量")
|
||||
embedding_model: str = Field(default="", description="向量模型")
|
||||
create_time: Optional[str] = Field(default=None, description="创建时间")
|
||||
update_time: Optional[str] = Field(default=None, description="更新时间")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentInfo(BaseModel):
|
||||
"""文档信息。
|
||||
|
||||
Attributes:
|
||||
id: 文档ID
|
||||
name: 文档名称
|
||||
chunk_method: 分块方法
|
||||
chunk_count: 块数量
|
||||
create_time: 创建时间
|
||||
update_time: 更新时间
|
||||
"""
|
||||
id: str = Field(default="", description="文档ID")
|
||||
name: str = Field(default="", description="文档名称")
|
||||
chunk_method: str = Field(default="naive", description="分块方法")
|
||||
chunk_count: int = Field(default=0, description="块数量")
|
||||
create_time: Optional[str] = Field(default=None, description="创建时间")
|
||||
update_time: Optional[str] = Field(default=None, description="更新时间")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -1,331 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — AI 服务(Dify 接入)
|
||||
# =============================================================================
|
||||
# 做什么:封装 Dify API 调用,实现 AI 自动回复
|
||||
# 为什么:
|
||||
# - ARCHITECTURE.md 设计了 ai_handling 状态,但当前未实现
|
||||
# - 现有系统交接文档提供了 Dify API 地址和 Key
|
||||
# - 这是实现「AI 自助解决」的核心模块
|
||||
# 依赖:需要 Dify API 可达(生产环境 http://yw-dify.dc.servyou-it.com)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIService:
|
||||
"""AI 服务:封装 Dify API,提供 AI 回复能力。
|
||||
|
||||
支持两种调用模式:
|
||||
1. 非流式(简单场景):一次性获取完整回复
|
||||
2. 流式(推荐):SSE 流式返回,前端可逐字显示
|
||||
|
||||
参考:现有系统交接文档
|
||||
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
|
||||
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 AI 服务。
|
||||
|
||||
做什么:从配置读取 Dify API 地址和认证信息
|
||||
为什么:集中管理 API 配置,便于切换测试/生产环境
|
||||
"""
|
||||
# Dify 兼容 OpenAI 格式的 API 端点
|
||||
self.api_url = settings.dify_api_url
|
||||
# Dify API Key(格式:base_url|app_id|app_name)
|
||||
self.api_key = settings.dify_api_key
|
||||
# 请求超时(秒)
|
||||
self.timeout = settings.dify_timeout
|
||||
|
||||
# httpx 异步客户端(复用连接池)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端。
|
||||
|
||||
做什么:懒加载 httpx.AsyncClient,复用连接池
|
||||
为什么:避免每次请求都创建新连接,提升性能
|
||||
"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端。
|
||||
|
||||
做什么:释放连接池资源
|
||||
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
|
||||
"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("AIService httpx client closed")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 非流式调用:一次性获取 AI 完整回复
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_reply(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 Dify API 获取 AI 回复(非流式)。
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容
|
||||
conversation_id: 会话ID(用于 Dify 多轮对话上下文)
|
||||
user_id: 员工企微 UserID(用于 Dify 用户标识)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"content": str, # AI 回复内容
|
||||
"hit": bool, # 是否命中知识库(可回复)
|
||||
"conversation_id": str, # Dify 会话ID(用于后续多轮对话)
|
||||
"usage": dict, # Token 用量(可选)
|
||||
}
|
||||
|
||||
做什么:发送消息到 Dify,解析返回内容,判断是否能回复
|
||||
为什么:
|
||||
- 非流式适合简单场景,代码简单
|
||||
- 返回结构兼容 OpenAI Chat Completions 格式
|
||||
- 通过回复内容判断是否命中知识库(有实质内容 = 命中)
|
||||
"""
|
||||
payload = {
|
||||
"model": "Chat", # Dify 应用名称(来自 API Key 格式)
|
||||
"messages": [
|
||||
{"role": "user", "content": message}
|
||||
],
|
||||
"stream": False, # 非流式
|
||||
"temperature": 0.1, # 低温度,保证回答稳定性
|
||||
}
|
||||
|
||||
# 传入 Dify 会话ID,保持多轮对话上下文
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
|
||||
# 传入用户标识(Dify 侧用于日志和追溯)
|
||||
if user_id:
|
||||
payload["user"] = user_id
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
logger.info(f"调用 Dify API: message={message[:50]}...")
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 OpenAI 兼容格式的返回
|
||||
# 格式:{"choices": [{"message": {"content": "..."}}]}
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning("Dify API 返回空 choices")
|
||||
return {
|
||||
"content": "",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
reply_content = choices[0]["message"]["content"]
|
||||
|
||||
# 判断是否命中知识库:
|
||||
# 策略1:检查内容是否为空或过长(Dify 可能返回提示语)
|
||||
# 策略2:检查是否包含「抱歉」「不知道」等无法回答的特征词
|
||||
hit = self._check_knowledge_hit(reply_content)
|
||||
|
||||
# 提取 Dify 返回的 conversation_id(用于多轮对话)
|
||||
dify_conv_id = data.get("conversation_id", conversation_id or "")
|
||||
|
||||
logger.info(
|
||||
f"Dify API 返回: hit={hit}, "
|
||||
f"content_length={len(reply_content)}, "
|
||||
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
|
||||
)
|
||||
|
||||
return {
|
||||
"content": reply_content,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"usage": data.get("usage", {}),
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Dify API 超时")
|
||||
return {
|
||||
"content": "⏰ AI 服务响应超时,请稍后再试或输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
|
||||
return {
|
||||
"content": "⚠️ AI 服务暂时不可用,请输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Dify API 调用失败: {e}")
|
||||
return {
|
||||
"content": "⚠️ AI 服务异常,请输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_reply_stream(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。
|
||||
|
||||
Yields:
|
||||
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
|
||||
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
|
||||
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
|
||||
|
||||
实现:
|
||||
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
|
||||
- 流结束后用完整内容整体判断 hit(_check_knowledge_hit)
|
||||
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
|
||||
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达",
|
||||
功能不破,仅无逐字动画)。
|
||||
"""
|
||||
payload = {
|
||||
"model": "Chat",
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"stream": True,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
if user_id:
|
||||
payload["user"] = user_id
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
full_parts: list = []
|
||||
dify_conv_id = conversation_id or ""
|
||||
async with client.stream("POST", self.api_url, json=payload) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# OpenAI / Dify SSE 格式:choices[0].delta.content
|
||||
try:
|
||||
delta = chunk["choices"][0]["delta"].get("content", "")
|
||||
except (KeyError, IndexError, TypeError):
|
||||
delta = ""
|
||||
if delta:
|
||||
full_parts.append(delta)
|
||||
yield {
|
||||
"delta": delta,
|
||||
"finished": False,
|
||||
"conversation_id": dify_conv_id,
|
||||
"hit": None,
|
||||
}
|
||||
# Dify 可能在流式块里给出 conversation_id
|
||||
cid = chunk.get("conversation_id")
|
||||
if cid:
|
||||
dify_conv_id = cid
|
||||
|
||||
# 流结束:用完整内容判断命中
|
||||
full_content = "".join(full_parts)
|
||||
hit = self._check_knowledge_hit(full_content) if full_content else False
|
||||
yield {
|
||||
"delta": "",
|
||||
"finished": True,
|
||||
"conversation_id": dify_conv_id,
|
||||
"hit": hit,
|
||||
}
|
||||
except Exception as e:
|
||||
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
|
||||
logger.warning(f"Dify 流式失败,回退非流式: {e}")
|
||||
try:
|
||||
result = await self.get_reply(message, conversation_id, user_id)
|
||||
yield {
|
||||
"delta": result["content"],
|
||||
"finished": True,
|
||||
"conversation_id": result["conversation_id"],
|
||||
"hit": result["hit"],
|
||||
}
|
||||
except Exception as e2:
|
||||
logger.error(f"Dify 流式与非流式均失败: {e2}")
|
||||
yield {
|
||||
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
|
||||
"finished": True,
|
||||
"conversation_id": conversation_id or "",
|
||||
"hit": False,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 判断是否命中知识库
|
||||
# --------------------------------------------------------------------------
|
||||
def _check_knowledge_hit(self, content: str) -> bool:
|
||||
"""判断 AI 回复是否命中知识库(可以回答用户问题)。
|
||||
|
||||
Args:
|
||||
content: AI 回复内容
|
||||
|
||||
Returns:
|
||||
bool: True=命中(可以回复),False=未命中(需转人工)
|
||||
|
||||
做什么:分析 AI 回复内容,判断是否能有效回答问题
|
||||
为什么:
|
||||
- Dify 在无法回答时通常会返回固定提示语
|
||||
- 参考现有系统:「抱歉,您的问题可能不在服务业务范围内」
|
||||
- 命中 = 有实质内容且不像是「无法回答」的提示
|
||||
"""
|
||||
if not content or len(content.strip()) < 5:
|
||||
return False
|
||||
|
||||
# 未命中特征词(Dify 无法回答时的典型回复)
|
||||
miss_keywords = [
|
||||
"抱歉", "对不起", "不知道", "无法回答",
|
||||
"不在服务范围内", "超出我的能力", "暂不支持",
|
||||
"请转人工", "联系管理员",
|
||||
]
|
||||
content_lower = content.lower()
|
||||
|
||||
# 如果回复中包含多个未命中特征词 → 判断为未命中
|
||||
miss_count = sum(1 for kw in miss_keywords if kw in content_lower)
|
||||
if miss_count >= 2:
|
||||
return False
|
||||
|
||||
# 如果回复长度过短(< 10 字符)且包含特征词 → 未命中
|
||||
if len(content) < 10 and any(kw in content_lower for kw in miss_keywords):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1,331 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 内容审核服务
|
||||
# =============================================================================
|
||||
# 说明:#81 v0.6.0 内容审核 — 检测敏感词 + 提示坐席优化语气
|
||||
# 用途:坐席发送消息前自动审核,避免发送违规内容
|
||||
# 设计:基于 wordfilter 开源库 + 自定义敏感词库
|
||||
# =============================================================================
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from wordfilter import Wordfilter
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModerationAction(str, Enum):
|
||||
"""内容审核动作"""
|
||||
PASS = "pass" # 通过
|
||||
WARN = "warn" # 警告(允许发送,但标记)
|
||||
BLOCK = "block" # 阻断(必须修改)
|
||||
|
||||
|
||||
class ModerationCategory(str, Enum):
|
||||
"""审核分类"""
|
||||
PROFANITY = "profanity" # 脏话
|
||||
POLITICS = "politics" # 政治敏感
|
||||
PORN = "porn" # 色情
|
||||
AD = "ad" # 广告
|
||||
PRIVACY = "privacy" # 隐私泄露(身份证/电话)
|
||||
OTHER = "other" # 其他
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationResult:
|
||||
"""审核结果"""
|
||||
action: ModerationAction
|
||||
category: Optional[ModerationCategory]
|
||||
matched_words: List[str]
|
||||
suggestion: str = ""
|
||||
|
||||
@property
|
||||
def is_blocked(self) -> bool:
|
||||
return self.action == ModerationAction.BLOCK
|
||||
|
||||
@property
|
||||
def is_warned(self) -> bool:
|
||||
return self.action == ModerationAction.WARN
|
||||
|
||||
|
||||
class ContentModerationService:
|
||||
"""内容审核服务 — 检测 + 提示。
|
||||
|
||||
设计要点:
|
||||
1. 加载 wordfilter + 自定义敏感词库
|
||||
2. 提供 3 个级别动作:pass / warn / block
|
||||
3. 返回命中的敏感词,给前端提示
|
||||
4. 异步不阻塞消息发送主流程
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 初始化 wordfilter(新 API: Wordfilter() 实例,而非 init() 全局)
|
||||
self.wf = Wordfilter()
|
||||
# 加载自定义敏感词库(预留,生产环境从配置文件加载)
|
||||
self.custom_sensitive_words: List[str] = [
|
||||
# 坐席严禁发送的
|
||||
"投诉我", # 暗示员工投诉自己
|
||||
"你爱找谁找谁", # 不当推诿
|
||||
"自己不会百度吗", # 不当反问
|
||||
"这点小事", # 轻视员工问题
|
||||
# 隐私保护(后端检测,前端不知道)
|
||||
# 实际部署时从 system_config 加载
|
||||
]
|
||||
if self.custom_sensitive_words:
|
||||
self.wf.addWords(self.custom_sensitive_words)
|
||||
|
||||
# ==================================================================
|
||||
# 主入口
|
||||
# ==================================================================
|
||||
|
||||
def moderate(self, text: str) -> ModerationResult:
|
||||
"""审核文本。
|
||||
|
||||
Args:
|
||||
text: 待审核文本(坐席准备发的消息)
|
||||
|
||||
Returns:
|
||||
ModerationResult: 审核结果
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ModerationResult(
|
||||
action=ModerationAction.PASS,
|
||||
category=None,
|
||||
matched_words=[],
|
||||
)
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# 1. wordfilter 检测
|
||||
matched: List[str] = []
|
||||
if self.wf.blacklisted(text):
|
||||
# 找出具体哪些词命中
|
||||
matched = self._extract_matched(text)
|
||||
|
||||
if not matched:
|
||||
return ModerationResult(
|
||||
action=ModerationAction.PASS,
|
||||
category=None,
|
||||
matched_words=[],
|
||||
)
|
||||
|
||||
# 2. 分类(简单规则:有命中就给 warn,后续可分级)
|
||||
category = self._classify(matched)
|
||||
|
||||
# 3. 决定动作(目前策略:命中即 warn,后续可升级 block)
|
||||
# 后续决策点:是否给某些类(政治/色情)直接 block
|
||||
action = ModerationAction.WARN
|
||||
suggestion = self._generate_suggestion(category, matched)
|
||||
|
||||
logger.info(
|
||||
f"[ContentModeration] 检测到敏感词 text={text[:30]}... "
|
||||
f"matched={matched} category={category}"
|
||||
)
|
||||
|
||||
return ModerationResult(
|
||||
action=action,
|
||||
category=category,
|
||||
matched_words=matched,
|
||||
suggestion=suggestion,
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# 隐私信息检测(基于正则,跟敏感词无关)
|
||||
# ==================================================================
|
||||
|
||||
def check_privacy_leak(self, text: str) -> List[str]:
|
||||
"""检测文本是否包含隐私信息(身份证 / 电话 / 银行卡)。
|
||||
|
||||
Returns:
|
||||
命中的隐私字段列表(描述性,如 ["phone", "id_card"])
|
||||
"""
|
||||
import re
|
||||
leaked = []
|
||||
|
||||
# 手机号(11位1开头)
|
||||
# BUGFIX: \b 和 (?<!\w) 对中文均失效(Python3 \w 含中文),
|
||||
# 改用 (?<!\d) / (?!\d) 检查数字边界——"电话13800138000" 可正确匹配
|
||||
if re.search(r"(?<!\d)1[3-9]\d{9}(?!\d)", text):
|
||||
leaked.append("phone")
|
||||
|
||||
# 身份证号(18位)
|
||||
if re.search(r"(?<!\d)\d{17}[\dXx](?!\d)", text):
|
||||
leaked.append("id_card")
|
||||
|
||||
# 银行卡(16-19位连续数字,简单判断)
|
||||
if re.search(r"(?<!\d)\d{16,19}(?!\d)", text):
|
||||
leaked.append("bank_card")
|
||||
|
||||
# 邮箱(个人邮箱,非公司邮箱)
|
||||
# 邮箱以 ASCII 字母开头,(?<!\w) 这里可用(前面不会是中文邮箱前缀)
|
||||
personal_email_pattern = (
|
||||
r"(?<!\w)[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
|
||||
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?!\w)"
|
||||
)
|
||||
if re.search(personal_email_pattern, text):
|
||||
leaked.append("personal_email")
|
||||
|
||||
return leaked
|
||||
|
||||
# ==================================================================
|
||||
# 工具方法
|
||||
# ==================================================================
|
||||
|
||||
def _extract_matched(self, text: str) -> List[str]:
|
||||
"""提取命中的敏感词。"""
|
||||
# wordfilter 没有直接的 "提取所有命中词" API,只能 replace 看
|
||||
matched = []
|
||||
# 遍历自建词库看哪些命中
|
||||
for word in self.custom_sensitive_words:
|
||||
if word in text:
|
||||
matched.append(word)
|
||||
return matched
|
||||
|
||||
def _classify(self, matched: List[str]) -> ModerationCategory:
|
||||
"""根据命中的词分类。"""
|
||||
# 简单分类:命中"投诉""爱找谁"等 → profanity
|
||||
# 后续可扩展
|
||||
return ModerationCategory.PROFANITY
|
||||
|
||||
def _generate_suggestion(
|
||||
self, category: ModerationCategory, matched: List[str]
|
||||
) -> str:
|
||||
"""生成修改建议。"""
|
||||
suggestions_map = {
|
||||
ModerationCategory.PROFANITY: (
|
||||
"建议改为更专业的表达,例如:"
|
||||
"「我理解您的问题,我们一起想办法解决」"
|
||||
),
|
||||
ModerationCategory.POLITICS: (
|
||||
"请避免讨论政治话题,保持服务专业性"
|
||||
),
|
||||
ModerationCategory.PORN: "请使用正式语言",
|
||||
ModerationCategory.AD: "请勿发送广告内容",
|
||||
ModerationCategory.PRIVACY: (
|
||||
"请勿发送员工隐私信息(电话/身份证),如需联系请走企微"
|
||||
),
|
||||
ModerationCategory.OTHER: "请检查并修改表达",
|
||||
}
|
||||
return suggestions_map.get(category, "请检查并修改表达")
|
||||
|
||||
@staticmethod
|
||||
def _get_fallback_question(keywords: List[str]) -> dict:
|
||||
"""Dify 失败时的兜底题(从预置题池随机抽一道)。
|
||||
|
||||
注意:这里写死 10 道 IT 基础题,生产环境可改成查 quiz_questions.source='manual'
|
||||
"""
|
||||
import random
|
||||
|
||||
fallback_pool = [
|
||||
{
|
||||
"question": "电脑突然黑屏,最安全的做法是?",
|
||||
"options": ["强制关机重启", "拔电源重启", "等几分钟看是否恢复", "砸电脑"],
|
||||
"correct_index": 0,
|
||||
"hint": "想想最稳妥的第一步",
|
||||
"explanation": "黑屏可能是系统卡死,强制重启通常能恢复,拔电源可能损坏硬件",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "打印机不响应,首先应该检查?",
|
||||
"options": ["打印机电源", "重装系统", "换台电脑", "直接呼叫维修"],
|
||||
"correct_index": 0,
|
||||
"hint": "最基础的物理连接",
|
||||
"explanation": "80% 故障是电源/线缆问题,先排除最简单的再考虑复杂方案",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "密码忘了应该怎么办?",
|
||||
"options": ["自己猜", "暴力破解", "找 IT 重置", "不用了"],
|
||||
"correct_index": 2,
|
||||
"hint": "走正规流程最安全",
|
||||
"explanation": "找 IT 重置是最快最安全的做法,自己猜可能锁账号,暴力破解违法",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "无法连接公司 VPN,首选排查?",
|
||||
"options": ["检查网络是否通", "重装系统", "换电脑", "联系运营商"],
|
||||
"correct_index": 0,
|
||||
"hint": "从外到内排查",
|
||||
"explanation": "先确认能上网,再排查 VPN 客户端,最后才是公司 VPN 服务器",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "Outlook 收不到邮件,先看哪里?",
|
||||
"options": ["垃圾邮件箱", "重装 Office", "换邮箱", "打电话给 IT"],
|
||||
"correct_index": 0,
|
||||
"hint": "最容易被忽略的",
|
||||
"explanation": "新邮件被误判到垃圾箱是常见原因,先看再排查服务器",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "Office 软件打开慢,先做什么?",
|
||||
"options": ["清理开机启动项", "换电脑", "买新硬盘", "卸载重装"],
|
||||
"correct_index": 0,
|
||||
"hint": "性能问题先减负",
|
||||
"explanation": "开机启动项太多会拖慢所有应用,清理后再观察",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "电脑提示磁盘空间不足,应该?",
|
||||
"options": ["清理回收站和临时文件", "关机", "重装系统", "不处理"],
|
||||
"correct_index": 0,
|
||||
"hint": "先释放空间再判断",
|
||||
"explanation": "90% 的情况清理回收站 + temp 目录就能解决,严重才需要重装",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "网页打不开,首先排查?",
|
||||
"options": ["检查网络连接", "换浏览器", "重装系统", "砸键盘"],
|
||||
"correct_index": 0,
|
||||
"hint": "从最基础的开始",
|
||||
"explanation": "先看能不能打开其他网页,排除是网站问题还是网络问题",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "U 盘插入电脑没反应,先检查?",
|
||||
"options": ["换个 USB 接口", "格式化 U 盘", "扔了", "拆电脑"],
|
||||
"correct_index": 0,
|
||||
"hint": "先排除最简单的问题",
|
||||
"explanation": "USB 接口可能松动或供电不足,先换接口试,不要先动数据",
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"question": "电脑突然变卡,第一步应该?",
|
||||
"options": ["看任务管理器占用", "砸电脑", "重装系统", "关机睡觉"],
|
||||
"correct_index": 0,
|
||||
"hint": "数据先行",
|
||||
"explanation": "任务管理器能看到 CPU/内存/磁盘占用,定位是哪个进程在吃资源",
|
||||
"source": "manual",
|
||||
},
|
||||
]
|
||||
|
||||
chosen = random.choice(fallback_pool)
|
||||
return chosen
|
||||
|
||||
def add_custom_word(self, word: str) -> None:
|
||||
"""动态添加敏感词(运营后台调用)。"""
|
||||
self.wf.addWords([word])
|
||||
if word not in self.custom_sensitive_words:
|
||||
self.custom_sensitive_words.append(word)
|
||||
|
||||
def remove_custom_word(self, word: str) -> None:
|
||||
"""动态删除敏感词。"""
|
||||
# wordfilter 没有 remove API,降级用 replace 占位
|
||||
# wordfilter.remove(word) # 实际库不一定支持
|
||||
if word in self.custom_sensitive_words:
|
||||
self.custom_sensitive_words.remove(word)
|
||||
|
||||
|
||||
# 单例
|
||||
_moderation_service: Optional[ContentModerationService] = None
|
||||
|
||||
|
||||
def get_moderation_service() -> ContentModerationService:
|
||||
"""获取内容审核服务单例。"""
|
||||
global _moderation_service
|
||||
if _moderation_service is None:
|
||||
_moderation_service = ContentModerationService()
|
||||
return _moderation_service
|
||||
@@ -1,237 +0,0 @@
|
||||
# =============================================================================
|
||||
# 员工目录解析服务
|
||||
# =============================================================================
|
||||
# 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 UserID,
|
||||
# 并校验该员工确实属于企微组织架构(需求:分配角色时按姓名/账号自动转换 + 校验)。
|
||||
#
|
||||
# 数据源优先级(自动适配,无需改代码即可在权限开通后升级):
|
||||
# 1. 企微通讯录(实时):
|
||||
# - get_user_info(userid) 校验账号是否为组织内真实员工
|
||||
# - get_department_members(1, 1) 拉取全组织架构,用于「姓名 -> 账号」匹配
|
||||
# - 需要企微应用具备「通讯录读取」权限;权限不足(errcode 60011)时自动降级
|
||||
# 2. 本地 employees 表(仅登录过的员工):作为降级目录,保证功能在缺权限时仍可用
|
||||
#
|
||||
# 设计目标:无论企微权限是否齐全,分配功能都可用;权限齐全时自动获得全公司
|
||||
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API)
|
||||
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
|
||||
ORG_DIRECTORY_CACHE_TTL = 600
|
||||
|
||||
|
||||
async def get_org_directory(
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis],
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。
|
||||
|
||||
优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。
|
||||
若企微权限不足或调用失败,降级到本地 employees 表。
|
||||
|
||||
Returns:
|
||||
(directory, full_directory)
|
||||
- directory: [{"employee_id": str, "name": str, "department": str}, ...]
|
||||
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
|
||||
"""
|
||||
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
|
||||
if redis:
|
||||
try:
|
||||
raw = await redis.get(ORG_DIRECTORY_CACHE_KEY)
|
||||
if raw:
|
||||
logger.debug("命中组织目录缓存")
|
||||
return json.loads(raw.decode("utf-8")), True
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织目录缓存失败(降级): {e}")
|
||||
|
||||
# 2. 尝试从企微通讯录拉全组织
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
|
||||
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||||
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2])
|
||||
dept_map: Dict[int, str] = {}
|
||||
try:
|
||||
departments = await wecom.get_department_list()
|
||||
dept_map = {
|
||||
dept.get("id"): dept.get("name", "")
|
||||
for dept in departments
|
||||
if dept.get("id") is not None
|
||||
}
|
||||
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
|
||||
except Exception as e:
|
||||
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
|
||||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
|
||||
|
||||
directory = [
|
||||
{
|
||||
"employee_id": m.get("userid", ""),
|
||||
"name": m.get("name", "") or "",
|
||||
# 优先用部门名映射,映射不到时降级为 ID 字符串
|
||||
"department": ",".join(
|
||||
dept_map.get(d, str(d)) for d in (m.get("department") or [])
|
||||
),
|
||||
}
|
||||
for m in members
|
||||
if m.get("userid")
|
||||
]
|
||||
# 写入缓存(仅全组织结果缓存,降级结果不缓存以免长期误用)
|
||||
if redis:
|
||||
try:
|
||||
await redis.setex(
|
||||
ORG_DIRECTORY_CACHE_KEY,
|
||||
ORG_DIRECTORY_CACHE_TTL,
|
||||
json.dumps(directory, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入组织目录缓存失败: {e}")
|
||||
logger.info(f"组织目录来自企微全组织,共 {len(directory)} 人")
|
||||
return directory, True
|
||||
except Exception as e:
|
||||
err_text = str(e)
|
||||
if "60011" in err_text or "privilege" in err_text.lower():
|
||||
logger.warning("企微通讯录部门读取权限不足,降级到本地 employees 表")
|
||||
else:
|
||||
logger.warning(f"企微通讯录获取失败,降级本地: {err_text}")
|
||||
|
||||
# 3. 降级:本地 employees 表(仅登录过的员工)
|
||||
# 注意:department 字段存储的是部门ID JSON数组(如 "[1,2]"),降级时无法解析为部门名;
|
||||
# 但在 DEV_MODE 下可直接存部门名(如 "研发一部"),使组织架构树在本地开发时也有数据
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Employee.employee_id, Employee.name, Employee.department).where(Employee.employee_id != "")
|
||||
)
|
||||
rows = result.all()
|
||||
directory = []
|
||||
for r in rows:
|
||||
dept_raw = r[2] or ""
|
||||
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]")
|
||||
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
|
||||
dept_name = ""
|
||||
if dept_raw:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(dept_raw)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
# 部门ID列表:取第一个ID(降级时无法解析ID为名称,留空)
|
||||
dept_name = ""
|
||||
else:
|
||||
dept_name = str(parsed)
|
||||
except (ValueError, TypeError):
|
||||
# 不是 JSON 格式,直接作为部门名使用(DEV_MODE 场景)
|
||||
dept_name = dept_raw
|
||||
directory.append({
|
||||
"employee_id": r[0],
|
||||
"name": r[1] or "",
|
||||
"department": dept_name,
|
||||
})
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
except Exception as e:
|
||||
logger.error(f"本地 employees 表查询失败: {e}")
|
||||
return [], False
|
||||
|
||||
|
||||
async def resolve_target(
|
||||
target: str,
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""将输入的「员工账号 或 姓名」解析为企微 UserID,并校验组织内存在性。
|
||||
|
||||
Returns(结构化结果,由调用方翻译为响应/异常):
|
||||
{"found": True, "employee_id": str, "name": str, "source": str}
|
||||
{"found": False, "reason": str, "suggestion": str}
|
||||
{"ambiguous": True, "candidates": [{"employee_id","name","department"}, ...]}
|
||||
"""
|
||||
target = (target or "").strip()
|
||||
if not target:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": "请输入员工账号或姓名",
|
||||
"suggestion": "请填写企微员工账号或姓名后重试",
|
||||
}
|
||||
|
||||
wecom = WecomService(redis_client=redis)
|
||||
|
||||
# 1) 先尝试按 userid 实时校验(企微组织内真实员工)
|
||||
try:
|
||||
info = await wecom.get_user_info(target)
|
||||
# 成功 -> target 本身就是有效 userid
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or target,
|
||||
"name": info.get("name") or "",
|
||||
"source": "wecom_userid",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"get_user_info('{target}') 未命中(将尝试按姓名解析): {e}")
|
||||
|
||||
# 2) 按姓名(包含)解析
|
||||
directory, full = await get_org_directory(db, redis)
|
||||
ql = target.lower()
|
||||
# 精确 userid 匹配优先(目录里可能存在)
|
||||
exact = [m for m in directory if m["employee_id"] and m["employee_id"].lower() == ql]
|
||||
# 姓名包含匹配
|
||||
name_hits = [m for m in directory if m["name"] and ql in m["name"].lower()]
|
||||
matches = exact if exact else name_hits
|
||||
|
||||
if len(matches) == 1:
|
||||
m = matches[0]
|
||||
# 二次实时校验该 userid 确实在组织内(网络可用时)
|
||||
try:
|
||||
info = await wecom.get_user_info(m["employee_id"])
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": info.get("userid") or m["employee_id"],
|
||||
"name": info.get("name") or m.get("name", ""),
|
||||
"source": "wecom_name" if full else "local_name",
|
||||
}
|
||||
except Exception:
|
||||
# 实时校验失败(网络/权限),但目录里有 -> 仍可用
|
||||
return {
|
||||
"found": True,
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m.get("name", ""),
|
||||
"source": "local_name",
|
||||
}
|
||||
|
||||
if len(matches) > 1:
|
||||
return {
|
||||
"ambiguous": True,
|
||||
"candidates": [
|
||||
{
|
||||
"employee_id": m["employee_id"],
|
||||
"name": m["name"],
|
||||
"department": m.get("department", ""),
|
||||
}
|
||||
for m in matches[:10]
|
||||
],
|
||||
}
|
||||
|
||||
# 未找到
|
||||
if full:
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"企微组织架构中未找到匹配「{target}」的员工",
|
||||
"suggestion": "请确认姓名/账号拼写,或改为输入员工账号",
|
||||
}
|
||||
return {
|
||||
"found": False,
|
||||
"reason": f"未找到匹配「{target}」的员工",
|
||||
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 业务路由推荐服务
|
||||
# =============================================================================
|
||||
# 说明:核心路由逻辑,在 H5 后台 AI 任务中拦截非IT业务消息,
|
||||
# 调用 Dify 统一意图识别,判定业务类别后发送对应联系人名片卡片。
|
||||
#
|
||||
# 主要职责:
|
||||
# 1. 关键词预过滤(ROUTING_PREFILTER_KEYWORDS)— 快速过滤非路由消息
|
||||
# 2. Dify 统一意图识别 — 调用 /v1/chat-messages,解析 intent_type/business_category/routing_confidence
|
||||
# 3. 联系人查询 — 按 business_category 查 business_contacts 表
|
||||
# 4. 名片三段式发送 — 路由文本 → contact_card → 系统提示(WS双通道推送)
|
||||
# 5. 路由事件记录(P1)— 记录路由命中统计
|
||||
#
|
||||
# 设计决策:
|
||||
# - 路由检测放在后台任务而非前端调用,与 BYOD 卡片处理模式一致
|
||||
# - 关键词预过滤与审批预过滤可能重叠,Dify Prompt 内部判断优先级确保正确分流
|
||||
# - routing_confidence < 0.7 不触发名片推荐,走正常 AI 回复流程
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.models.business_contact import BusinessContact
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.models.routing_event import RoutingEvent
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 路由关键词预过滤列表
|
||||
# =============================================================================
|
||||
# 说明:覆盖 5 个业务类别的关键词,用于快速过滤非路由消息。
|
||||
# 只要命中任意一个关键词才值得调用 Dify 做精确判断。
|
||||
# 关键词可能与审批预过滤重叠(如"办公用品"),Dify Prompt 内部判断
|
||||
# 优先级(先审批→再IT咨询→再非IT路由)确保正确分流。
|
||||
|
||||
ROUTING_PREFILTER_KEYWORDS: list[str] = [
|
||||
# 行政
|
||||
"打印机", "复印机", "扫描仪", "保洁", "名片印刷",
|
||||
# 人力资源
|
||||
"工牌", "考勤", "入职", "离职", "社保", "公积金",
|
||||
# 财务
|
||||
"报销", "发票", "借款", "工资条",
|
||||
# 法务
|
||||
"合同", "法务", "知识产权",
|
||||
# 行政-物业
|
||||
"空调", "电梯", "门禁", "停车",
|
||||
]
|
||||
|
||||
# 关键词到业务类别的映射(Dify 不可用时降级兜底用)
|
||||
ROUTING_KEYWORD_TO_CATEGORY: dict[str, str] = {
|
||||
# 行政
|
||||
"打印机": "行政", "复印机": "行政", "扫描仪": "行政",
|
||||
"保洁": "行政", "名片印刷": "行政",
|
||||
# 人力资源
|
||||
"工牌": "人力资源", "考勤": "人力资源", "入职": "人力资源",
|
||||
"离职": "人力资源", "社保": "人力资源", "公积金": "人力资源",
|
||||
# 财务
|
||||
"报销": "财务", "发票": "财务", "借款": "财务", "工资条": "财务",
|
||||
# 法务
|
||||
"合同": "法务", "法务": "法务", "知识产权": "法务",
|
||||
# 行政-物业
|
||||
"空调": "行政-物业", "电梯": "行政-物业", "门禁": "行政-物业", "停车": "行政-物业",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 预过滤 & 降级兜底
|
||||
# =============================================================================
|
||||
|
||||
def routing_keyword_prefilter(text: str) -> bool:
|
||||
"""路由关键词预过滤:检查文本是否包含非IT业务关键词。
|
||||
|
||||
只要命中任意一个路由关键词即返回 True,未命中返回 False。
|
||||
用于在调用 Dify 前快速过滤,减少不必要的 API 调用。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
|
||||
Returns:
|
||||
bool: 是否包含路由关键词
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
return any(kw in text for kw in ROUTING_PREFILTER_KEYWORDS)
|
||||
|
||||
|
||||
def _keyword_fallback_category(text: str) -> Optional[str]:
|
||||
"""关键词降级兜底:Dify 不可用时通过关键词匹配业务类别。
|
||||
|
||||
遍历 ROUTING_KEYWORD_TO_CATEGORY 映射,命中第一个关键词即返回对应业务类别。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
|
||||
Returns:
|
||||
Optional[str]: 业务类别(行政/人力资源/财务/法务/行政-物业),未命中返回 None
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
for kw, category in ROUTING_KEYWORD_TO_CATEGORY.items():
|
||||
if kw in text:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dify 统一意图识别调用
|
||||
# =============================================================================
|
||||
|
||||
async def detect_routing_intent(text: str, employee_id: str = "") -> dict:
|
||||
"""调用 Dify 统一意图识别,解析路由相关字段。
|
||||
|
||||
复用 approval.py 的 _call_dify_approval_intent 调用模式(Dify 原生 API),
|
||||
但本函数独立维护,解析路由关心的字段:
|
||||
- intent_type: approval/it_consult/non_it_routing/chitchat
|
||||
- business_category: 行政/人力资源/财务/法务/行政-物业(仅 non_it_routing 时有值)
|
||||
- routing_confidence: 0.0~1.0,≥0.7 触发名片推荐
|
||||
|
||||
使用与审批意图识别相同的 Dify 应用(同一 API Key),只是解析各自关心的字段。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"intent_type": str,
|
||||
"business_category": str | None,
|
||||
"routing_confidence": float,
|
||||
"is_approval_request": bool,
|
||||
"confidence": float,
|
||||
"approval_type": str | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
Exception: Dify 调用失败或响应解析失败
|
||||
"""
|
||||
base_url = settings.approval_dify_base_url
|
||||
api_key = settings.approval_dify_api_key
|
||||
timeout = settings.approval_dify_timeout
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise ValueError(
|
||||
"Dify 统一意图识别应用未配置"
|
||||
"(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY)"
|
||||
)
|
||||
|
||||
# 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 API)
|
||||
url = f"{base_url.rstrip('/')}/v1/chat-messages"
|
||||
|
||||
body = {
|
||||
"inputs": {},
|
||||
"query": text,
|
||||
"response_mode": "blocking",
|
||||
"user": employee_id or "routing_detection",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
|
||||
response = await client.post(url, json=body, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串
|
||||
answer = data.get("answer", "")
|
||||
parsed = json.loads(answer)
|
||||
|
||||
# 解析统一意图识别的 6 个字段
|
||||
return {
|
||||
"is_approval_request": bool(parsed.get("is_approval_request", False)),
|
||||
"confidence": float(parsed.get("confidence", 0.0)),
|
||||
"approval_type": parsed.get("approval_type"),
|
||||
"intent_type": str(parsed.get("intent_type", "chitchat")),
|
||||
"business_category": parsed.get("business_category"),
|
||||
"routing_confidence": float(parsed.get("routing_confidence", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 联系人查询
|
||||
# =============================================================================
|
||||
|
||||
async def get_contact_by_category(db, category: str) -> Optional[BusinessContact]:
|
||||
"""按业务类别查询联系人。
|
||||
|
||||
按 business_category + is_active=True 查询,取第一条有效联系人。
|
||||
P0 阶段单联系人推荐,P2 支持按服务区域匹配。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
category: 业务类别(行政/人力资源/财务/法务/行政-物业)
|
||||
|
||||
Returns:
|
||||
Optional[BusinessContact]: 联系人对象,未找到返回 None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(BusinessContact)
|
||||
.where(
|
||||
BusinessContact.business_category == category,
|
||||
BusinessContact.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(BusinessContact.id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 名片三段式发送
|
||||
# =============================================================================
|
||||
|
||||
async def send_contact_card(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
employee_id: str,
|
||||
contact: BusinessContact,
|
||||
reason: str,
|
||||
business_category: str,
|
||||
routing_confidence: float,
|
||||
) -> None:
|
||||
"""发送名片三段式消息(路由文本 → contact_card → 系统提示)。
|
||||
|
||||
完全参考 _handle_byod_query 模式:
|
||||
1. 创建路由说明文本消息(AI, text)→ 落库 + WS双通道推送
|
||||
2. 创建 contact_card 名片消息(AI, contact_card)→ 落库 + WS双通道推送
|
||||
3. 创建系统提示消息(system, system)→ 落库 + WS双通道推送
|
||||
|
||||
每条消息分别落库 + WS推送,与 PRD 4.3 交互流程一致。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
conversation: 当前会话对象
|
||||
employee_id: 员工企微 UserID
|
||||
contact: 联系人对象
|
||||
reason: 路由说明文本(如"打印机问题属于行政设备范畴...")
|
||||
business_category: 业务类别
|
||||
routing_confidence: 路由置信度
|
||||
"""
|
||||
contact_data = contact.to_dict()
|
||||
extra_data: dict[str, Any] = {
|
||||
"contact": contact_data,
|
||||
"routing_reason": reason,
|
||||
"business_category": business_category,
|
||||
"routing_confidence": routing_confidence,
|
||||
}
|
||||
|
||||
# === 1. 路由说明文本消息 ===
|
||||
routing_text_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=reason,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(routing_text_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(routing_text_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": reason,
|
||||
"msg_type": "text",
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(routing_text_msg.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": reason,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"路由文本 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# === 2. contact_card 名片消息 ===
|
||||
contact_card_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
msg_type="contact_card",
|
||||
extra_data=extra_data,
|
||||
is_read=True,
|
||||
)
|
||||
db.add(contact_card_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(contact_card_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
"msg_type": "contact_card",
|
||||
"extra_data": extra_data,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(contact_card_msg.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
"msg_type": "contact_card",
|
||||
"extra_data": extra_data,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"名片卡片 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# === 3. 系统提示消息 ===
|
||||
system_text = "以上为AI自动推荐,点击名片可直接发起企微聊天"
|
||||
system_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="系统",
|
||||
content=system_text,
|
||||
msg_type="system",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(system_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(system_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "system",
|
||||
"sender_id": "system",
|
||||
"sender_name": "系统",
|
||||
"content": system_text,
|
||||
"msg_type": "system",
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(system_msg.id),
|
||||
"sender_type": "system",
|
||||
"sender_id": "system",
|
||||
"sender_name": "系统",
|
||||
"content": system_text,
|
||||
"msg_type": "system",
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"系统提示 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# 更新会话状态(路由推荐视为一次实质性 AI 回复)
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"路由名片发送完成: employee_id={employee_id}, category={business_category}, "
|
||||
f"contact={contact.name}, confidence={routing_confidence}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 路由事件记录(P1)
|
||||
# =============================================================================
|
||||
|
||||
async def record_routing_event(
|
||||
db,
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
message_content: str,
|
||||
business_category: str,
|
||||
routing_confidence: float,
|
||||
contact: Optional[BusinessContact],
|
||||
) -> None:
|
||||
"""记录路由命中事件(P1)。
|
||||
|
||||
为后续优化 Prompt 准确率、分析高频非IT业务提供数据支撑。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
conversation_id: 会话ID
|
||||
employee_id: 员工ID
|
||||
message_content: 触发路由的员工消息(截断至500字)
|
||||
business_category: 业务类别
|
||||
routing_confidence: 路由置信度
|
||||
contact: 推荐的联系人对象(可能为 None)
|
||||
"""
|
||||
try:
|
||||
event = RoutingEvent(
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
message_content=message_content[:500],
|
||||
business_category=business_category,
|
||||
routing_confidence=routing_confidence,
|
||||
contact_id=contact.id if contact else None,
|
||||
contact_name=contact.name if contact else "",
|
||||
is_clicked=False,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# 路由事件记录失败不影响主流程,仅记录 warning
|
||||
logger.warning(f"路由事件记录失败: {e}")
|
||||
@@ -1,473 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — H5 员工端 AI 回复后台任务
|
||||
# =============================================================================
|
||||
# 背景:原 h5_send_message 在同步 HTTP 请求内 await AI 推理(Dify 3~15s),
|
||||
# 整条请求被阻塞,前端表现为"发送中"长时间卡顿。
|
||||
# 本模块将 AI 推理移出请求,改为 asyncio 后台任务,结果经 WebSocket
|
||||
# 流式推回(ai_reply_chunk / ai_reply),发送瞬时完成。
|
||||
#
|
||||
# 关键约束(详见 docs/02-需求分析/技术架构演进/员工端消息发送延时改造方案.md):
|
||||
# 1. 必须单 worker 运行(docker-compose --workers 1):
|
||||
# ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在
|
||||
# 同进程,broadcast 会静默丢失(约 50%)。
|
||||
# 2. 使用独立 DB session(_get_session_factory),不可复用请求的 db
|
||||
# (请求返回后该 session 会被关闭)。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.byod import _byod_keyword_prefilter
|
||||
from app.database import _get_session_factory
|
||||
from app.dependencies import get_shared_ai_handler
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.routing_service import (
|
||||
routing_keyword_prefilter,
|
||||
detect_routing_intent,
|
||||
get_contact_by_category,
|
||||
send_contact_card,
|
||||
record_routing_event,
|
||||
_keyword_fallback_category,
|
||||
)
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _persist_and_push(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
employee_id: str,
|
||||
content: str,
|
||||
is_guidance: bool,
|
||||
should_count: bool,
|
||||
should_transfer: bool,
|
||||
dify_conversation_id,
|
||||
):
|
||||
"""持久化 AI 回复并推送给员工端 + 广播坐席端。
|
||||
|
||||
做什么:
|
||||
1. 存 AI 消息到 DB
|
||||
2. 更新会话状态(dify 上下文 / 计数 / 转人工)
|
||||
3. 经 WS 向员工推 ai_reply 终态(前端据此替换打字机气泡)
|
||||
4. 经 WS 向坐席端广播 new_message + conversation_updated
|
||||
为什么:把"落库 + 推送"封装为单点,供同步路径与流式路径复用。
|
||||
"""
|
||||
# 1. 存 AI 消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=content,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 2. 更新会话状态
|
||||
if dify_conversation_id:
|
||||
conversation.dify_conversation_id = dify_conversation_id
|
||||
if should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
if should_transfer:
|
||||
conversation.status = "queued"
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# 3. 推 ai_reply 终态给员工(前端替换打字机气泡)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": content,
|
||||
"msg_type": "text",
|
||||
"is_guidance": is_guidance,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
|
||||
# 4. 广播坐席端(new_message + conversation_updated)
|
||||
try:
|
||||
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": "Duckula(达寇拉)",
|
||||
"content": 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 广播 AI 回复给坐席失败(消息已存储): {ws_err}")
|
||||
|
||||
|
||||
async def _handle_byod_query(db, conversation, employee_id, content):
|
||||
"""处理 BYOD 自备电脑补贴查询。
|
||||
|
||||
在 H5 聊天消息流中拦截 BYOD 关键词后执行资格检查,并以 byod_card
|
||||
卡片消息形式推送给员工端(前端 MessageBubble 据 msg_type 渲染
|
||||
ByodSubsidyCard)。
|
||||
|
||||
流程:
|
||||
1. 通过 WecomService 获取员工岗位(position)
|
||||
2. 与 BYOD 资格清单匹配(_match_position)
|
||||
3. 创建 byod_card 类型 AI 消息并落库
|
||||
4. 经 WS 推送 ai_reply 给员工端(携带 extra_data.byod_result)
|
||||
5. 广播 new_message + conversation_updated 给坐席端(与 _persist_and_push 一致)
|
||||
|
||||
Args:
|
||||
db: 异步 DB session(process_h5_ai_reply 的 factory session)
|
||||
conversation: 当前会话对象(Conversation)
|
||||
employee_id: 员工企微 UserID
|
||||
content: 用户消息原文(用于日志)
|
||||
"""
|
||||
# 延迟导入避免循环依赖(byod 模块注册路由时可能引用 app.main)
|
||||
from app.api.byod import _match_position, BYOD_APPLICATION_URL, BYOD_NOTES, BYOD_REGISTER_NOTES
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
# 1. 获取员工岗位(企微通讯录 API)
|
||||
position = ""
|
||||
try:
|
||||
wecom_service = WecomService()
|
||||
try:
|
||||
user_info = await wecom_service.get_user_info(employee_id)
|
||||
position = user_info.get("position", "")
|
||||
finally:
|
||||
await wecom_service.close()
|
||||
except Exception as e:
|
||||
logger.error(f"BYOD: 获取员工岗位失败: {e}")
|
||||
|
||||
# 2. 岗位匹配(返回: 是否匹配, 匹配岗位, 匹配类别)
|
||||
eligible, matched_pos, matched_category = _match_position(position)
|
||||
|
||||
# 3. 构建 BYOD 结果数据
|
||||
# 字段与前端 ByodSubsidyCard.vue props 完全一致:
|
||||
# eligible / position / matched_category / application_url / notes / reason
|
||||
byod_result = {
|
||||
"eligible": eligible,
|
||||
"has_subsidy": eligible,
|
||||
"position": position,
|
||||
"matched_category": matched_category,
|
||||
"application_url": BYOD_APPLICATION_URL, # 所有岗位都提供链接
|
||||
"notes": BYOD_NOTES if eligible else BYOD_REGISTER_NOTES,
|
||||
"reason": (
|
||||
"" if eligible
|
||||
else f"您的岗位「{position}」不在自备电脑补贴资格清单中,可进行自备电脑登记(无补贴)"
|
||||
),
|
||||
}
|
||||
|
||||
# 4. 展示文本(AI 气泡的 content,卡片下方不直接展示,但会话列表/坐席端可见)
|
||||
if eligible:
|
||||
display_text = f"您岗位为「{position}」,符合自备电脑补贴申请资格"
|
||||
else:
|
||||
display_text = f"您岗位为「{position}」,可进行自备电脑登记(无补贴)"
|
||||
|
||||
# 5. 创建 AI 消息(byod_card 类型,携带 byod_result)
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=display_text,
|
||||
msg_type="byod_card",
|
||||
extra_data={"byod_result": byod_result},
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 6. 更新会话状态(计数 + 时间,BYOD 视为一次实质性 AI 回复)
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# 7. 推送 ai_reply 给员工端(前端据 msg_type="byod_card" 渲染卡片)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": display_text,
|
||||
"msg_type": "byod_card",
|
||||
"extra_data": {"byod_result": byod_result},
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
|
||||
# 8. 广播坐席端(new_message + conversation_updated,与 _persist_and_push 一致)
|
||||
try:
|
||||
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": "Duckula(达寇拉)",
|
||||
"content": display_text,
|
||||
"msg_type": "byod_card",
|
||||
"extra_data": {"byod_result": byod_result},
|
||||
},
|
||||
})
|
||||
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:
|
||||
logger.warning(f"BYOD: WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
logger.info(
|
||||
f"BYOD 查询完成: employee_id={employee_id}, position={position}, "
|
||||
f"eligible={eligible}, matched_category={matched_category}"
|
||||
)
|
||||
|
||||
|
||||
async def _handle_routing(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
employee_id: str,
|
||||
content: str,
|
||||
) -> bool:
|
||||
"""处理非IT业务路由推荐。
|
||||
|
||||
在 H5 聊天消息流中拦截路由关键词后调用 Dify 统一意图识别,
|
||||
判定为 non_it_routing 且 routing_confidence ≥ 阈值时发送名片三段式消息。
|
||||
|
||||
流程:
|
||||
1. 调用 Dify 统一意图识别(detect_routing_intent)
|
||||
2. 检查 intent_type == "non_it_routing" && routing_confidence ≥ 阈值
|
||||
→ YES: 查询联系人 → 发送名片三段式消息 → 记录路由事件 → 返回 True
|
||||
→ NO: 返回 False(继续走正常 AI 流程)
|
||||
3. Dify 调用失败 → 关键词降级兜底(按 ROUTING_KEYWORD_TO_CATEGORY 映射)
|
||||
|
||||
Args:
|
||||
db: 异步 DB session(process_h5_ai_reply 的 factory session)
|
||||
conversation: 当前会话对象(Conversation)
|
||||
employee_id: 员工企微 UserID
|
||||
content: 用户消息原文
|
||||
|
||||
Returns:
|
||||
bool: True 表示已发送路由名片(应 return 中断后续流程),
|
||||
False 表示未触发路由(继续走正常 AI 流程)
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
# 1. 调用 Dify 统一意图识别
|
||||
try:
|
||||
result = await detect_routing_intent(content, employee_id)
|
||||
intent_type = result.get("intent_type", "chitchat")
|
||||
business_category = result.get("business_category")
|
||||
routing_confidence = result.get("routing_confidence", 0.0)
|
||||
|
||||
# 如果是审批意图,不拦截(让审批流程处理)
|
||||
if intent_type == "approval":
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"路由意图检测(Dify): intent_type={intent_type}, "
|
||||
f"business_category={business_category}, "
|
||||
f"routing_confidence={routing_confidence}"
|
||||
)
|
||||
|
||||
# 2. 检查是否触发路由推荐
|
||||
threshold = settings.routing_confidence_threshold
|
||||
if intent_type != "non_it_routing" or routing_confidence < threshold:
|
||||
# 置信度不足或非路由意图,走正常 AI 流程
|
||||
return False
|
||||
|
||||
if not business_category:
|
||||
logger.warning("路由意图为 non_it_routing 但 business_category 为空,跳过")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Dify 路由意图识别失败,降级为关键词匹配: {e}")
|
||||
# 3. 降级为关键词匹配
|
||||
business_category = _keyword_fallback_category(content)
|
||||
if not business_category:
|
||||
# 关键词也未命中,走正常 AI 流程
|
||||
return False
|
||||
routing_confidence = 0.75 # 降级兜底给一个略高于阈值的置信度
|
||||
logger.info(f"路由意图检测(兜底): business_category={business_category}")
|
||||
|
||||
# 4. 查询联系人
|
||||
contact = await get_contact_by_category(db, business_category)
|
||||
if not contact:
|
||||
logger.warning(f"未找到 {business_category} 类别的联系人,跳过路由推荐")
|
||||
return False
|
||||
|
||||
# 5. 构建路由说明文本
|
||||
category_display = business_category.replace("行政-物业", "物业")
|
||||
reason = (
|
||||
f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n"
|
||||
f"为您推荐{category_display}服务相关联系人,您可以直接点击名片联系TA:"
|
||||
)
|
||||
|
||||
# 6. 发送名片三段式消息
|
||||
await send_contact_card(
|
||||
db=db,
|
||||
conversation=conversation,
|
||||
employee_id=employee_id,
|
||||
contact=contact,
|
||||
reason=reason,
|
||||
business_category=business_category,
|
||||
routing_confidence=routing_confidence,
|
||||
)
|
||||
|
||||
# 7. 记录路由事件(P1)
|
||||
await record_routing_event(
|
||||
db=db,
|
||||
conversation_id=str(conversation.id),
|
||||
employee_id=employee_id,
|
||||
message_content=content,
|
||||
business_category=business_category,
|
||||
routing_confidence=routing_confidence,
|
||||
contact=contact,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def process_h5_ai_reply(
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
content: str,
|
||||
dify_conversation_id=None,
|
||||
):
|
||||
"""H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。
|
||||
|
||||
流程:
|
||||
- 本地快判断(打招呼 / 呼叫人工)→ 同步结果,整段推送(不调 Dify)
|
||||
- 否则流式调 Dify,逐 chunk 推 ai_reply_chunk,流结束推 ai_reply 终态
|
||||
- 任意异常 → 推 ai_reply_failed,不阻塞用户
|
||||
"""
|
||||
ai_handler = get_shared_ai_handler()
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
if not conversation:
|
||||
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
|
||||
return
|
||||
|
||||
is_guidance = False
|
||||
should_count = False
|
||||
should_transfer = False
|
||||
new_dify_conv_id = dify_conversation_id
|
||||
full_parts: list = []
|
||||
|
||||
# === BYOD 关键词拦截 ===
|
||||
# 在打招呼/呼叫人工判断之前,先检查是否为 BYOD(自备电脑补贴)意图。
|
||||
# 命中关键词 → 执行 BYOD 资格检查并推送 byod_card 卡片,不走正常 AI 流程。
|
||||
if _byod_keyword_prefilter(content):
|
||||
await _handle_byod_query(db, conversation, employee_id, content)
|
||||
return # BYOD 处理完毕,直接返回
|
||||
|
||||
# === 业务路由检测(新增)===
|
||||
# 在 BYOD 检测之后、打招呼/呼叫人工检测之前,检查是否为非IT业务路由。
|
||||
# 命中路由关键词 → 调用 Dify 统一意图识别 → non_it_routing && confidence≥0.7
|
||||
# → 发送名片三段式消息(路由文本 + contact_card + 系统提示)
|
||||
# 置信度不足或非路由意图 → 继续往下走正常 AI 流程
|
||||
if routing_keyword_prefilter(content):
|
||||
routed = await _handle_routing(db, conversation, employee_id, content)
|
||||
if routed:
|
||||
return # 路由名片已发送,直接返回
|
||||
|
||||
# 本地快判断(不打 Dify):打招呼 / 呼叫人工 → 同步路径
|
||||
if ai_handler.is_greeting(content) or ai_handler.is_call_human(content):
|
||||
result = await ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
)
|
||||
await _persist_and_push(
|
||||
db, conversation, employee_id, result.content,
|
||||
result.is_guidance, result.should_count,
|
||||
result.should_transfer, result.dify_conversation_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 流式调 Dify(get_reply_stream 内部已处理真 SSE / 非流式 fallback)
|
||||
# 注意:首参是 message(用户文本),不是 content
|
||||
async for chunk in ai_handler.ai_service.get_reply_stream(
|
||||
message=content,
|
||||
conversation_id=dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
):
|
||||
delta = chunk.get("delta", "")
|
||||
if delta:
|
||||
full_parts.append(delta)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_chunk",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"chunk": delta,
|
||||
},
|
||||
})
|
||||
if chunk.get("finished"):
|
||||
new_dify_conv_id = chunk.get("conversation_id") or dify_conversation_id
|
||||
hit = chunk.get("hit")
|
||||
# 命中 → 计数;未命中 → 转人工
|
||||
should_count = bool(hit)
|
||||
should_transfer = not bool(hit)
|
||||
|
||||
content_ai = "".join(full_parts)
|
||||
if not content_ai:
|
||||
# 流式无内容(极端情况),给降级提示,不转人工
|
||||
content_ai = "⚠️ AI 暂时没有返回内容,请输入「IT」转人工。"
|
||||
should_count = False
|
||||
should_transfer = False
|
||||
await _persist_and_push(
|
||||
db, conversation, employee_id, content_ai,
|
||||
is_guidance, should_count, should_transfer, new_dify_conv_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_failed",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"message": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
# 推送失败也无所谓,员工端 3 秒轮询兜底
|
||||
pass
|
||||
@@ -1,100 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 超时提醒定时任务
|
||||
# =============================================================================
|
||||
# 说明:定时检查超时未回复的会话,发送企微提醒消息
|
||||
# 运行频率:每 30 秒执行一次
|
||||
# 超时逻辑:
|
||||
# 1. 坐席回复后 3 分钟员工未回复 -> 发送企微提醒(只发 1 次)
|
||||
# 2. 坐席回复后 10 分钟员工未回复 -> 标记会话为 pending_close
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.reminder_service import send_reminder_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时配置(分钟)
|
||||
REMINDER_TIMEOUT_MINUTES = 3 # 未回复超时时间
|
||||
CLOSE_TIMEOUT_MINUTES = 10 # 自动待关闭时间
|
||||
|
||||
|
||||
async def check_unreplied_sessions():
|
||||
"""检查超时未回复的会话,发送提醒并标记待关闭。
|
||||
|
||||
此函数由 APScheduler 定时调用(每 30 秒)。
|
||||
执行流程:
|
||||
1. 查找需要发送提醒的会话(坐席回复超过3分钟,员工未回复且未发送过提醒)
|
||||
2. 发送企微提醒消息
|
||||
3. 标记已发送提醒
|
||||
4. 查找需要标记待关闭的会话(坐席回复超过10分钟)
|
||||
5. 更新会话状态为 pending_close
|
||||
"""
|
||||
# 导入数据库 session 工厂
|
||||
from app.database import _get_session_factory
|
||||
|
||||
async_session_factory = _get_session_factory()
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
# 1. 查找需要发送提醒的会话
|
||||
# 条件:active 状态 + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒
|
||||
reminder_threshold = datetime.now() - timedelta(minutes=REMINDER_TIMEOUT_MINUTES)
|
||||
|
||||
reminder_stmt = select(Conversation).where(
|
||||
Conversation.status == "serving",
|
||||
Conversation.last_agent_reply_at.isnot(None),
|
||||
Conversation.last_agent_reply_at < reminder_threshold,
|
||||
Conversation.reminder_sent == False,
|
||||
)
|
||||
result = await db.execute(reminder_stmt)
|
||||
sessions_to_remind = result.scalars().all()
|
||||
|
||||
logger.info(f"发现 {len(sessions_to_remind)} 个需要发送提醒的会话")
|
||||
|
||||
# 2. 发送企微提醒消息
|
||||
for session in sessions_to_remind:
|
||||
try:
|
||||
success = await send_reminder_message(session.employee_id)
|
||||
if success:
|
||||
# 3. 标记已发送提醒
|
||||
session.reminder_sent = True
|
||||
session.reminder_sent_at = datetime.now()
|
||||
logger.info(f"会话 {session.id} 已发送提醒: employee_id={session.employee_id}")
|
||||
else:
|
||||
logger.warning(f"会话 {session.id} 提醒发送失败,跳过")
|
||||
except Exception as e:
|
||||
logger.error(f"会话 {session.id} 发送提醒异常: {e}")
|
||||
continue
|
||||
|
||||
# 4. 查找需要标记待关闭的会话
|
||||
# 条件:active 状态 + 有坐席回复 + 超过10分钟未回复
|
||||
close_threshold = datetime.now() - timedelta(minutes=CLOSE_TIMEOUT_MINUTES)
|
||||
|
||||
close_stmt = select(Conversation).where(
|
||||
Conversation.status == "serving",
|
||||
Conversation.last_agent_reply_at.isnot(None),
|
||||
Conversation.last_agent_reply_at < close_threshold,
|
||||
)
|
||||
result = await db.execute(close_stmt)
|
||||
sessions_to_close = result.scalars().all()
|
||||
|
||||
logger.info(f"发现 {len(sessions_to_close)} 个需要标记待关闭的会话")
|
||||
|
||||
# 5. 更新会话状态为 pending_close
|
||||
for session in sessions_to_close:
|
||||
session.status = "pending_close"
|
||||
logger.info(f"会话 {session.id} 已标记为待关闭: employee_id={session.employee_id}")
|
||||
|
||||
# 提交数据库变更
|
||||
await db.commit()
|
||||
logger.info("超时检查任务执行完成")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"超时检查任务执行异常: {e}")
|
||||
raise
|
||||
@@ -1,29 +0,0 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('it_smart_desk.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check employee table
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='employees'")
|
||||
if cursor.fetchone():
|
||||
cursor.execute('PRAGMA table_info(employees)')
|
||||
cols = [row[1] for row in cursor.fetchall()]
|
||||
print('Employee columns:')
|
||||
for c in cols:
|
||||
print(f' {c}')
|
||||
|
||||
missing = ['it_level', 'it_level_source', 'notes']
|
||||
for m in missing:
|
||||
status = "EXISTS" if m in cols else "MISSING!"
|
||||
print(f'{m}: {status}')
|
||||
else:
|
||||
print("No employees table found")
|
||||
|
||||
# Check todo_items and troubleshooting_templates tables
|
||||
for table in ['todo_items', 'troubleshooting_templates']:
|
||||
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'")
|
||||
if cursor.fetchone():
|
||||
print(f"\n{table} table: EXISTS")
|
||||
else:
|
||||
print(f"\n{table} table: NOT FOUND (will be auto-created by SQLAlchemy on first access)")
|
||||
|
||||
conn.close()
|
||||
@@ -1,84 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""内容审核服务 真实验证(#81 敏感词检测 / 隐私泄露识别)
|
||||
|
||||
真实验证点(来自功能规格说明书 + 状态看板验收标准):
|
||||
- moderate("你爱找谁找谁") → WARN + matched 含该词
|
||||
- check_privacy_leak("电话13800138000") → 含 "phone"
|
||||
- 命中敏感词动作是 WARN(仅警告,不阻断发送)
|
||||
- 自定义词库为写死的若干条(生产应从配置加载,当前未接)
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.services.content_moderation_service import (
|
||||
ContentModerationService,
|
||||
ModerationAction,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moderation_service():
|
||||
# 直接使用构造函数(单例亦可,这里用新实例避免跨测试状态)
|
||||
return ContentModerationService()
|
||||
|
||||
|
||||
def test_moderate_returns_warn_with_matched_word(moderation_service):
|
||||
"""验收点1: 命中自定义敏感词 → WARN 且 matched 含该词"""
|
||||
result = moderation_service.moderate("你爱找谁找谁")
|
||||
assert result.action == ModerationAction.WARN
|
||||
assert "你爱找谁找谁" in result.matched_words
|
||||
|
||||
|
||||
def test_moderate_all_known_custom_words_warn(moderation_service):
|
||||
"""所有已知自定义敏感词均能命中并返回 WARN"""
|
||||
words = ["投诉我", "你爱找谁找谁", "自己不会百度吗", "这点小事"]
|
||||
for w in words:
|
||||
r = moderation_service.moderate(w)
|
||||
assert r.action == ModerationAction.WARN, f"{w} 应被 warn"
|
||||
assert w in r.matched_words, f"{w} 应在 matched 中"
|
||||
|
||||
|
||||
def test_moderate_clean_text_passes(moderation_service):
|
||||
"""正常文本 → PASS,无命中词"""
|
||||
r = moderation_service.moderate("您好,我的电脑无法开机了")
|
||||
assert r.action == ModerationAction.PASS
|
||||
assert r.matched_words == []
|
||||
|
||||
|
||||
def test_moderate_empty_string_passes(moderation_service):
|
||||
"""空字符串 → PASS"""
|
||||
r = moderation_service.moderate("")
|
||||
assert r.action == ModerationAction.PASS
|
||||
assert r.matched_words == []
|
||||
|
||||
|
||||
def test_default_action_is_warn_not_block(moderation_service):
|
||||
"""关键事实: 当前命中动作是 WARN 而非 BLOCK(仅警告、不阻断发送)"""
|
||||
r = moderation_service.moderate("自己不会百度吗")
|
||||
assert r.action != ModerationAction.BLOCK
|
||||
assert r.action == ModerationAction.WARN
|
||||
|
||||
|
||||
def test_check_privacy_leak_phone(moderation_service):
|
||||
"""验收点2: 手机号被识别为 phone"""
|
||||
leaked = moderation_service.check_privacy_leak("我的电话13800138000")
|
||||
assert "phone" in leaked
|
||||
|
||||
|
||||
def test_check_privacy_leak_id_card(moderation_service):
|
||||
"""身份证号被识别为 id_card"""
|
||||
leaked = moderation_service.check_privacy_leak("身份证11010119900307123X")
|
||||
assert "id_card" in leaked
|
||||
|
||||
|
||||
def test_check_privacy_leak_clean_text_empty(moderation_service):
|
||||
"""正常沟通内容不触发隐私识别"""
|
||||
leaked = moderation_service.check_privacy_leak("这是正常的工作沟通内容")
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_custom_word_list_is_hardcoded(moderation_service):
|
||||
"""确认自定义词库是写死的(生产应从配置加载,当前未接)"""
|
||||
words = moderation_service.custom_sensitive_words
|
||||
assert len(words) >= 4
|
||||
for w in ["投诉我", "你爱找谁找谁", "自己不会百度吗", "这点小事"]:
|
||||
assert w in words
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 966 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -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,249 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
.eggs/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# 前端构建
|
||||
frontend-agent/dist/
|
||||
frontend-h5/dist/
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# =============================================================================
|
||||
# P0 安全: SSL 私钥 / 部署包 / 历史日志 (2026-06-14 强化)
|
||||
# =============================================================================
|
||||
|
||||
# SSL 私钥 + 证书请求
|
||||
*.key
|
||||
*.pem
|
||||
*.csr
|
||||
|
||||
# docs/ 下所有 servyou.com.cn 证书(目录 + 文件 + zip 全部,** 匹配任意深度)
|
||||
docs/servyou.com.cn**
|
||||
docs/*证书*/
|
||||
docs/*ssl*/
|
||||
docs/*SSL*/
|
||||
docs/*证书*.zip
|
||||
/docs/*证书*.zip
|
||||
|
||||
# 部署包 + 部署产物(体积大 + 经常含私钥)
|
||||
deploy-*.tar
|
||||
deploy-*.tar.gz
|
||||
deploy-packages/
|
||||
deploy-patch.tar
|
||||
deploy-admin.tar
|
||||
deploy-agent.tar
|
||||
deploy-backend.tar
|
||||
deploy-h5.tar
|
||||
deploy-portal.tar
|
||||
*.tar
|
||||
*.tar.gz
|
||||
|
||||
# 历史日志 + 构建日志(stderr 噪声)
|
||||
*.log.err
|
||||
build_logs/
|
||||
agent_build.log.err
|
||||
build-agent.log.err
|
||||
build-agent2.log.err
|
||||
build-h5.log.err
|
||||
build-h5-fix.log.err
|
||||
build_all.log.err
|
||||
|
||||
# 本地部署脚本 + 部署目录
|
||||
build.ps1
|
||||
build_all.ps1
|
||||
deploy-frontend-nas.bat
|
||||
deploy-nas/
|
||||
|
||||
# 部署产物 zip(体积大 + 含 dist)
|
||||
frontend-*-dist-fix*.zip
|
||||
frontend-*-dist-fix-*.zip
|
||||
it-smart-desk-*-deploy.zip
|
||||
|
||||
# deploy-server/ 部署配置(nginx conf 入仓, .env 排除)
|
||||
deploy-server/**/.env*
|
||||
deploy-server/**/secrets/
|
||||
deploy-server/**/*.pem
|
||||
deploy-server/**/*.key
|
||||
|
||||
# SQLite 本地测试库
|
||||
it_smart_desk.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Base64 编码凭据(部署脚本用,含 admin token / 证书)
|
||||
# 2026-06-22: gen_admin_token.b64 含生产 admin token,不能入仓
|
||||
*.b64
|
||||
|
||||
# pytest / 临时
|
||||
.pytest_cache/
|
||||
/tmp/
|
||||
*.pid
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# 本地开发脚本 + 临时文件(2026-06-14 强化)
|
||||
dev-start.bat
|
||||
dev-start.ps1
|
||||
start-backend.ps1
|
||||
temp_*.txt
|
||||
temp_*.py
|
||||
wecom-it-desk-nas.zip
|
||||
wecom-it-desk-server-deploy.zip
|
||||
|
||||
# =============================================================================
|
||||
# P0 安全: workbuddy 凭据(2026-06-14 强化)
|
||||
# =============================================================================
|
||||
# workbuddy config 含 Gitea access token,绝对不入仓
|
||||
# 类比 .git/config: 工作目录可写,但 git add . 时排除
|
||||
.workbuddy/config.json
|
||||
.workbuddy/config.local.json
|
||||
.workbuddy/*.token
|
||||
.workbuddy/credentials*
|
||||
.workbuddy/.env*
|
||||
# workbuddy 临时日志(评审/任务跑批的中间产物)
|
||||
.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/
|
||||
|
||||
# 补充忽略 (2026-07-13): 临时目录 / 备份 / 截图
|
||||
.workbuddy/tmp/
|
||||
.workbuddy/automations/
|
||||
deploy-staging-ki/
|
||||
dist-old-*/
|
||||
dist_deploy/
|
||||
screenshots/
|
||||
test-screenshots/
|
||||
tools/
|
||||
chat_export/
|
||||
deliverables/
|
||||
02meiti/
|
||||
data/
|
||||
@@ -34,7 +34,7 @@ nav:
|
||||
- QA 综合报告: 06-测试质量/testing-测试/QA_COMPREHENSIVE_REPORT.md
|
||||
- 项目管理:
|
||||
- 任务总索引: 10-项目管理/01-任务总索引.md
|
||||
- 项目状态看板: 10-项目管理/05-项目状态看板/01-项目状态看板.md
|
||||
- 项目状态看板: 07-项目管理/看板/项目状态看板.md
|
||||
- 风险跟踪表: 10-项目管理/02-风险跟踪表.md
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
@@ -0,0 +1 @@
|
||||
lete(de)}}function re(A){var Y,de;if(P.value.has(A.message_id)){console.log(`[H5 WS去重] 跳过重复消息: ${A.message_id}`);return}if(console.log("[H5 WS] 收到 new_message:",{message_id:A.message_id,sender_type:A.sender_type,content:(Y=A.content)==null?void 0:Y.substring(0,40),msg_type:A.msg_type,lastSentOptionContent:$e.value,current_conv_id:(de=t.value)==null?void 0:de.conversation_id,data_conv_id:A.conversation_id}),A.sender_type==="employee"&&A.content===$e.value&&$e.value!==""){console.log(`[H5 WS去重] 跳过选项选择广播消息: ${A.message_id}, content: ${A.content}`),ae(A.message_id),$e.value="";return}ae(A.message_id),n.value.push({message_id:A.message_id,conversation_id:A.conversation_id,message_type:A.sender_type||"system",msg_type:A.msg_type||"text",content:typeof A.content=="string"?A.content:A.content?JSON.stringify(A.content):"",sender_name:A.sender_name||"",media_url:A.media_url,file_name:A.file_name,file_size:A.file_size,extra_data:A.extra_data,reply_to_id:A.reply_to_id,created_at:A.created_at||new Date().toISOString()}),f.value=A.message_id}async function ce(A,Y){await Rt().handleOAuthCallback(A,Y)}async function Re(){try{const A=Rt();A.employeeInfo&&(e.value={employee_id:A.employeeInfo.employee_id,employee_name:A.employeeInfo.employee_name,department:A.employeeInfo.department,position:A.employeeInfo.position,level:"",is_vip:A.employeeInfo.is_vip,avatar_url:A.employeeInfo.avatar});const Y=await Hw();e.value=Y,console.log("[Store] 获取用户信息成功:",Y.employee_name)}catch(A){throw console.error("[Store] 获取用户信息失败:",A),A}}async function ke(){try{const A=await Ww();t.value=A,i.value=(A==null?void 0:A.can_call_agent)??!1,y.value=(A==null?void 0:A.participants)||[],console.log("[Store] 获取当前会话:",A?A.conversation_id:"无活跃会话","参与者:",y.value.length)}catch(A){console.error("[Store] 获取当前会话失败:",A)}}async function oe(A,Y){var it,Mt,Gt,Jt;if(console.log("[Store] sendNewMessage 开始执行, content:",A,"options:",Y),!A.trim()){console.warn("[Store] content 为空,直接返回");return}const de=Rt(),xe=`temp_${Date.now()}_${Math.random().toString(36).slice(2,8)}`,Ie={message_id:xe,conversation_id:((it=t.value)==null?void 0:it.conversation_id)||"",message_type:"employee",msg_type:(Y==null?void 0:Y.msg_type)||"text",content:A.trim(),sender_name:de.employeeName||"我",created_at:new Date().toISOString(),status:"sending"};n.value.push(Ie),console.log("[Store] 乐观更新:临时消息已添加到列表, tempMessageId:",xe),s.value=!0;try{const Ge={content:A.trim()};Y!=null&&Y.msg_type&&(Ge.msg_type=Y.msg_type,Ge.media_url=Y.media_url,Ge.file_name
|
||||
@@ -0,0 +1 @@
|
||||
){console.warn("[API] 401 处理失败,刷新页面:",n),window.location.reload()}finally{Hs=null}})(),Hs)}function Zr(e){return{message_id:e.id||e.message_id||"",conversation_id:e.conversation_id||"",message_type:e.sender_type||e.message_type||"text",msg_type:e.msg_type,content:typeof e.content=="string"?e.content:e.content?JSON.stringify(e.content):"",sender_name:e.sender_name||"",created_at:e.created_at||"",media_url:e.media_url,file_name:e.file_name,file_size:e.file_size,extra_data:e.extra_data,reply_to_id:e.reply_to_id}}function Gf(e){return(e||[]).map(Zr)}async function Hw(){return await Oe.get("/h5/user")}function qw(e){return e?{conversation_id:e.id||e.conversation_id||"",employee_id:e.employee_id||"",employee_name:e.employee_name||"",status:e.status||"waiting",agent_id:e.assigned_agent_id||e.agent_id||"",agent_name:e.agent_name||"",created_at:e.created_at||"",updated_at:e.updated_at||"",ai_substantive_reply_count:e.ai_substantive_reply_count??0,can_call_agent:e.can_call_agent??!1,participants:e.participants||[]}:null}async function Ww(){const e=await Oe.get("/h5/conversations/current");return qw(e)}async function Oc(e){const t=await Oe.post("/h5/conversations/current/messages",e,{timeout:3e4});return{user_message:Zr(t.user_message),ai_reply:t.ai_reply?Zr(t.ai_reply):t.ai_reply,is_guidance:t.is_guidance,ai_reply_count:t.ai_reply_count,can_call_agent:t.can_call_agent}}async function Kw(e){const n=await Oe.get("/h5/conversations/current/messages/poll",{params:e}),s=(n==null?void 0:n.items)||n||[];return Gf(s)}async function Pc(e){const n=await Oe.get("/h5/conversations/current/messages",{params:e}),s=(n==null?void 0:n.items)||[];return{items:Gf(s),has_more:(n==null?void 0:n.has_mor
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 创建设备清单表
|
||||
CREATE TABLE IF NOT EXISTS device_inventory (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
computer_name VARCHAR(255) DEFAULT '',
|
||||
ip_address VARCHAR(64) DEFAULT '',
|
||||
mac_address VARCHAR(64) DEFAULT '',
|
||||
employee_account VARCHAR(128) DEFAULT '',
|
||||
employee_name VARCHAR(128) DEFAULT '',
|
||||
department VARCHAR(255) DEFAULT '',
|
||||
source VARCHAR(32) DEFAULT '',
|
||||
asset_tag VARCHAR(64) DEFAULT '',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 创建索引加速查询
|
||||
CREATE INDEX IF NOT EXISTS idx_device_employee ON device_inventory(employee_account);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_ip ON device_inventory(ip_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_mac ON device_inventory(mac_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_source ON device_inventory(source);
|
||||
@@ -24,7 +24,7 @@ mkdir -p "$BACKUP_DIR"
|
||||
cp -r "$DEPLOY_DIR/frontend-h5/dist" "$BACKUP_DIR/frontend-h5-dist" 2>/dev/null || true
|
||||
cp -r "$DEPLOY_DIR/frontend-agent/dist" "$BACKUP_DIR/frontend-agent-dist" 2>/dev/null || true
|
||||
cp -r "$DEPLOY_DIR/frontend-admin/dist" "$BACKUP_DIR/frontend-admin-dist" 2>/dev/null || true
|
||||
cp -r "$DEPLOY_DIR/frontend-portal/dist" "$BACKUP_DIR/frontend-portal-dist" 2>/dev/null || true
|
||||
# ⚠️ 2026-08-03: frontend-portal 已下线(源码 + dist 不存在),跳过备份
|
||||
cp -r "$DEPLOY_DIR/backend" "$BACKUP_DIR/backend" 2>/dev/null || true
|
||||
echo "备份完成: $BACKUP_DIR"
|
||||
|
||||
@@ -50,10 +50,7 @@ rm -rf frontend-admin/dist
|
||||
tar -xf /tmp/deploy-admin.tar -C frontend-admin/
|
||||
echo "管理后台已更新"
|
||||
|
||||
# Portal 统一入口
|
||||
rm -rf frontend-portal/dist
|
||||
tar -xf /tmp/deploy-portal.tar -C frontend-portal/ 2>/dev/null || echo "Portal 包未找到,跳过"
|
||||
echo "Portal 已更新"
|
||||
# ⚠️ 2026-08-03: Portal 已下线(commit bea288e4 删除源码),跳过此步骤
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. 更新后端代码
|
||||
@@ -142,11 +139,11 @@ echo "部署完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "访问地址:"
|
||||
echo " 统一入口: https://itsupport.servyou.com.cn/itportal/"
|
||||
echo " H5 用户端: https://itsupport.servyou.com.cn/itdesk/"
|
||||
echo " 坐席工作台: https://itsupport.servyou.com.cn/itagent/"
|
||||
echo " 管理后台: https://itsupport.servyou.com.cn/itadmin/"
|
||||
echo ""
|
||||
echo "⚠️ 注: /itportal/ 入口已下线(2026-08-03 清理),根域名会自动重定向到 /itdesk/"
|
||||
echo "如需回滚,执行:"
|
||||
echo " cp -r $BACKUP_DIR/frontend-h5-dist $DEPLOY_DIR/frontend-h5/dist"
|
||||
echo " cp -r $BACKUP_DIR/frontend-agent-dist $DEPLOY_DIR/frontend-agent/dist"
|
||||
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
- WECOM_SSO_CALLBACK_BASE=${WECOM_SSO_CALLBACK_BASE:-https://itsupport.servyou.com.cn}
|
||||
- BACKEND_HOST=0.0.0.0
|
||||
- BACKEND_PORT=8000
|
||||
command: ["/bin/sh", "-c", "echo '>>> Green Environment' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
||||
command: ["/bin/sh", "-c", "echo '>>> Green Environment' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]
|
||||
networks:
|
||||
- it-desk-internal
|
||||
healthcheck:
|
||||
@@ -74,7 +74,6 @@ services:
|
||||
- ./html/itdesk:/usr/share/nginx/html/itdesk:ro
|
||||
- ./html/itagent:/usr/share/nginx/html/itagent:ro
|
||||
- ./html/itadmin:/usr/share/nginx/html/itadmin:ro
|
||||
- ./html/itportal:/usr/share/nginx/html/itportal:ro
|
||||
environment:
|
||||
- NGINX_HOST=localhost
|
||||
- NGINX_PORT=80
|
||||
|
||||
@@ -388,7 +388,6 @@ services:
|
||||
- ./frontend-h5/dist:/usr/share/nginx/html/itdesk:ro
|
||||
- ./frontend-agent/dist:/usr/share/nginx/html/itagent:ro
|
||||
- ./frontend-admin/dist:/usr/share/nginx/html/itadmin:ro
|
||||
- ./frontend-portal/dist:/usr/share/nginx/html/itportal:ro
|
||||
depends_on:
|
||||
core-svc:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -112,7 +112,7 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: ["/bin/sh", "-c", "echo '>>> Skipping DB migration' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
||||
command: ["/bin/sh", "-c", "echo '>>> Skipping DB migration' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]
|
||||
networks:
|
||||
- it-desk-internal
|
||||
healthcheck:
|
||||
@@ -143,7 +143,6 @@ services:
|
||||
- ./html/itdesk:/usr/share/nginx/html/itdesk:ro
|
||||
- ./html/itagent:/usr/share/nginx/html/itagent:ro
|
||||
- ./html/itadmin:/usr/share/nginx/html/itadmin
|
||||
- ./html/itportal:/usr/share/nginx/html/itportal:ro
|
||||
- ./html/h5:/usr/share/nginx/html/h5:ro
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||