Compare commits

...

48 Commits

Author SHA1 Message Date
Simon 1c5169531a docs(REQ-会话-001): v1.4 状态条删除闭环 + 三件套升 v1.4
- 以 v1.3.5 代码为准,删除整合区状态条(不向员工暴露坐席在线/离线)
- PRD/技术方案/原型三件套升 v1.4:整合区 4→3 元素(操作按钮+进度胶囊+引导语)
- 任务说明书 v1.3→v1.4:M8 shiftHours 取消、M9 降为三元素、M11 依赖调整
- TR-会话-001-v1.3.2 分歧1 由待拍板升级为已闭环,附录A 补 v1.4 闭环记录
- 整改记录 #9→#10:闭环状态条分歧遗留项(v1.7 登记)
- 代码落地:IntegrationZone.vue 移除状态条渲染;新增 integrationZoneStatusBar.test.ts 源码级回归守卫
2026-08-04 00:34:41 +08:00
Simon 502551ad9c chore(repo): 补 dist.bak.*/ 忽略规则 + 记忆目录脱管 + 坐席工作台原型 v1.3 入仓
- .gitignore: 新增 dist.bak.*/ 规则,覆盖 7 个 Vite 构建备份目录
  (原规则 dist_bak*/ 只匹配下划线变体,点号变体仍显示为未跟踪噪声)
- .workbuddy/memory/MEMORY.md: git rm --cached 脱管
  (.gitignore:144 已声明「记忆目录个人上下文,不入仓」,该文件为规则生效前遗留;
   磁盘文件保留不受影响)
- docs: 坐席工作台原型 v1.3(右栏重组) 纳入版本控制,接续 v1.0-v1.2 版本链
2026-08-03 23:55:19 +08:00
Simon a8da4da0db docs(会话-001/用户-005): 版本合并与文档规范化整改 #9
REQ-会话-001 员工结束会话:
- PRD/原型 v1.0/v1.1/v1.2 归档为 .archive,现行收敛至单一 v1.3
- 技术方案 v1.2 归档,v1.3 为现行版本(位于 docs/02-技术文档/)
- PRD v1.3 §六 关联文档表指向 v1.3 原型与技术方案实际路径
- 三份任务说明书 + BUG-003 + TC-用户-008 引用同步至 .archive/v1.3

REQ-用户-005 头像菜单退出:
- PRD/原型/技术方案文件名 v1.0 -> v1.1,追平内容版本(铁律2)
- 三件套 + 任务说明书 + TC 互引版本对齐,相关 PRD 指向会话-001 v1.3

其他:
- 修复 4 处 UTF-8 乱码(U+FFFD)
- 新增 TR-会话-001-结束会话-v1.3.2 端到端测试报告(单测 75/75 + 生产构建通过)
- 整改记录 #9 写入 00-文档规范化整改记录.md

遗留: PRD v1.3「状态条永久显示」与代码 v1.3.5「状态条已删除」存在分歧,待产品拍板
2026-08-03 23:53:59 +08:00
Simon f9c25147c9 feat(会话/消息可靠性): REQ-会话-001 员工结束会话 + message-reliability 收尾
- 取消人工坐席按钮的结束咨询功能,满意度评价迁移至头像右侧结束会话按钮
- ChatPanel: 结束会话直接触发满意度评价
- InputBar: 移除 serving 态,保留人工坐席呼叫四态(hidden/disabled/active/urgent/waiting)逻辑
- inputBarGuideText: 场景1/2 引导语返回 null
- useH5WebSocket: 新增 recommend_update 事件处理
- recommend: 30 分钟推荐抑制白名单
- routing_service 阻断性转人工逻辑已在上游提交,本提交聚焦前端收尾
2026-08-03 23:18:28 +08:00
Simon 1f6a2936f5 chore(cleanup): ignore archives/ + 02meiti/.hilo + 移除误入 index 的 3 个文件
- .gitignore: 新增 archives/ (85 个早期临时备份) + 02meiti/.hilo/ (hilo 应用数据)
- git rm --cached: 02meiti/.hilo/{index.sqlite-shm, index.sqlite-wal, storage.json}
  (这 3 个文件被误加入 git index, working tree 文件保留)
2026-08-03 20:29:55 +08:00
Simon 01e070c8ef docs(看板): v1.6 → v1.7 — T81 状态反转 + 今日完成项入库
P0 修复 — 滴答清单任务 6a6bfc2ae4b06440c36701f2

变更:
- #81 敏感词检测 + 语气优化:从 待开始(P0 逾期) 反转为 进行中
  - v1.1 阶段1 已于 2026-07-28 完成(词库入库 + 后台管理 UI + 12 个 API)
  - 剩余阶段(语气优化、灰度策略细化)待 v1.2 排期
  - 关联 PRD: docs/01-产品文档/00-产品规划/PRD-REQ-通用-004-敏感词检测-v1.2-AI辅助.md
- 新增今日完成项(2 项 P0):
  - troubleshooting_templates.py 5 端点补 auth + MOCK 替换 ORM(commit a20606f 已部署)
  - /itportal 500 修复(commit 21830d5 待部署)
- 统计更新:92 总 / 87 已完成 / 1 进行中 / 1 待开始 / 5 暂停 / 2 阻塞
- 版本变更记录追加 v1.7
2026-08-03 20:07:44 +08:00
Simon 21830d507d fix(nginx): 移除 /itportal/ 静态前端块(portal 源码已下线)
P0 修复 — /itportal/ HTTP 500(连续 3+ 天阻塞)

根因分析:
- portal 源码 frontend-portal/ 在 commit bea288e4 (2026-07-11) 被误删
- portal dist 不存在(前端目录已不存在)
- backend/app/api/portal.py 也已删除
- /api/portal/* API 不再在 router.py 注册
- 但 nginx 配置 + docker-compose 仍引用 portal dist → try_files 指向空目录 → 500
- nginx-split.conf 还有根路径重定向 / → /itportal/ → 用户访问根域名直接 500

修复(最小集、最安全):
1. nginx 6 个配置(nginx/nginx.conf + 5 个 deploy-server 历史变体):
   - 删除 location /itportal/ { alias ... } 静态块
   - 保留 location /itportal/meetingroom/ API 块(最长前缀仍命中)
   - nginx-split.conf / nginx-full.conf / nginx-green-upstream.conf 根重定向
     /itportal/ → /itdesk/
2. docker-compose 4 个配置(+split):
   - 删除 ./frontend-portal/dist 或 ./html/itportal 卷挂载
3. deploy-server/deploy.sh:
   - 删除 portal dist 备份/解压步骤
   - 部署完成提示去掉 /itportal/ 入口

预期效果:
- /itportal/ → nginx 404(不再 500)
- /itportal/meetingroom/ → API 仍工作(终端+H5 共用会议室预定)
- /itdesk/ /itagent/ /itadmin/ /itterminal/ 不受影响
- 根域名访问 → 重定向到 /itdesk/(H5 入口)

文件改动:
- nginx/nginx.conf(双 server block)
- deploy-server/nginx.conf(双 server block)
- deploy-server/nginx-full.conf + green-upstream.conf + nginx.conf + nginx-split.conf
- docker-compose.yml + 3 个 deploy-server 变体
- deploy-server/deploy.sh

⚠️ 待部署(无 jumpserver/VPN):本地已就绪,需在 jumpserver-V2 流程下
   scp 上述 11 个文件到 /opt/wecom-it-desk/ 覆盖 + docker compose restart nginx 验证
   验证命令:curl -I https://itsupport.servyou.com.cn/itportal/ 期望返回 404(或 301/302)
            curl -I https://itsupport.servyou.com.cn/itportal/meetingroom/... 期望 200/401/403
            curl -I https://itsupport.servyou.com.cn/ 期望 302 → /itdesk/

Refs: 滴答清单任务 6a6bfc29e4b06440c36701e1 (P0 逾期 1 天)
2026-08-03 20:02:52 +08:00
Simon 15be4963a1 chore: 删除根目录 $null 调试文件
$null 是 devtools 误保存的 HTML 文件(H5 端 index.html 内容),
文件名以 $ 开头在 POSIX shell 中是变量,会引起 bash 操作问题。
直接删除不入仓。
2026-08-03 18:54:36 +08:00
Simon 189458912c chore(.gitignore): 排除 workbuddy 工作区其他产物目录
2026-08-03 工作区清理时发现 .workbuddy/outputs/、.workbuddy/artifacts/、
.workbuddy/automations/、.workbuddy/tmp/、.workbuddy/deploy-temp/ 未被 ignore,
git add -A . 时被误纳入 M 状态。这些是 WorkBuddy 平台工作区产物,
不入项目仓库。

之前已有 .workbuddy/memory/、.workbuddy/logs/、.workbuddy/config.json 等 ignore。
2026-08-03 18:54:09 +08:00
Simon 0e54312b5c fix: 从 aaebe31 删除误加入的 test_api2.py / test_output.txt
这两个文件是临时调试产物(test_api2.py 是 1 行 requests.get 调试脚本,
test_output.txt 是 Vite 构建 chunk 列表输出),在 Batch 6 commit 时
误加入。修正删除。
2026-08-03 18:51:38 +08:00
Simon aaebe31bd8 chore: 清理根目录冗余历史与废弃副本
**根目录项目文档删除**(已迁入 docs/01-产品文档/):
- README.md / CHANGELOG.md / CONTRIBUTING.md
- mkdocs.yml / openapi.json / set-real-ip-patch.md
- (原 configs/ 移至根目录后被 git 识别为 rename)

**废弃源码副本清理**:
- frontend-h5/ 整个目录被 src/frontend-h5/ 取代(src 重组后未删除)
- frontend-h5-dist-part[1-3].bin 旧版部署分片

**历史归档清理**(根目录的 *.zip / *.bin / *.b64):
- agent-v1.4/1.5/1.6/1.7.zip
- chunk_0000.bin
- diagnose-500.b64 / fix-prod.b64

**根目录散落调试脚本清理**(已迁入 scripts/):
- build_frontend.ps1 / debug_*.py / test_*.py(18 个)
- deploy_*.py / pack_fix*.py / split_upload_v8.py
- tmp_upload_*.py / append_upload_v8.py
- add_*.py / add_full.py / add_neo4j.py
- update_*.py / verify_prompt.py / update_mfa.*
- split-b64.ps1 / fix-prod.sh / diagnose-500.sh / stage1-probe.sh
- start-backend.sh / run_db_check.py / main_original.py
- list_nas_*.ps1 / probe_nas.ps1
- init_neo4j*.cypher / fix_*.cypher / fix_alembic.sql
- mkdocs.yml / openapi.json / set-real-ip-patch.md

合计 82 文件删除(git 自动识别大量 rename 已在前序 commit 中处理)

**未 commit 保留在工作区的临时文件**(已 restore --staged):
- $null              bash 输出重定向残留
- --selector          git checkout 残留空文件
- test_api2.py        临时调试
- test_output.txt     chunk 列表临时输出
- .workbuddy/outputs/ WorkBuddy 工作区产物(应由 .gitignore 排除)
- archives/           历史归档 85MB(不适合入仓)
- 02meiti/            已 .gitignore
2026-08-03 18:51:25 +08:00
Simon 53b7bbcff7 chore(root): 收纳根目录调试产物与项目级文件
**项目级文件**:
- configs/mkdocs.yml           mkdocs 站点配置
- jumpserver-webcli/ANALYSIS.md  webcli 调试分析报告
- jumpserver-webcli/CHANGELOG.md webcli 变更日志
- jumpserver-webcli/deploy_0802_*.txt  0802 部署调试输出(4 个)
- jumpserver-webcli/diag_0802_*.txt   0802 诊断输出(3 个)
- jumpserver-webcli/diag_184715.txt   18:47:15 诊断输出
- tests/test_quick_rules.py   quick_rules API 测试
- temp/                       临时调试脚本目录(5 个:check_tables + 调试 + rename_dzpc + ts-check + inspect_batch)

**交付报告**:
- overview.md  「结束会话失败」修复交付报告(2026-07-30,ChatPanel.vue 三件套修复)

**会话调试上下文**:
- dedup_context.txt / dedup2.txt       去重调试上下文
- pattern_check.txt / pattern_context.txt  模式检查
- option_button_context.txt / option_select_context.txt  选项按钮/选择上下文
- qid.txt                              会话 ID 记录
- yixuan_context.txt                   艺璇任务上下文
- final_check.txt                      最终检查

**部署脚本输出**:
- deploy_v11.txt / deploy_h5_v11.txt   v11 部署命令输出

合计 27 文件(含 1 项目文档 + 22 调试上下文 + 4 jumpserver webcli 调试 + 1 mkdocs 配置 + 1 测试 + 1 temp/ + 2 部署输出)
2026-08-03 18:49:44 +08:00
Simon 7864ab404b feat(scripts): 收纳运维/部署/修复/调试脚本到 scripts/ 目录
**目录结构**:
- scripts/                 顶层运维/工具脚本(20 个)
- scripts/deploy/          一键部署脚本(deploy_*.py / upload_*.py / redeploy.py 等 8 个)
- scripts/fix/             一次性修复脚本(fix_*.py / fix_*.cypher / fix_*.sql 等 9 个)
- scripts/debug/           调试脚本集合(check_*.py/sql、test_*.py、debug_*.py 等 42 个)

**ops-tools/jms_ops.py**:jumpserver-ops skill 默认路径修复
- 从 'jumpserver-automation-shareable' 改为 'jumpserver-ops'
- 同步更新注释(优先 JP_SKILL_DIR 环境变量)

**典型脚本用途**:
- scripts/deploy-v1.2.ps1 / -staging.ps1:一键部署 v1.2 到生产 / staging
- scripts/init_quick_rules.sql:quick_rules 表初始数据
- scripts/gen_admin_token.py:生成 admin JWT token(调试用)
- scripts/build-and-verify.sh:v1.1 实施验证脚本
- scripts/verify_*.sh / .py:API/quick_rules 验证
- scripts/fix/*.py:环境修复(compose、env_key、itsm_bridge 等)

合计 81 文件 + 3317 行 / - 1 行
2026-08-03 18:48:02 +08:00
Simon 44e77dcb0e chore(docs): docs/ 目录全面重新编号 + 重组
**重构前**(旧编号 02-11):
- docs/02-产品需求/      → 00 产品规划/PRD
- docs/03-技术架构/      → 01-05 子目录散落
- docs/04-原型设计/      → 01-02 产品设计(HTML 原型)
- docs/05-原型设计/      → screens/
- docs/06-测试素材/      → 02-E2E / 03-功能 / 04-版本测试
- docs/07-项目管理/      → 任务说明书/日报/计划
- docs/08-安全审计/      → 审计报告
- docs/09-堡垒运维/      → toolbox / deploy
- docs/10-项目管理/      → 任务说明书(重复)
- docs/11-历史归档/      → deploy-nas-archived

**重构后**(新编号 00-07,语义化):
- docs/00-产品开发流程与文档管理规范.md
- docs/00-版本迭代总览.md
- docs/01-产品文档/      (PRD/原型/认证/会话/AI 服务/坐席/集成)
- docs/02-技术文档/      (技术方案/架构图/重构记录/前端改造/实现配置)
- docs/03-测试文档/      (E2E/功能用例/版本报告/缺陷单)
- docs/04-运维文档/      (部署运维/运维指南)
- docs/05-运营文档/      (品牌推广/用户手册)
- docs/06-安全审计/      (审计报告)
- docs/07-项目管理/      (任务说明书/日报/计划/看板)

**净收益**:
- 目录编号与产品文档管理规范对齐(按文档阶段 01-07 编号)
- 消除 02-产品需求 与 10-项目管理 的编号重叠
- 子目录按文档类型分组(如 01-产品文档/00-产品规划、01-产品文档/01-认证与登录)
- 把运维/安全/项目管理从 0X 散落改为 04/06/07

合计 494 文件 + 78495 行 / - 14076 行
2026-08-03 18:46:55 +08:00
Simon 3a44141eac chore(config): 同步 src/ 路径迁移 + nginx SPA 路由 + 文档图简化
**src/ 路径迁移配套**:
- docker-compose.yml: backend context ./backend → ./src/backend
- docker-compose.yml: bind mount ./app → ./src/backend/app
- docker-compose.dev.yml: 3 个 bind mount 同步调整
- Dify 双路径超时从 12s 提到 20s(2026-07-20 用户反馈高峰期超时)

**nginx SPA 路由修复**(前端路由 /itagent/x → /itagent/index.html):
- nginx/nginx.conf: 加 try_files $uri /itagent/index.html

**生产环境 nginx 反代重写**:
- deploy-server/nginx.conf: 从 33 行 localhost 模板改为 357 行完整反代
  - 新增 /itdesk /itagent /itadmin /itportal /itterminal/itportal/meetingroom
    路由规则
  - 反代到 backend:8000 (API + WS)
  - / 根路径反代到数据查询平台

**架构图简化**(精简但保留关键类/时序):
- docs/class-diagram.mermaid: 215 行 → 59 行
- docs/sequence-diagram.mermaid: 115 行 → 35 行

合计 6 文件 + 470 行 / - 293 行
2026-08-03 18:46:10 +08:00
Simon 969f524962 feat(REQ-通用-005): v1.1 选项选择持久化方案实施
参考:docs/02-技术文档/技术架构/实施报告-REQ-通用-005-v1.1.md
方案:docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.1-正式方案.md

4 个子任务全部完成:
- T01 基础设施(BackendObserver 服务 + WS 广播)
- T02 Bug 6(conversation.ts option 状态持久化)
- T03 Bug 4(h5_ai_task.py:443 still_thinking 兜底)
- T04 Bug 5+Req 7(ChatPanel.vue collapsed 折叠 + WS 监听)

文件清单:
- 后端 API:4 文件(api/router/ws + 新增 api/backend_observer)
- 后端 service:1 文件(services/backend_observer.py)
- 后端 tasks:1 文件(h5_ai_task.py 兜底)
- 后端 tests:2 文件(test_backend_observer + test_h5_mask_option_select)
- 前端 stores:1 文件(conversation.ts 持久化 + WS 订阅)
- 前端 composables:1 文件(useH5WebSocket.ts 选项广播监听)
- 前端组件:2 文件(ChatPanel.vue 折叠 + MessageBubble.vue 状态展示)
- 前端 package.json:2 文件(agent + h5 dependencies)
- 前端 scripts:1 文件(measure-option-latency.mjs 端到端测量)
- 前端 types:1 文件(components.d.ts 声明)

合计 16 文件 + 1053 行 / - 12 行。
2026-08-03 18:45:46 +08:00
Simon 5adcb4852a fix(alembic): 053 down_revision 笔误修正
down_revision 之前误写为文件名 '052_diagnostic_queue_quiz_closing',
应为 052 迁移的实际 revision id '052_diag_queue_quiz'。

影响范围:
- alembic history 直接抛 KeyError: '052_diagnostic_queue_quiz_closing'
- 任何 alembic upgrade / stamp head 操作均无法解析完整链
- 之前未被发现的根因:生产 alembic_version 长期停在 052,从未尝试解析后续链

验证(生产环境 2026-08-03 17:03 P1 治理任务):
- 修复前:alembic history → KeyError
- 修复后:052 → 053 → 054 → 055 → 056(moderation) → 057 链完整解析
- alembic stamp 057_troubleshooting_templates 已成功执行
- 数据库实际状态确认:conversations.is_archived / messages.reply_source /
  quick_rules / sensitive_words / privacy_patterns / moderation_logs /
  troubleshooting_templates 均已存在,迁移链与数据库一致
2026-08-03 18:34:41 +08:00
Simon 22909fb3bd docs: 补充 #134 相关 PRD-009 + 技术方案 #002 交叉引用 (#134)
**新增**
- `docs/01-产品文档/04-坐席工作台/PRD-REQ-坐席-009-会话状态Tab筛选-v1.0.md`
  - 7 段结构:背景与目标 / 需求说明(5 个 FR + 状态映射规则 + 跨段渲染)/ 用户故事 & 10 项 AC / 交互示意 / 不在本 PRD 范围 / 关联文档 / 变更记录
  - 明确边界:未实现"Tab 数量徽章 + 空状态文案",后续 #135 候选

**修改**
- `docs/02-技术文档/技术架构/技术方案-REQ-坐席-002-AI辅助消息框-v1.0.md`
  - §3.1 组件架构图:ConversationList.vue 增加 2 行注释(说明筛选 Tab + 数据源)
  - 文末追加"📌 增量变更记录(不破坏 v1.0 原结构)"段:交叉引用 PRD #009 / 任务 #134 / 原型 v1.2 / commit 566bb46

**未改**
- 不动架构图层级 / 不动其他组件的描述 / 不破坏 v1.0 原 §1-§12 结构
- 不在 #002 中重写需求细节(这些都在 PRD #009 集中管理,避免文档冗余)
- `docs/04-运维文档/部署运维/01-项目总览与部署手册-20260704.md` / `开发交付概览.md` grep 通过无相关描述,无需更新

**关联**
- #134 落地代码:commit `566bb46`
- 已同步文档:PRD #009(新建)+ 技术方案 #002(增量段)

**操作失误修复**
- 起草时通过 Write 工具误将 PRD #009 写到了 `D:\资料\03-产品文档\` 而非项目内的 `D:\资料\03-项目开发\wecom_it_smart_desk\docs\01-产品文档\`(少了一个目录层级 + 中文章段编号错位)
- 修复:用 PowerShell `Move-Item` 把文件移到正确路径,递归清理空目录 `D:\资料\03-产品文档\`(只含我自己刚建的 1 个文件 + 2 个空目录,无误删任何个人文件)
2026-08-03 17:58:22 +08:00
Simon 566bb46cd4 feat(agent): 坐席端左栏会话状态 Tab 顺序调整 + 默认显示待处理 (#134)
**改动**
- `src/frontend-agent/src/components/conversation/ConversationList.vue`
  - `filterTags` 数组顺序倒置:`[全部, 待处理, 进行中, 已完成]` → `[待处理, 进行中, 已完成, 全部]`
  - `activeFilter` 初始值 `'all'` → `'pending'`(坐席日常首选入口)
- `docs/07-项目管理/任务说明书/任务说明书-134-坐席端左栏会话状态Tab顺序调整+默认显示待处理.md`(新增)

**未改**
- 不动 store(`myConversations` / `colleagueConversations` / `historyConversations` 划分准确)
- 不动 `applyFilters` 逻辑(status 映射正确:pending→queued、active→serving/ai_handling、done→resolved)
- 不动 ConversationItem.vue / 样式 / backend API
- 不重命名 `activeFilter` 的 key(保持 `'pending' | 'active' | 'done' | 'all'` 后向兼容)

**部署**
- 构建:`npm run build` 6.20s 通过,新 main chunk `index-BK_U7e10.js`(旧 `index-BDGZ_gcJ.js`),Workspace chunk 唯一 `D44-5RYv.js`
- 打包:`packages/frontend-agent-dist-0803-133.tar.gz` 576,913B(25 个文件,0 累积污染)
- jumpserver-V2 全程免登录(cache 19.7h 内有效)
- 备份链:`/opt/wecom-it-desk/frontend-agent/dist.bak.0803_pre133`

**验证**
- 服务端(curl 绕用户/CDN 缓存):HTTP 200 + main JS hash 变更 + Workspace=1 + 备份存在 
- 浏览器无痕模式:用户 2026-08-03 17:48 确认"显示已更新"(默认激活"待处理" + Tab 顺序倒置)
- v1.2 原型与实际页面 Tab 顺序一致 

**任务编号修正**
- 起草时用了 #133,但该编号已被当日另一任务 `voice_asr.py POST /asr auth 加固` 占用
- 沿用 #132 后下一个空号 → 实际为 **#134**
2026-08-03 17:49:42 +08:00
Simon a20606f328 fix(security): troubleshooting_templates 5 端点加 auth + MOCK 替换 ORM
P0 安全修复(连续 2 次巡检标记):

1. 5 端点全部加 auth 依赖
   - GET list/detail → Depends(get_current_user)(任何已登录用户)
   - POST/PUT/DELETE → Depends(require_admin)(仅 admin)

2. 进程内 MOCK_TEMPLATES → PostgreSQL troubleshooting_templates 表
   - ORM model 早已注册但缺迁移 → 新建 057_troubleshooting_templates.py
   - 8 套预设模板在冷启动时通过 seed_default_troubleshooting_templates 插入
   - 容器重启不再丢数据

3. 5 源调用方审计通过(与 voice_asr.py P0 修复同款教训):
   - H5 端 api/troubleshooting-templates.ts:只读
   - Admin 端 api/troubleshooting.ts + Flowcharts.vue:5 端 CRUD 全套
   - Agent 端 api/troubleshooting.ts:只读
   - service_routes.py:仅注册无反向调用
   - tests:无相关调用

4. 端到端验证(生产环境实测):
   - GET no-auth → 403
   - GET invalid-token → 401
   - GET admin token → 200 + 8 items
   - POST admin (sxn) → 201 + 新建 UUID
   - POST 非 admin → 拒绝(业务 code 1002)
   - PUT/DELETE admin → 200 + 字段更新/删除
   - 容器重启后 → 8 套数据仍在(DB 持久化生效)

文件改动:
- alembic/versions/057_troubleshooting_templates.py(新建, 79 行)
- app/api/troubleshooting_templates.py(重写 720→859 行)
- app/main.py(1071→1082, +11 行 seed 调用)

相关 P1 跟进(已同步滴答清单):
- 053-056 迁移脱节(生产 alembic_version=052)
- 缺 2 索引 idx_tpl_category / idx_tpl_active

Refs: voice_asr.py P0 修复(5 来源审计教训)
2026-08-03 16:27:10 +08:00
Simon b321e3dabd chore(repo): 重组项目源码目录至 src/ 下 (#重组)
- 顶级源码目录 backend/ frontend-h5/ frontend-admin/ frontend-terminal/ frontend-agent/ 物理移动到 src/ 下
- frontend-agent/ HEAD 中原本只跟踪 dist 备份和零散配置(无业务源码),本次作为新模块首次纳入
- 强化 .gitignore: 覆盖历史 dist 备份(dist.bak/ dist_bak*/ dist-clean/ dist_old/ 等)、node_modules_old/、前端部署 bin chunk(*-part*.bin / p[0-9].bin)、src/backend/uploads/ + src/backend/media/(运行时上传)
- 备注: docs/ 目录重组(01-产品设计→01-产品文档 等)暂留 working tree,本次未入库
2026-08-03 15:42:28 +08:00
Simon 7886f2f523 kfid路由窗口: 5类别映射企微客服链接(ROUTING_TARGETS配置化,弃用business_contacts个人名片)+ send_service_card新函数 + ContactCard支持service_url点击打开客服会话(行政→机票酒店前台/人力→共享服务/财务→报销服务台/法务→行政法务/物业→物业服务) 2026-07-18 11:24:10 +08:00
Simon 28bc4bda3b docs: C7/C8 完成状态更新 2026-07-18 10:53:00 +08:00
Simon 05304aec9c C7/C8: employees.last_login_ip 列补全(ALTER TABLE 迁移)+ redis decode 兼容修复 7 处(wecom_service×2/employee_directory×3/auth_wecom_sso×2,统一 isinstance 保护模式) 2026-07-18 10:50:27 +08:00
Simon 928f4bb337 docs: 批次4死代码大扫除完成 + D1合并上线状态 2026-07-18 10:35:40 +08:00
Simon b997a683ce 批次4死代码大扫除: 删triage三件套(H5零挂载)+ /approval/keywords端点 + scheduler.py孤儿模块 + 3个.bak文件 + get_reply_stream(~96行) 2026-07-18 10:22:07 +08:00
Simon 6a5f01ff90 docs: D1合并状态更新(后端双模已部署,待Dify发布v1.3) 2026-07-18 02:47:12 +08:00
Simon 6f545fe0b6 D1正式合并: 后端双模支持(主Dify读intent_type,无字段降级旧路径)+ ai_service透传路由字段 + Dify Prompt v1.3(新增路由意图标注规则+场景4) 2026-07-18 02:46:28 +08:00
Simon 1bfc00559a docs: 批次3管线化完成 + 24h观察期标记 + D1分步实施决策 2026-07-18 02:34:51 +08:00
Simon bfac494b01 批次3-P1-3: process_h5_ai_reply管线化重构(主函数312行→76行,try/except 11对→1对,9步骤函数)+ D1止血(路由意图超时15s→8s) 2026-07-18 02:31:46 +08:00
Simon 2681e7b0bf docs: P1-7完成 + 批次2全部完成 + C7/C8预存在问题记录 2026-07-18 02:19:12 +08:00
Simon 6cbee00dc0 P1-7(完成): Redis密码轮换(硬编码→.env注入,32位新密码已生效,老密码WRONGPASS验证失效)+ APP_ENV=production传入容器(修复生产UA校验和IP白名单未生效) 2026-07-18 02:16:56 +08:00
Simon f9be3547d9 docs: P1-6 完成状态更新 2026-07-18 02:03:55 +08:00
Simon 59609b1d8d P1-6: WS死路由清理+结单确认断链修复 - 删ai_reply_chunk/quiz_diagnostic_answer死路由 + pending_close_request改为resolve_confirm对齐后端 + 挂载ResolveConfirmCard(修复坐席结单员工端不可见的功能断链) 2026-07-18 02:03:13 +08:00
Simon 9cbf2650e1 P1-4b: Matcher优先级修正 - title匹配提到category之前(title是Dify判断的具体意图,比大类更精确,真实场景: approval_type=账号权限申请+title=VPN账号申请应输出单卡) 2026-07-18 01:42:05 +08:00
Simon 6e74631ecb docs: P1-2/P1-4/P1-7部署完成记录 + pack-upload通道铁律 2026-07-18 01:34:40 +08:00
Simon 555a16c310 docs: 更新12-重构记录状态看板 - P1-2/P1-4/P1-7部分代码完成待部署 + 待部署清单 2026-07-18 01:18:11 +08:00
Simon 5dd8436cde P2-1(部分): 删除孤儿组件 InputBox.vue/MessageItem.vue + store死代码 showApprovalCard/closeApprovalCard/approvalCardVisible(P0-3误报确认,组件备份于.workbuddy/tmp/orphan-backup) 2026-07-18 01:17:08 +08:00
Simon 2a4db59f02 P1-7(部分): 3个潜伏bug修复 - main.py app_root未定义/scheduler.py AsyncIOSScheduler拼写/config.py logger未定义 2026-07-18 01:13:33 +08:00
Simon 873f3176e8 P1-4: Matcher死分支删除+匹配失败兜底全量卡片 + 补全18模板icon/desc/category字段(v3.0未生效) + title匹配增强 2026-07-18 01:10:18 +08:00
Simon aeb4e0cf39 P1-2: 统一Dify调用点 - 删除detect-intent死链路(approval/byod) + routing_service共享client修复连接泄漏 + 前端死代码清理 2026-07-18 01:05:20 +08:00
Simon af392f2bf0 P1-1: AIService/AIHandler 真单例 + lifespan close httpx 连接池(修复连接泄漏) 2026-07-18 00:36:13 +08:00
Simon a6518bff03 P0-6: Dify超时预算切分 native/proxy 各12s(修复proxy兜底不可达,变量已注入容器) 2026-07-18 00:28:15 +08:00
Simon 54481525f4 P0-3/P0-4/P0-5: 快捷申请全量卡片端点+RecommendCard崩溃修复+WS全字段透传(部署v16) 2026-07-18 00:05:59 +08:00
Simon 18a639919c v3.2: 降级结果已含card_data时跳过重复匹配(避免approval_type=None误报匹配失败) 2026-07-17 23:39:20 +08:00
Simon 6932e5a449 P0-2: compose environment 声明 DIFY_NATIVE_*(修复 v2.1 原生直连未上线,服务器已验证变量注入) 2026-07-17 23:21:28 +08:00
Simon 939de70e48 P0-1: 删除 docker-compose-override.yml + deploy-server 两份 workers=2→1(预防性修复,服务器根 compose 已是 workers=1) 2026-07-17 23:16:40 +08:00
Simon 3ed86d5fb3 v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化 2026-07-17 23:08:59 +08:00
1872 changed files with 134600 additions and 27628 deletions
+39
View File
@@ -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 不入仓
@@ -247,3 +253,36 @@ 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,152 @@
# 早班巡检自动化 - 执行记录
## 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-117天)、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-1307-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`
-96
View File
@@ -1,96 +0,0 @@
# IT智能服务台 - 项目记忆
## 设计决策(锁定)
- AI交互:小段多回合;术语:**员工端"人工坐席"按钮** = 用户呼叫坐席(统一命名,不再用"人工"/"摇人"变体);"摇人"=坐席呼叫坐席
- 原型:坐席v5.3 + H5 v1.1UI:企微浅色扁平,accent=#07C160
- 统一入口 `/itportal/` → user/agent/adminadmin需OTP
- **H5 v42026-07-13 00:48 已部署)**:人工按钮三态文案统一为"人工坐席";位置在"发送键和语音按钮上方"(垂直堆叠于 `.input-bar__controls` 容器内);点按钮直接调 `store.shakeAgent()`,不弹 CallAgentModal 浮窗动画;截图说明 PC 显示/移动端隐藏(CSS 媒体查询)
- **H5 v52026-07-13 02:08 已部署)**RightPanel v2.1 — 删除"软件安装"和"资源权限"标签页,移除标签栏,智能推荐直接展示;JS hash `index-BP1rEZIf.js`CSS hash `index-DC1iZpKe.css`
- **Agent v52026-07-13 01:38 已部署)**ai_structured/byod_card 只读渲染 + AI思考指示器 + handleNewMessage 透传 msg_type/extra_data 修复;JS hash `index-2BTn4SZz.js`
- **后端 v52026-07-13 01:38 已部署)**6个Python文件(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等);diagnosis_stage(6值)+response_time_ms计时+VisionService接入+双WS推送(ai_reply+dynamic_recommend)+ai_thinking同时推员工和坐席
## 技术架构
- 前端:坐席(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`
- **服务器项目根路径**`/opt/wecom-it-desk/`(非本地仓库路径)
- 后端卷挂载:`./app:/app/app``.py`变更→`docker compose restart backend`env变更→`up -d backend`
- **前端部署铁律**:所有前端 dist 均为 ro bind mount,**只能在宿主机源路径操作**,不可在容器内修改
- H5`/opt/wecom-it-desk/frontend-h5/dist``/usr/share/nginx/html/h5` (ro)
- Agent`/opt/wecom-it-desk/frontend-agent/dist``/usr/share/nginx/html/itagent` (ro)
- Admin`/opt/wecom-it-desk/frontend-admin/dist``/usr/share/nginx/html/itadmin` (ro)
- Portal`/opt/wecom-it-desk/frontend-portal/dist``/usr/share/nginx/html/itportal` (ro)
- Terminal`/opt/wecom-it-desk/frontend-terminal/dist``/usr/share/nginx/html/itterminal` (ro)
- nginx.conf`/opt/wecom-it-desk/nginx/nginx.conf``/etc/nginx/nginx.conf` (ro)
- 部署命令模板:`H5_DIR=/opt/wecom-it-desk/frontend-h5/dist && cp -r $H5_DIR ${H5_DIR}_bak && rm -rf $H5_DIR/* && tar -xzf /tmp/h5-dist-vX.tar.gz -C $H5_DIR/ && docker exec wecom_it_nginx nginx -s reload`
- **服务器 nginx /h5/ 配置**(与本地仓库不同):静态文件服务 `root /usr/share/nginx/html; try_files $uri /h5/index.html;` + `/h5/api/` 反代后端
- Docker bind mount铁律:rm后重建必须重启容器(或 `nginx -s reload` 热重载)
- **文件上传**elFinder Web UI`hz-oa-ai-g-dataquery-90-5-110`目录=服务器`/tmp/`/ fast_upload_v3.py~20KB/s/ jms_ops.py pack-upload
- ⚠️ **elFinder 上传二进制文件不可靠**2026-07-13 确认):tar.gz 上传后 MD5 不匹配(差90字节)。**推荐用 base64 分块上传**`jms_ops.py batch` 模式,45KB/块,~113秒/7.6MB(脚本 `.workbuddy/tmp/chunked_upload_v2.py`
- pscp/plink -T不可用;elFinder上传后需手动mvhttpx.Timeout须含default
- **JumpServer v2.28 变更**:登录新增图片验证码(CAPTCHA)connection-token 端点改为 `/api/v1/authentication/connection-token/`
## 外部集成
- 企微通讯录:Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8`Redis key=`wecom:contact_access_token`
- Dify`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`;分诊Key `app-z3S9AEUUAVPbtR2rioxpiIvp`
- ⚠️ **禁止使用** `app-UaTWYdBSwN6VktKQlbh5YN5H`(老线上Dify应用,未收到明确指令前不可调用);应使用副本 `app-7jkRkAzvX4QM9v9SM3P8mMEO` 或自建 `app-J3s8sHarZQ2SCaNF3xCppliL`(后者不适合dify2openai代理,仅限直接API调用)
- RAGFlow:生产 `http://10.80.0.85:8080/` / API `:9380`
- 映射策略:联软(主) > aTrust(VPN) > eHR(静态)
## 企微JS-SDK技术
- 双鉴权:`wx.config()`(jsapi_ticket) + `wx.agentConfig()`(agent_config_ticket),签名算法相同但**不能混用**
- `wx.invoke('thirdPartyOpenPage', {oaType:'10001', templateId, thirdNo, extData})` 原生打开审批表单
- 后端端点:`GET /wecom/jsapi-config?url=...&with_agent_config=true`
- 前端composable`frontend-h5/src/composables/useWecomApproval.ts`(懒加载+全降级+超时保护)
- 既有bug`EmergencyDispatcher.vue` 第99-119行 复用jsapi签名给agentConfig(靠3秒超时兜底)
## 已上线功能模块
- 群聊(摇人/邀请/四角色) / 审批(12类型18流程/三级意图) / 代办(getapprovalinfo+Semaphore/缓存45s)
- IT资产推送(模板`Bs7ucT...`) / 语音转文字(手机JS-SDK/PC百度ASR) / 截图拍照 / 复杂场景P0~P3
- 会议室预定(终端`/itterminal/`,企微会议室Secret待申请)
## 坐席端布局优化v2.02026-07-12 部署)
- 8新增+7修改+3删除;QuickReplyBar L1+L2悬浮;ReplyBox左右分区;右栏260↔560px模式切换
- **键盘快捷键v2.3**:纯数字1~9上下文路由(AI/L1/L2)ESC分层撤销;Shift+Space用`event.code`匹配(不受IME影响)
- `useKeyboardShortcuts.ts`中央管理器,IME/ScreenCapture守卫;L1 chip移除模板数量徽章只保留kbd编号
## 知识库迭代3功能(2026-07-12 部署)
- 分诊交互(H5+坐席+Dify独立应用) + 拓扑预览(ECharts只读) + 代答排除(4种匹配器)
- 44文件43测试通过;迁移051;路由顺序铁律:固定路径必须在参数路由前注册
## 上下文感知智能诊断→修复闭环(2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked) + 答题插队 + 五场景关闭
- 后端:迁移052(6表+6列) / queue_service / quiz_service / closing_service / seed_quiz / 每日3:00定时生成
- H5前端:QueueWaiting / RightPanel双Tab / InputBar三态"人工"按钮 / ResolveConfirmCard
- 坐席前端:pending_close结单流程;信息锁定(Dify步骤完成+有效回答率≥70%)
- **部署时间**2026-07-12 21:24H5前端通过jms_ops.py upload elFinder通道上传7.61MB
- **验证**Queue API 200 ✅ / Quiz API 200 ✅ / H5页面200+新JS hash ✅ / Agent前端v2.3 ✅ / Nginx healthy ✅
## Dify App改造 + 右边栏v22026-07-12 设计确认 → Phase 1-3 已完成)
- **Dify App现状**85节点→计划精简至~35;保留RAGFlow+Vision节点(后端未接入前不删)
- **单通道统一消息架构**:Dify输出JSON `{text, action, options}` → 后端发两条WSai_reply+dynamic_recommend)→ 文字到聊天气泡/卡片到侧边栏
- **审批意图优化**:删除前端`checkApprovalIntent()`;关键词预过滤收窄(~40→~25);两级分类(4粗→12细);后端统一入口
- **右边栏v2.1已实施**v2.1 2026-07-13:删除软件安装/资源权限标签,全面AI化):
- 手风琴两大区域:设备信息(默认折叠) / 自助诊断(默认折叠)
- 智能推荐区域:DynamicRecommend 组件直接展示(无标签栏切换,始终可见)
- 设备信息:CPU/内存/硬盘默认隐藏(避免焦虑)
- 自助诊断标签:网络联通/账号权限/设备硬件
- **Phase 1-3 完成状态**2026-07-12):
- Phase 1 ✅:Dify Prompt(JSON输出) + 后端blocking+JSON解析+双WS推送 + 错误降级(30s超时/15s still_thinking)
- Phase 2 ✅:关键词收窄(~25强意图词) + 两级分类Prompt v4.0 + 删除前端checkApprovalIntent
- Phase 3 ✅:WS扩展(ai_thinking+dynamic_recommend) + MessageBubble ai_structured渲染(文字+选项按钮) + RightPanel v2.1(手风琴+智能推荐直接展示) + DynamicRecommend.vue(新建) + sendOptionSelect WS回传
- **Phase 4 ✅**VisionService接入(`_enrich_image_content`+`_fetch_recent_employee_text`5秒融合) + 图片消息跳过关键词拦截 + 降级策略
- **Phase 5 ✅**:坐席端`ai_thinking` WS+指示器UI + `MessageBubble` ai_structured/byod_card渲染 + `handleNewMessage`修复(msg_type/extra_data透传)
- **Phase 6 ✅**`diagnosis_stage`字段(6种值) → `closing_service`辅助方法 + `response_time_ms`计时+慢响应告警(>10s)
- **v2.0 新增前端文件**`DynamicRecommend.vue`(动态推荐卡片,3种类型 approval/action/info
- **v2.0 关键架构**`sendWsMessage()` 模块级导出函数(useH5WebSocket.ts),供 store 在 composable 外部发送 WS 消息
- **实施计划文档**`docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md`6阶段Phase 1-610个Task #59-#69跟踪
## 运维工具
- SOP`docs/10-项目管理/IT智能服务台-标准作业流程SOP.md`
- 故障排查:`docs/09-部署运维/00-标准故障排查手册.md`
Binary file not shown.
Binary file not shown.
-19
View File
@@ -1,19 +0,0 @@
{
"_version": 5,
"preferences": {
"loadUserMemory": true
},
"recentFiles": [],
"assetPanel": {
"typeFilters": [],
"dateFilter": {
"kind": "all"
},
"sortOrder": "desc"
},
"timelinePanel": {
"openTimelineIds": [],
"activeTimelineId": null
},
"lastUsedModelParams": {}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-83
View File
@@ -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()
-71
View File
@@ -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",
]
-449
View File
@@ -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 KeyBearer Token
base_url: RAGFlow API 基础地址(不含尾部斜杠)
timeout: 默认请求超时(秒)
Raises:
RagflowConfigError: API Key 为空
"""
if not api_key:
raise RagflowConfigError("RAGFlow API Key 不能为空")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
"""构建请求头。
Returns:
Dict: 包含 Authorization 和 Content-Type 的请求头
"""
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
json_data: Optional[Dict] = None,
params: Optional[Dict] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""统一请求封装。
Args:
method: HTTP 方法(GET/POST/PUT/DELETE
path: API 路径(如 /api/v1/retrieval
json_data: JSON 请求体
params: 查询参数
timeout: 覆盖默认超时
Returns:
Dict: API 响应的 JSON 数据
Raises:
RagflowAuthError: 认证失败(401
RagflowApiError: API 返回错误
RagflowConnectionError: 网络连接失败
"""
url = f"{self.base_url}{path}"
req_timeout = timeout or self.timeout
try:
async with httpx.AsyncClient() as client:
response = await client.request(
method=method,
url=url,
headers=self._headers(),
json=json_data,
params=params,
timeout=req_timeout,
)
# 处理 HTTP 错误
if response.status_code == 401:
raise RagflowAuthError("RAGFlow API Key 无效或已过期")
if response.status_code >= 400:
try:
err_body = response.json()
err_msg = err_body.get("message", response.text)
except Exception:
err_msg = response.text
raise RagflowApiError(
code=response.status_code,
message=f"RAGFlow API 错误 ({response.status_code}): {err_msg}",
)
# 解析响应
result = response.json()
# RAGFlow 统一响应格式:{code: 0, data: ..., message: ...}
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "未知错误"),
)
return result
except httpx.TimeoutException:
raise RagflowConnectionError(f"RAGFlow 请求超时 ({req_timeout}s): {path}")
except httpx.ConnectError:
raise RagflowConnectionError(f"RAGFlow 连接失败: {self.base_url}")
except (RagflowAuthError, RagflowApiError, RagflowConnectionError):
raise
except Exception as e:
raise RagflowError(f"RAGFlow 请求异常: {str(e)}")
# ==========================================================================
# 测试连接
# ==========================================================================
async def test_connection(self) -> Dict[str, Any]:
"""测试 RAGFlow API 连接。
通过列出数据集(limit=1)验证 API Key 是否有效。
Returns:
Dict: {success: bool, message: str}
"""
try:
result = await self.list_datasets(page=1, page_size=1)
return {
"success": True,
"message": f"连接成功,共 {result.get('total', 0)} 个知识库",
}
except RagflowAuthError:
return {"success": False, "message": "API Key 无效或已过期"}
except RagflowConnectionError as e:
return {"success": False, "message": f"连接失败: {e.message}"}
except RagflowError as e:
return {"success": False, "message": e.message}
# ==========================================================================
# 知识检索(核心接口)
# ==========================================================================
async def retrieval(
self,
question: str,
dataset_ids: Optional[List[str]] = None,
document_ids: Optional[List[str]] = None,
similarity_threshold: float = 0.2,
vector_similarity_weight: float = 0.3,
top_k: int = 1024,
keyword: bool = False,
highlight: bool = False,
) -> RetrievalResult:
"""知识检索 — 从知识库中搜索相关文档片段。
这是 RAGFlow 的核心接口,用于根据用户问题检索最相关的文本块。
Args:
question: 用户查询问题
dataset_ids: 要搜索的数据集ID列表(与 document_ids 二选一)
document_ids: 要搜索的文档ID列表
similarity_threshold: 最小相似度阈值(0-1),默认 0.2
vector_similarity_weight: 向量相似度权重(0-1),默认 0.3
top_k: 参与计算的块数量,默认 1024
keyword: 是否启用关键词匹配,默认 False
highlight: 是否高亮匹配术语,默认 False
Returns:
RetrievalResult: 检索结果(含文本块、文档聚合、总数)
Raises:
RagflowError: 检索失败
"""
body: Dict[str, Any] = {
"question": question,
"similarity_threshold": similarity_threshold,
"vector_similarity_weight": vector_similarity_weight,
"top_k": top_k,
"keyword": keyword,
"highlight": highlight,
}
if dataset_ids:
body["dataset_ids"] = dataset_ids
if document_ids:
body["document_ids"] = document_ids
result = await self._request("POST", "/api/v1/retrieval", json_data=body)
data = result.get("data", {})
# 解析文本块
chunks = [
RetrievalChunk.model_validate(chunk)
for chunk in data.get("chunks", [])
]
# 解析文档聚合
doc_aggs = [
DocAggregate.model_validate(agg)
for agg in data.get("doc_aggs", [])
]
return RetrievalResult(
chunks=chunks,
doc_aggs=doc_aggs,
total=data.get("total", 0),
)
# ==========================================================================
# 数据集(知识库)管理
# ==========================================================================
async def list_datasets(
self,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出所有数据集(知识库)。
Args:
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DatasetInfo], total: int}
"""
result = await self._request(
"GET",
"/api/v1/datasets",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DatasetInfo.model_validate(ds)
for ds in data.get("datasets", [])
]
return {"items": items, "total": data.get("total", 0)}
async def create_dataset(
self,
name: str,
embedding_model: str = "BAAI/bge-m3@BAAI",
chunk_method: str = "naive",
permission: str = "me",
) -> DatasetInfo:
"""创建数据集(知识库)。
Args:
name: 数据集名称
embedding_model: 向量模型
chunk_method: 分块方法(naive/qa/book/laws 等)
permission: 权限(me/team
Returns:
DatasetInfo: 创建的数据集信息
"""
body = {
"name": name,
"embedding_model": embedding_model,
"chunk_method": chunk_method,
"permission": permission,
}
result = await self._request("POST", "/api/v1/datasets", json_data=body)
return DatasetInfo.model_validate(result.get("data", {}))
async def delete_dataset(self, dataset_ids: List[str]) -> bool:
"""删除数据集。
Args:
dataset_ids: 要删除的数据集ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
"/api/v1/datasets",
json_data={"ids": dataset_ids},
)
return True
# ==========================================================================
# 文档管理
# ==========================================================================
async def list_documents(
self,
dataset_id: str,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""列出数据集中的文档。
Args:
dataset_id: 数据集ID
page: 页码
page_size: 每页条数
Returns:
Dict: {items: List[DocumentInfo], total: int}
"""
result = await self._request(
"GET",
f"/api/v1/datasets/{dataset_id}/documents",
params={"page": page, "page_size": page_size},
)
data = result.get("data", {})
items = [
DocumentInfo.model_validate(doc)
for doc in data.get("documents", [])
]
return {"items": items, "total": data.get("total", 0)}
async def upload_document(
self,
dataset_id: str,
file_path: str,
file_name: Optional[str] = None,
) -> DocumentInfo:
"""上传文档到数据集。
Args:
dataset_id: 数据集ID
file_path: 本地文件路径
file_name: 文件名(可选,默认取 file_path 的文件名)
Returns:
DocumentInfo: 上传的文档信息
"""
import os
if not os.path.exists(file_path):
raise RagflowError(f"文件不存在: {file_path}")
fname = file_name or os.path.basename(file_path)
url = f"{self.base_url}/api/v1/datasets/{dataset_id}/documents"
try:
async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f:
response = await client.post(
url=url,
headers={"Authorization": f"Bearer {self.api_key}"},
files={"file": (fname, f)},
timeout=60.0,
)
if response.status_code == 401:
raise RagflowAuthError()
result = response.json()
if result.get("code") != 0:
raise RagflowApiError(
code=result.get("code", -1),
message=result.get("message", "上传失败"),
)
docs = result.get("data", {}).get("documents", [])
if docs:
return DocumentInfo.model_validate(docs[0])
return DocumentInfo(name=fname)
except (RagflowAuthError, RagflowApiError):
raise
except Exception as e:
raise RagflowError(f"文档上传失败: {str(e)}")
async def delete_documents(
self,
dataset_id: str,
document_ids: List[str],
) -> bool:
"""删除文档。
Args:
dataset_id: 数据集ID
document_ids: 要删除的文档ID列表
Returns:
bool: 是否成功
"""
await self._request(
"DELETE",
f"/api/v1/datasets/{dataset_id}/documents",
json_data={"ids": document_ids},
)
return True
@@ -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)
-110
View File
@@ -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智能服务台 — 内容审核服务
# =============================================================================
# 说明:#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
-237
View File
@@ -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": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
}
-454
View File
@@ -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"
)
# 构建请求 URLbase_url + /v1/chat-messagesDify 原生 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}")
-903
View File
@@ -1,903 +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 asyncio
import logging
import os
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import select
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.vision_service import VisionService
from app.services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__)
# =============================================================================
# Phase 4A: VisionService 接入 — 图片消息视觉理解
# =============================================================================
# 图片文件本地存储根目录(与 upload.py 中 UPLOAD_DIR 一致)
_UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
# 视觉理解置信度阈值:低于此值不注入描述(避免错误描述误导 AI)
_VISION_CONFIDENCE_THRESHOLD = 0.6
def _media_url_to_local_path(media_url: str) -> Path:
"""将媒体 URL 路径转换为本地文件系统路径。
做什么:把 "/api/media/2026/07/13/abc.png" 转换为
"./uploads/2026/07/13/abc.png"
为什么:VisionService 需要读取原始图片字节流,
而媒体 URL 是 HTTP 访问路径,不是文件系统路径。
Args:
media_url: 媒体文件 URL(如 /api/media/2026/07/13/abc.png
Returns:
Path: 本地文件路径对象
"""
# 去掉 URL 前缀 /api/media/,拼接到 UPLOAD_DIR
# 例: "/api/media/2026/07/13/abc.png" → "2026/07/13/abc.png"
relative = media_url.replace("/api/media/", "", 1)
return _UPLOAD_DIR / relative
async def _fetch_recent_employee_text(
db, conversation_id: str, employee_id: str, within_seconds: int = 5
) -> str:
"""获取最近 N 秒内员工的文字消息(Phase 4B 消息融合)。
做什么:查询同一会话中,当前图片消息之前 within_seconds 秒内,
员工发送的文本消息内容。
为什么:用户经常先打字描述问题再发截图,或先发截图再补充文字。
将文字与图片视觉描述融合后一次性传给 Dify,
避免 AI 分别处理两条消息导致上下文割裂。
Args:
db: 异步 DB session
conversation_id: 会话 ID
employee_id: 员工企微 UserID
within_seconds: 时间窗口(秒),默认 5 秒
Returns:
str: 最近的员工文字消息内容(多条用换行拼接),无则返回空字符串
"""
cutoff = datetime.now() - timedelta(seconds=within_seconds)
stmt = (
select(Message)
.where(
Message.conversation_id == conversation_id,
Message.sender_type == "employee",
Message.sender_id == employee_id,
Message.msg_type == "text",
Message.created_at >= cutoff,
)
.order_by(Message.created_at.desc())
.limit(3) # 最多取 3 条,避免内容过长
)
result = await db.execute(stmt)
messages = result.scalars().all()
if not messages:
return ""
# 按时间正序拼接(先发的在前)
texts = [m.content for m in reversed(messages) if m.content]
return "\n".join(texts)
async def _enrich_image_content(
db,
media_url: str,
original_content: str,
conversation_id: str,
employee_id: str,
) -> str:
"""用 VisionService 分析图片,生成增强后的消息内容。
做什么:
1. 从本地文件系统读取图片
2. 调用 VisionService.analyze_screenshot() 获取视觉描述
3. 查询最近 5 秒内的员工文字消息(消息融合)
4. 拼接视觉描述 + 用户文字 → 传给 Dify
为什么:Dify 文本模型无法直接""图片,需要先将图片转为
文字描述,再与用户输入融合后传给 Dify 推理。
降级策略:
- 图片文件不存在 → 返回原始 content
- VisionService 调用失败 → 返回 "我收到了您的截图,但暂时无法识别内容"
- 置信度 < 0.6 → 不注入视觉描述,仅使用用户文字
Args:
db: 异步 DB session
media_url: 图片 URL(如 /api/media/2026/07/13/abc.png
original_content: 原始消息内容(如 "[图片] 截图"
conversation_id: 会话 ID
employee_id: 员工企微 UserID
Returns:
str: 增强后的消息内容(视觉描述 + 用户文字)
"""
# 1. 读取本地图片文件
local_path = _media_url_to_local_path(media_url)
if not local_path.exists():
logger.warning(f"图片文件不存在: {local_path} (media_url={media_url})")
return original_content
try:
image_bytes = local_path.read_bytes()
except Exception as e:
logger.error(f"读取图片文件失败: {local_path} - {e}")
return original_content
# 2. 调用 VisionService 分析截图
vision_service = VisionService()
try:
result = await vision_service.analyze_screenshot(
image_bytes, conversation_id
)
description = result.get("description", "")
confidence = result.get("confidence", 0.0)
logger.info(
f"VisionService 分析完成: conversation={conversation_id}, "
f"confidence={confidence:.2f}, desc_len={len(description)}"
)
# 3. 注入视觉描述到会话上下文(供后续多轮对话使用)
if description and confidence >= _VISION_CONFIDENCE_THRESHOLD:
await vision_service.inject_to_conversation_context(
description, conversation_id
)
except Exception as e:
logger.error(f"VisionService 调用异常: {e}")
description = ""
confidence = 0.0
finally:
await vision_service.close()
# 4. 消息融合:查询最近 5 秒内员工的文字消息
recent_text = await _fetch_recent_employee_text(
db, conversation_id, employee_id, within_seconds=5
)
# 5. 拼接增强内容
# 格式:[视觉描述] + [用户最近文字] + [原始消息内容]
parts = []
if description and confidence >= _VISION_CONFIDENCE_THRESHOLD:
parts.append(f"[用户发送了截图,视觉理解结果] {description}")
if recent_text:
parts.append(f"[用户最近的文字描述] {recent_text}")
# 原始内容如果不是纯占位符(如"[图片] 截图"),也加入
if original_content and not original_content.startswith("[图片]"):
parts.append(original_content)
if not parts:
# 降级:视觉分析失败且无文字补充
return "我收到了您的截图,但暂时无法识别内容,请描述一下您遇到的问题。"
return "\n".join(parts)
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 _persist_and_push_structured(
db,
conversation: Conversation,
employee_id: str,
result: dict,
):
"""持久化结构化 AI 回复并推送给员工端 + 广播坐席端(v2.0 双 WS 通道)。
改造后的核心变化(2026-07-13):
- Dify 返回 JSON {text, action, options},后端解析后同时发两条 WS:
① ai_reply → 聊天气泡(text + options
② dynamic_recommend → 侧边栏推荐(action 卡片)
- 两条消息同一时刻发出,零时间差到达
- 文字明确引用侧边栏内容(如"右侧已为您准备好入口"),语义强关联
命中判断规则:
- 结构化回复且有 action 或 options → 视为命中(AI 在主动引导)
- 纯文本回复 → 走原有 _check_knowledge_hit 判断
Args:
db: 异步 DB session
conversation: 当前会话对象
employee_id: 员工企微 UserID
result: get_structured_reply() 返回的结构化结果
"""
text = result.get("text", "")
action = result.get("action")
options = result.get("options")
hit = result.get("hit", False)
is_structured = result.get("is_structured", False)
dify_conv_id = result.get("conversation_id")
# Phase 6A: 提取诊断阶段
diagnosis_stage = result.get("diagnosis_stage")
# 结构化回复且有 action 或 options → 视为命中(AI 在主动引导/推荐)
if is_structured and (action or options):
hit = True
# Phase 6A: 基于 diagnosis_stage 调整会话状态
# escalating → AI 建议转人工
# resolved → AI 认为问题已解决
if diagnosis_stage == "escalating":
hit = False # 不计为有效回复,触发转人工
elif diagnosis_stage == "resolved":
hit = True # 计为有效回复
should_count = hit
should_transfer = not hit
# 确定消息类型
if is_structured and (options or action):
msg_type = "ai_structured"
else:
msg_type = "text"
# 构建 extra_data(存储 options 和 action 供前端渲染)
extra_data = {}
if options:
extra_data["options"] = options
if action:
extra_data["action"] = action
# 1. 存 AI 消息
ai_message = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=text,
msg_type=msg_type,
extra_data=extra_data if extra_data else None,
is_read=True,
)
db.add(ai_message)
await db.flush()
# 2. 更新会话状态
if dify_conv_id:
conversation.dify_conversation_id = dify_conv_id
if should_count:
conversation.ai_substantive_reply_count += 1
if should_transfer:
conversation.status = "queued"
# Phase 6A: 将 diagnosis_stage 存入 tags(无需迁移,利用现有 JSON 字段)
if diagnosis_stage:
tags = conversation.tags or {}
tags["diagnosis_stage"] = diagnosis_stage
tags["diagnosis_updated_at"] = datetime.now().isoformat()
conversation.tags = tags
conversation.updated_at = datetime.now()
db.add(conversation)
await db.flush()
await db.commit()
# 3. 推 ai_reply 给员工端(聊天气泡:text + options
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": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
"diagnosis_stage": diagnosis_stage,
},
})
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空时)
# 与 ai_reply 同一时刻发出 → 零时间差到达
if action:
recommend_data = {
"recommend_id": f"rec_{ai_message.id}",
"card_type": action.get("type", "approval_card"),
"title": action.get("title", ""),
"description": action.get("description", ""),
"approval_type": action.get("approval_type"),
"confidence": action.get("confidence", 0.85),
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
}
await ws_manager.broadcast_to_employees([employee_id], {
"type": "dynamic_recommend",
"data": recommend_data,
})
logger.info(
f"动态推荐已推送: employee={employee_id}, "
f"card_type={recommend_data['card_type']}, "
f"title={recommend_data['title']}"
)
# 5. 广播坐席端(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": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
},
})
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"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 sessionprocess_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 sessionprocess_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,
msg_type: str = "text",
media_url: str = None,
):
"""H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。
v2.0 改造(2026-07-13):
- AI 回复从流式 SSE 改为 blocking + JSON 结构化输出
- Dify 返回 {text, action, options} JSON → 后端解析 → 双 WS 推送
- 聊天气泡收到 ai_replytext + options),侧边栏收到 dynamic_recommendaction
- 新增 ai_thinking 指示器,用户发送后立即看到"正在思考..."
v2.1 改造(2026-07-13 Phase 4):
- 新增图片消息处理分支(msg_type=image
- 图片 → VisionService.analyze_screenshot() → 视觉描述 → 融合到用户文字
- 消息融合:查询最近 5 秒内员工的文字消息,与图片描述合并后传给 Dify
- 降级:VisionService 失败/低置信度 → 使用原始文字或提示用户描述问题
流程:
1. BYOD 关键词拦截 → byod_card 卡片
2. 路由关键词拦截 → 名片推荐
3. 本地快判断(打招呼/呼叫人工)→ 同步引导
4. ★ 图片消息处理(Phase 4A)→ VisionService 分析 → 内容增强
5. ★ 结构化 AI 回复(blocking + JSON 解析 + 双 WS 推送)
6. 任意异常 → 推 ai_reply_failed
Args:
conversation_id: 会话 ID
employee_id: 员工企微 UserID
content: 消息文本内容
dify_conversation_id: Dify 会话 ID(用于多轮上下文)
msg_type: 消息类型(text/image/file),默认 text
media_url: 媒体文件 URL(图片消息时使用)
"""
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
# === BYOD 关键词拦截(仅文本消息)===
# 图片消息的 content 是占位符(如 "[图片] 截图"),跳过关键词拦截
if msg_type == "text" and _byod_keyword_prefilter(content):
await _handle_byod_query(db, conversation, employee_id, content)
return
# === 业务路由检测(仅文本消息)===
if msg_type == "text" and routing_keyword_prefilter(content):
routed = await _handle_routing(db, conversation, employee_id, content)
if routed:
return
# === 本地快判断:打招呼 / 呼叫人工(仅文本消息)===
if msg_type == "text" and (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
# === ★ v2.1 图片消息处理(Phase 4A/4B===
# 做什么:检测到图片消息 → 调用 VisionService 分析截图 →
# 将视觉描述与用户文字融合 → 传给 Dify 推理
# 为什么:Dify 文本模型无法"看"图片,需要先将图片转为文字描述
# 降级:VisionService 失败 → 使用原始 content 继续流程
enriched_content = content # 默认使用原始内容
if msg_type == "image" and media_url:
logger.info(
f"图片消息检测: conversation={conversation_id}, "
f"media_url={media_url}"
)
try:
enriched_content = await _enrich_image_content(
db=db,
media_url=media_url,
original_content=content,
conversation_id=conversation_id,
employee_id=employee_id,
)
logger.info(
f"图片内容增强完成: original_len={len(content)}, "
f"enriched_len={len(enriched_content)}"
)
except Exception as vision_err:
logger.error(
f"VisionService 处理失败,降级为纯文本: {vision_err}"
)
# 降级:使用原始 contentAI 会收到 "[图片] 截图" 这样的占位符
# Dify 会回复"我收到了您的截图,请描述一下问题"
# === ★ v2.0 结构化 AI 回复(替代流式)===
# 1. 立即推送 "正在思考..." 指示器
# 同时推给员工(气泡动画)和坐席(状态指示)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
},
})
# 坐席端也通知:AI 正在处理此会话的消息
try:
await ws_manager.broadcast({
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
"employee_id": employee_id,
},
})
except Exception:
pass # 坐席端通知失败不影响主流程
# 2. 启动延迟 "仍在思考" 后台任务(15 秒后触发)
# 如果 Dify 在 15 秒内返回,此任务会被取消
async def _push_still_thinking():
"""15 秒后推送 "仍在思考" 提示,缓解用户等待焦虑。"""
await asyncio.sleep(15)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
"status": "still_thinking",
},
})
thinking_task = asyncio.create_task(_push_still_thinking())
# 3. 调用 Difyblocking 模式 + JSON 解析 + 30 秒硬超时)
# get_structured_reply 内部处理 HTTP 错误和 JSON 解析失败
# asyncio.wait_for 处理 30 秒硬超时 → 建议转人工
# 注意:图片消息使用 enriched_content(视觉描述+用户文字融合)
try:
result = await asyncio.wait_for(
ai_handler.ai_service.get_structured_reply(
message=enriched_content,
conversation_id=dify_conversation_id,
user_id=employee_id,
),
timeout=30,
)
except asyncio.TimeoutError:
# 30 秒硬超时 → 建议转人工
thinking_task.cancel()
logger.warning(f"Dify 30 秒超时: conversation={conversation_id}")
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",
"data": {
"conversation_id": conversation_id,
"message": "AI 响应时间较长,建议转人工坐席处理。",
},
})
return
# 4. 取消 "仍在思考" 任务(Dify 已返回)
thinking_task.cancel()
try:
await thinking_task # 等待 task 真正取消,避免 warning
except asyncio.CancelledError:
pass
# 5. 持久化 + 双 WS 推送(ai_reply + dynamic_recommend
await _persist_and_push_structured(
db, conversation, employee_id, result,
)
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 服务异常,请转人工坐席或稍后重试。",
},
})
except Exception:
# 推送失败也无所谓,员工端 3 秒轮询兜底
pass
-29
View File
@@ -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()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-84
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

BIN
View File
Binary file not shown.
+68
View File
@@ -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/
+249
View File
@@ -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/
+1 -1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+20
View File
@@ -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);
+3 -6
View File
@@ -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"
+1 -2
View File
@@ -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
-1
View File
@@ -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
+1 -2
View File
@@ -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
+2 -6
View File
@@ -145,11 +145,7 @@ http {
index index.html;
try_files $uri /itadmin/index.html;
}
location /itportal/ {
alias /usr/share/nginx/html/itportal/;
index index.html;
try_files $uri /itportal/index.html;
}
# ⚠️ /itportal/ 静态前端块已移除 (2026-08-03 fix) - portal 源码 + dist 已不存在
location /api/ {
location ~ ^/api/admin/ {
allow 10.0.0.0/8;
@@ -210,7 +206,7 @@ http {
proxy_read_timeout 300s;
}
location = / {
return 302 /itportal/;
return 302 /itdesk/;
}
}
}
+3 -8
View File
@@ -67,12 +67,7 @@ http {
alias /usr/share/nginx/html/itadmin/;
try_files $uri /itadmin/index.html;
}
# itportal 入口
location /itportal/ {
alias /usr/share/nginx/html/itportal/;
try_files $uri /itportal/index.html;
}
# ⚠️ /itportal/ 静态前端块已移除 (2026-08-03 fix) - portal 源码 + dist 已不存在
# API — Green 后端
location /api/ {
@@ -120,9 +115,9 @@ http {
proxy_read_timeout 86400s;
}
# 根路径重定向
# 根路径重定向2026-08-03 fix: /itportal/ → /itdesk/,因 portal 已下线)
location = / {
return 302 /itportal/;
return 302 /itdesk/;
}
}
}

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