Compare commits

...

52 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
Simon 5a77a89ab1 Merge remote-tracking branch 'origin/main' into feature/message-reliability 2026-07-13 02:18:48 +08:00
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
2026-07-13 02:17:03 +08:00
Simon 4052e19ff8 Merge branch 'feature/message-reliability' 2026-07-09 09:28:05 +08:00
Simon 6db1c0eef0 fix(ws): echo WebSocket subprotocol on accept to fix browser handshake
后端 ws_manager.connect/connect_employee 与 ws.py 调用未回显客户端协商的 Sec-WebSocket-Protocol (bearer.{token}),导致浏览器以 'Sent non-empty Sec-WebSocket-Protocol header but no response' 拒绝握手,H5/坐席 WS 实际一直走 3s 轮询兜底而非流式打字机。现回显 subprotocol 修复握手。验证:dev 真实浏览器 E2E 343 帧 typewriter;生产 docker cp 部署后日志显示 H5/坐席 WS 连接 [accepted] 且 连接建立。
2026-07-09 09:18:17 +08:00
1883 changed files with 179799 additions and 30995 deletions
+53
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 不入仓
@@ -233,3 +239,50 @@ backend/scripts/create_test_agent.py
# 补充忽略 (2026-07-09 WIP 提交): 新增构建产物
dist-deploy/
dist-v2/
# 补充忽略 (2026-07-13): 临时目录 / 备份 / 截图
.workbuddy/tmp/
.workbuddy/automations/
deploy-staging-ki/
dist-old-*/
dist_deploy/
screenshots/
test-screenshots/
tools/
chat_export/
deliverables/
02meiti/
data/
# 补充忽略 (2026-08-03 仓库重组): 历史 dist 备份 + node_modules_old
# 这些是早期部署流程误将 dist 目录 commit 的残留,每个 50MB+,必须不入仓
dist.bak/
dist.bak.*/
dist.old/
dist_bak*/
dist-clean/
dist_bak_*/
dist_old/
node_modules_old/
# 补充忽略 (2026-08-03 仓库重组): 用户运行时上传的二进制文件(src/ 前缀)
# 原 .gitignore 只有 backend/media/* 规则,重组后需补充 src/backend/* 对应规则
src/backend/uploads/
src/backend/media/
# 补充忽略 (2026-08-03 仓库重组): 前端部署脚本生成的 bin chunk
# agent.p[0-9].bin (坐席端部署脚本产物) + h5-v4-part[0-9].bin (H5端部署脚本产物)
*-part*.bin
p[0-9].bin
*.p[0-9].bin
# 补充忽略 (2026-08-03 仓库重组): 前端部署脚本生成的 part 拆分文件
# 部署脚本将大 tar/zip 拆分为 part0/part1/part2 上传 (变体多, 通配匹配)
*.part*
# 补充忽略 (2026-08-03 收尾): archives/ 临时备份 + 02meiti hilo 应用数据
# archives/ 是早期未跟踪的临时备份目录(85 个文件)
# 02meiti/.hilo/ 是 hilo 多媒体应用数据,原 .gitignore 254 行 02meiti/ 已覆盖,
# 但之前有 3 个文件被误加入 index (index.sqlite-shm/wal/storage.json),已 git rm --cached
archives/
02meiti/.hilo/
@@ -1,5 +1,184 @@
# 早班巡检自动化 - 执行记录
## 2026-07-18 09:30 执行结果
**数据来源**:主文档第四章 v2.8 (2026-07-14) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 上次巡检记忆 (07-17)
**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第10次不存在。独立看板v1.0已更新(#76/#82 07-17入完成区),但主文档第四章未同步
### 关键发现
1. **P0待办2项**#81敏感词检测+语气优化(阻塞14天,约07-21到期需启动)、#104结构化日志查看页(无阻塞已4天未启动);#117 Neo4j已完成但仍列P0区未清理(数据不一致持续2次巡检)
2. **P1待办3项**#80坐席图片预览(数据不一致持续2次——已完成区07-16 vs P1清单仍列"待排查")、#73后端文件覆盖#86流程图review
3. **等用户决策2项,均超3天阈值**:企微会议室Secret(自07-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`
**说明**:指定看板路径仍不存在,状态看板在主文档第四章;看板版本滞后2天,07-11/07-12大量产出未入看板
### 关键发现
1. **P0阻塞2项持续未推进**#48 IP白名单收窄(阻塞≈29天,自06-13)、#81 敏感词检测(阻塞≈8天,自07-04)— 均>3天,需PM立即关注
2. **#105数据不一致持续3次巡检**:已完成区+P1清单双重列出,07-04/07-10/07-11三次巡检指出至今未修正
3. **07-11/07-12大量产出未入看板**:代办事项集成(8bug修复链)、IT资产审批推送、语音识别、截图拍照、复杂场景重构、会议室预定系统(全栈部署)、知识库迭代3功能(44文件43测试通过)、知识迭代3Bug修复、坐席v9/v10部署修复、AI辅助消息框+布局优化技术设计文档
4. **进行中2项**#91 忘记密码 + #107 后端卷挂载改造(后者07-11已恢复卷挂载,可能已完成需确认)
5. **等用户决策5项**:企微会议室Secret未申请、ITSM API授权待申请、ITSM代办API待抓包、布局优化v2.0的8个待明确事项、知识库迭代待确认
6. **看板版本严重滞后**v2.4截止07-1007-11全日+07-12产出均未入看板
### 全局状态
- P0待办:3项(2项长期阻塞)
- P1待办:5项(1项#105已完成未清理1项#75可能已完成
- 等决策:5项
- 进行中:2项(1项可能已完成)
### PM行动项
1. 联系网络组确认代理IP段(#48阻塞29天)⚠️紧急
2. 确认敏感词库来源/语气优化范围(#81阻塞8天)⚠️紧急
3. 从P1清单移除#105(连续3次巡检指出)⚠️数据质量
4. 确认#75头像同步是否已完成07-08已交付12/12测试)
5. 确认#107卷挂载改造是否已完成07-11已恢复卷挂载)
6. 企微管理后台申请会议室Secret
7. 向ITSM平台方申请app_id/app_secret
8. 确认布局优化v2.0的8个待明确事项
9. 更新看板至v2.5+,将07-11/07-12产出纳入已完成区
---
## 2026-07-11 09:30 执行结果
**数据来源**`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md`
@@ -59,10 +238,10 @@
## 2026-07-04 09:30 执行结果
**数据来源**`.taskboard-cache/任务执行状态看板_cache.json`(缓存时间 2026-07-03T08:44:12
**⚠️ 原始看板文件缺失**`docs/小组任务/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析
**⚠️ 原始看板文件缺失**`docs/小组任务/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析
### 关键发现
1. **看板源文件丢失**`docs/小组任务/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失
1. **看板源文件丢失**`docs/小组任务/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失
2. **B-T10双重可激活信号**:①依赖B-T8已完成(33/33 PASS) ②REVIEW_B_T10.md显示代码评审已于07-03通过(IS_PASS: YES),但缓存中仍为⏳等待中
3. **3个阻塞已逾期2天**BLOCK-17(企微SSO)、BLOCK-19(扫码登录超时)、BLOCK-20(管理后台Network Error) 均 due 07-02,现已逾期2天
4. **BLOCK-18状态矛盾持续**B-T17标记🟢已修复,但BLOCK-18阻塞表仍为🔵排查中(07-03已发现,至今未修正)
@@ -76,7 +255,7 @@
- 整体:39/55 (71%) — 若计入🟢则41/55 (75%)
### PM行动项
1. **恢复看板源文件**`docs/小组任务/任务执行状态看板.md` 缺失,需重建
1. **恢复看板源文件**`docs/小组任务/任务执行状态看板.md` 缺失,需重建
2. 通知B组激活B-T10(依赖已完成 + 评审已通过)
3. 优先解决3个逾期阻塞(BLOCK-17/19/20),已逾期2天
4. 确认BLOCK-18/B-T17真实状态并校正看板
-77
View File
@@ -1,77 +0,0 @@
# IT智能服务台 - 项目记忆
## 设计决策(锁定)
- AI交互:小段多回合;术语:"人工"=用户呼叫坐席,"摇人"=坐席呼叫坐席
- 原型:坐席v5.3 + H5 v1.1UI:企微浅色扁平,accent=#07C160
- 统一入口 `/itportal/` → user/agent/adminadmin需OTP
## 技术架构
- 前端:坐席(Vue3+Element Plus) / H5(Vue3+Vant4) / 管理后台(Vue3+Element+Tailwind)
- 后端:FastAPI + SQLAlchemy + PostgreSQL + Redis(代码在 `app/`
- 字段映射:后端`id`/`sender_type` → 前端`message_id`/`message_type``conversation.ts``mapMessage()`
- WS双连接池:`active_connections`(agent) + `employee_connections`(H5)
## 部署
- 正式服务器:itsupport.servyou.com.cn (10.90.5.110),出口IP `218.75.34.87`
- 堡垒机:sxn@10.212.189.210:2222 (OTP),脚本 `C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py`
- 后端卷挂载:`./app:/app/app``.py`变更→`docker compose restart backend`env变更→`up -d backend`
- 前端:rm旧→tar解压dist/→`restart nginx`Nginx: H5→`/html/h5` / Agent→`/html/itagent` / Admin→`/html/itadmin`
- Docker bind mount铁律:rm后重建必须重启容器
- 部署工具:`jms_ops.py pack-upload <local_dir> <remote_dir>`(≥100KB用此)
- httpx.Timeout须含default`httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)`
## 外部集成
- 企微通讯录:Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8`Redis key=`wecom:contact_access_token`
- Dify`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`
- RAGFlow:生产 `http://10.80.0.85:8080/` / API `:9380`
- 映射策略:联软(主) > aTrust(VPN) > eHR(静态)
## 群聊系统
- 摇人(`collaborating_agent_ids`) / 邀请(`participants`);四角色权限
- 参与者展开/缩略双模式;头像代理 `/api/avatar/proxy` 解决 COEP/CSP
## 审批流程系统(2026-07-10上线)
- 12种审批类型 / 18个流程;意图识别三级链路:关键词→Dify→降级
- 卡片导航用 `window.location.href`(同窗口)
## 代办事项真实数据源集成(2026-07-11部署+修复完成)
- **PRD**`docs/02-产品需求/prd_todo_integration.md`
- **后端**`todo_source_service.py` / `itsm_service.py` / `todo_aggregator_service.py`(Redis缓存45s)
- **前端**`TodoPanel.vue` / `todo.ts`(API+Store)
- **企微审批API**`getapprovalinfo`(旧`getapprovaldata`已废弃404)→ Semaphore(10)并发`getapprovaldetail` → 代码层过滤template_id
- **Token方案**:统一用 `TokenManager`(IT支持应用Secret,IP已在白名单)
- **缓存策略**:初始+60s自动=用缓存(无_force) / 手动🔄=跳过缓存(_force=1) / TTL=45s
- **8个Bug修复链**env未注入→Redis密码→bind mount丢失→API废弃→token decode→IP白名单60020→filter参数301025→**_extract_current_approver字段名全错**
- **企微API实际字段名**(dump确认):`sp_record[].sp_status`(非status/ `details[].approver.userid`(非approver[].userid
- **验证**:sxn名下2条审批单正确返回(IT资产外修申请)
- **ITSM**:工单详情API已实现,代办列表API待抓包;签名 app_id+app_secret+SHA1
- **测试**40/40通过
## IT资产升级审批推送(待部署)
- 模板ID`Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS`
- 新建 `asset_service.py`;修改 `approval.py`(+urge端点) / `config.py`(+asset_excel_path)
- 资产Excel12月度sheet,编码列(2)匹配,日期列(41)算年限
## 语音识别转文字(2026-07-11部署)
- 手机企微=JS-SDK / PC企微=百度ASR(AppID=123947532) / Mac=隐藏
- H5内联录音:🎤→⏹→⏳(disabled)
## 坐席端截图+拍照(2026-07-11部署)
- 截图编辑器(马赛克/文字/箭头/矩形/撤销);拍照(getUserMedia→兜底input capture)
- 铁律:`.selection-box`必须`pointer-events:none`;文字工具CSS类切换非v-if`getDisplayMedia()`必须同步调用
## 复杂场景重构(2026-07-11部署)
- P0~P3:任务中断恢复/信息更正/上下文压缩/多轮纠错
- 路由前缀`/itportal/automation/`;迁移链修复041→028_merge_heads
## 知识库迭代(待确认)
- 技术方案:`docs/03-技术架构/增量设计-知识库迭代与痛点缓解-20260711.md`
- 原型图:`docs/01-产品设计/知识库迭代-未实现功能原型设计-20260711.md`
## 近期修复 (2026-07)
- H5非企微环境→扫码登录页;Vue版本不一致白屏→重装+`ElMessage._context`
- 消息重复/头像COEP/OAuth残留→已修
## 运维工具
- SOP`docs/10-项目管理/IT智能服务台-标准作业流程SOP.md`
- 故障排查:`docs/09-部署运维/00-标准故障排查手册.md`
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}
-331
View File
@@ -1,331 +0,0 @@
# =============================================================================
# 企微IT智能服务台 — AI 服务(Dify 接入)
# =============================================================================
# 做什么:封装 Dify API 调用,实现 AI 自动回复
# 为什么:
# - ARCHITECTURE.md 设计了 ai_handling 状态,但当前未实现
# - 现有系统交接文档提供了 Dify API 地址和 Key
# - 这是实现「AI 自助解决」的核心模块
# 依赖:需要 Dify API 可达(生产环境 http://yw-dify.dc.servyou-it.com
# =============================================================================
import json
import logging
import asyncio
from typing import Any, Dict, List, Optional, AsyncGenerator
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class AIService:
"""AI 服务:封装 Dify API,提供 AI 回复能力。
支持两种调用模式:
1. 非流式(简单场景):一次性获取完整回复
2. 流式(推荐):SSE 流式返回,前端可逐字显示
参考:现有系统交接文档
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
"""
def __init__(self):
"""初始化 AI 服务。
做什么:从配置读取 Dify API 地址和认证信息
为什么:集中管理 API 配置,便于切换测试/生产环境
"""
# Dify 兼容 OpenAI 格式的 API 端点
self.api_url = settings.dify_api_url
# Dify API Key(格式:base_url|app_id|app_name
self.api_key = settings.dify_api_key
# 请求超时(秒)
self.timeout = settings.dify_timeout
# httpx 异步客户端(复用连接池)
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端。
做什么:懒加载 httpx.AsyncClient,复用连接池
为什么:避免每次请求都创建新连接,提升性能
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._client
async def close(self):
"""关闭 httpx 客户端。
做什么:释放连接池资源
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("AIService httpx client closed")
# --------------------------------------------------------------------------
# 非流式调用:一次性获取 AI 完整回复
# --------------------------------------------------------------------------
async def get_reply(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""调用 Dify API 获取 AI 回复(非流式)。
Args:
message: 员工发送的消息内容
conversation_id: 会话ID(用于 Dify 多轮对话上下文)
user_id: 员工企微 UserID(用于 Dify 用户标识)
Returns:
Dict: {
"content": str, # AI 回复内容
"hit": bool, # 是否命中知识库(可回复)
"conversation_id": str, # Dify 会话ID(用于后续多轮对话)
"usage": dict, # Token 用量(可选)
}
做什么:发送消息到 Dify,解析返回内容,判断是否能回复
为什么:
- 非流式适合简单场景,代码简单
- 返回结构兼容 OpenAI Chat Completions 格式
- 通过回复内容判断是否命中知识库(有实质内容 = 命中)
"""
payload = {
"model": "Chat", # Dify 应用名称(来自 API Key 格式)
"messages": [
{"role": "user", "content": message}
],
"stream": False, # 非流式
"temperature": 0.1, # 低温度,保证回答稳定性
}
# 传入 Dify 会话ID,保持多轮对话上下文
if conversation_id:
payload["conversation_id"] = conversation_id
# 传入用户标识(Dify 侧用于日志和追溯)
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
logger.info(f"调用 Dify API: message={message[:50]}...")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# 解析 OpenAI 兼容格式的返回
# 格式:{"choices": [{"message": {"content": "..."}}]}
choices = data.get("choices", [])
if not choices:
logger.warning("Dify API 返回空 choices")
return {
"content": "",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
reply_content = choices[0]["message"]["content"]
# 判断是否命中知识库:
# 策略1:检查内容是否为空或过长(Dify 可能返回提示语)
# 策略2:检查是否包含「抱歉」「不知道」等无法回答的特征词
hit = self._check_knowledge_hit(reply_content)
# 提取 Dify 返回的 conversation_id(用于多轮对话)
dify_conv_id = data.get("conversation_id", conversation_id or "")
logger.info(
f"Dify API 返回: hit={hit}, "
f"content_length={len(reply_content)}, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
return {
"content": reply_content,
"hit": hit,
"conversation_id": dify_conv_id,
"usage": data.get("usage", {}),
}
except httpx.TimeoutException:
logger.error("Dify API 超时")
return {
"content": "⏰ AI 服务响应超时,请稍后再试或输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except httpx.HTTPStatusError as e:
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
return {
"content": "⚠️ AI 服务暂时不可用,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except Exception as e:
logger.error(f"Dify API 调用失败: {e}")
return {
"content": "⚠️ AI 服务异常,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
# --------------------------------------------------------------------------
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
# --------------------------------------------------------------------------
async def get_reply_stream(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。
Yields:
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
实现:
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
- 流结束后用完整内容整体判断 hit_check_knowledge_hit
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达"
功能不破,仅无逐字动画)。
"""
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": message}],
"stream": True,
"temperature": 0.1,
}
if conversation_id:
payload["conversation_id"] = conversation_id
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
full_parts: list = []
dify_conv_id = conversation_id or ""
async with client.stream("POST", self.api_url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
# OpenAI / Dify SSE 格式:choices[0].delta.content
try:
delta = chunk["choices"][0]["delta"].get("content", "")
except (KeyError, IndexError, TypeError):
delta = ""
if delta:
full_parts.append(delta)
yield {
"delta": delta,
"finished": False,
"conversation_id": dify_conv_id,
"hit": None,
}
# Dify 可能在流式块里给出 conversation_id
cid = chunk.get("conversation_id")
if cid:
dify_conv_id = cid
# 流结束:用完整内容判断命中
full_content = "".join(full_parts)
hit = self._check_knowledge_hit(full_content) if full_content else False
yield {
"delta": "",
"finished": True,
"conversation_id": dify_conv_id,
"hit": hit,
}
except Exception as e:
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
logger.warning(f"Dify 流式失败,回退非流式: {e}")
try:
result = await self.get_reply(message, conversation_id, user_id)
yield {
"delta": result["content"],
"finished": True,
"conversation_id": result["conversation_id"],
"hit": result["hit"],
}
except Exception as e2:
logger.error(f"Dify 流式与非流式均失败: {e2}")
yield {
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
"finished": True,
"conversation_id": conversation_id or "",
"hit": False,
}
# --------------------------------------------------------------------------
# 判断是否命中知识库
# --------------------------------------------------------------------------
def _check_knowledge_hit(self, content: str) -> bool:
"""判断 AI 回复是否命中知识库(可以回答用户问题)。
Args:
content: AI 回复内容
Returns:
bool: True=命中(可以回复),False=未命中(需转人工)
做什么:分析 AI 回复内容,判断是否能有效回答问题
为什么:
- Dify 在无法回答时通常会返回固定提示语
- 参考现有系统:「抱歉,您的问题可能不在服务业务范围内」
- 命中 = 有实质内容且不像是「无法回答」的提示
"""
if not content or len(content.strip()) < 5:
return False
# 未命中特征词(Dify 无法回答时的典型回复)
miss_keywords = [
"抱歉", "对不起", "不知道", "无法回答",
"不在服务范围内", "超出我的能力", "暂不支持",
"请转人工", "联系管理员",
]
content_lower = content.lower()
# 如果回复中包含多个未命中特征词 → 判断为未命中
miss_count = sum(1 for kw in miss_keywords if kw in content_lower)
if miss_count >= 2:
return False
# 如果回复长度过短(< 10 字符)且包含特征词 → 未命中
if len(content) < 10 and any(kw in content_lower for kw in miss_keywords):
return False
return True
@@ -1,331 +0,0 @@
# =============================================================================
# 企微IT智能服务台 — 内容审核服务
# =============================================================================
# 说明:#81 v0.6.0 内容审核 — 检测敏感词 + 提示坐席优化语气
# 用途:坐席发送消息前自动审核,避免发送违规内容
# 设计:基于 wordfilter 开源库 + 自定义敏感词库
# =============================================================================
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from wordfilter import Wordfilter
import logging
logger = logging.getLogger(__name__)
class ModerationAction(str, Enum):
"""内容审核动作"""
PASS = "pass" # 通过
WARN = "warn" # 警告(允许发送,但标记)
BLOCK = "block" # 阻断(必须修改)
class ModerationCategory(str, Enum):
"""审核分类"""
PROFANITY = "profanity" # 脏话
POLITICS = "politics" # 政治敏感
PORN = "porn" # 色情
AD = "ad" # 广告
PRIVACY = "privacy" # 隐私泄露(身份证/电话)
OTHER = "other" # 其他
@dataclass
class ModerationResult:
"""审核结果"""
action: ModerationAction
category: Optional[ModerationCategory]
matched_words: List[str]
suggestion: str = ""
@property
def is_blocked(self) -> bool:
return self.action == ModerationAction.BLOCK
@property
def is_warned(self) -> bool:
return self.action == ModerationAction.WARN
class ContentModerationService:
"""内容审核服务 — 检测 + 提示。
设计要点:
1. 加载 wordfilter + 自定义敏感词库
2. 提供 3 个级别动作:pass / warn / block
3. 返回命中的敏感词,给前端提示
4. 异步不阻塞消息发送主流程
"""
def __init__(self):
# 初始化 wordfilter(新 API: Wordfilter() 实例,而非 init() 全局)
self.wf = Wordfilter()
# 加载自定义敏感词库(预留,生产环境从配置文件加载)
self.custom_sensitive_words: List[str] = [
# 坐席严禁发送的
"投诉我", # 暗示员工投诉自己
"你爱找谁找谁", # 不当推诿
"自己不会百度吗", # 不当反问
"这点小事", # 轻视员工问题
# 隐私保护(后端检测,前端不知道)
# 实际部署时从 system_config 加载
]
if self.custom_sensitive_words:
self.wf.addWords(self.custom_sensitive_words)
# ==================================================================
# 主入口
# ==================================================================
def moderate(self, text: str) -> ModerationResult:
"""审核文本。
Args:
text: 待审核文本(坐席准备发的消息)
Returns:
ModerationResult: 审核结果
"""
if not text or not text.strip():
return ModerationResult(
action=ModerationAction.PASS,
category=None,
matched_words=[],
)
text = text.strip()
# 1. wordfilter 检测
matched: List[str] = []
if self.wf.blacklisted(text):
# 找出具体哪些词命中
matched = self._extract_matched(text)
if not matched:
return ModerationResult(
action=ModerationAction.PASS,
category=None,
matched_words=[],
)
# 2. 分类(简单规则:有命中就给 warn,后续可分级)
category = self._classify(matched)
# 3. 决定动作(目前策略:命中即 warn,后续可升级 block)
# 后续决策点:是否给某些类(政治/色情)直接 block
action = ModerationAction.WARN
suggestion = self._generate_suggestion(category, matched)
logger.info(
f"[ContentModeration] 检测到敏感词 text={text[:30]}... "
f"matched={matched} category={category}"
)
return ModerationResult(
action=action,
category=category,
matched_words=matched,
suggestion=suggestion,
)
# ==================================================================
# 隐私信息检测(基于正则,跟敏感词无关)
# ==================================================================
def check_privacy_leak(self, text: str) -> List[str]:
"""检测文本是否包含隐私信息(身份证 / 电话 / 银行卡)。
Returns:
命中的隐私字段列表(描述性,如 ["phone", "id_card"])
"""
import re
leaked = []
# 手机号(11位1开头)
# BUGFIX: \b 和 (?<!\w) 对中文均失效(Python3 \w 含中文),
# 改用 (?<!\d) / (?!\d) 检查数字边界——"电话13800138000" 可正确匹配
if re.search(r"(?<!\d)1[3-9]\d{9}(?!\d)", text):
leaked.append("phone")
# 身份证号(18位)
if re.search(r"(?<!\d)\d{17}[\dXx](?!\d)", text):
leaked.append("id_card")
# 银行卡(16-19位连续数字,简单判断)
if re.search(r"(?<!\d)\d{16,19}(?!\d)", text):
leaked.append("bank_card")
# 邮箱(个人邮箱,非公司邮箱)
# 邮箱以 ASCII 字母开头,(?<!\w) 这里可用(前面不会是中文邮箱前缀)
personal_email_pattern = (
r"(?<!\w)[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?!\w)"
)
if re.search(personal_email_pattern, text):
leaked.append("personal_email")
return leaked
# ==================================================================
# 工具方法
# ==================================================================
def _extract_matched(self, text: str) -> List[str]:
"""提取命中的敏感词。"""
# wordfilter 没有直接的 "提取所有命中词" API,只能 replace 看
matched = []
# 遍历自建词库看哪些命中
for word in self.custom_sensitive_words:
if word in text:
matched.append(word)
return matched
def _classify(self, matched: List[str]) -> ModerationCategory:
"""根据命中的词分类。"""
# 简单分类:命中"投诉""爱找谁"等 → profanity
# 后续可扩展
return ModerationCategory.PROFANITY
def _generate_suggestion(
self, category: ModerationCategory, matched: List[str]
) -> str:
"""生成修改建议。"""
suggestions_map = {
ModerationCategory.PROFANITY: (
"建议改为更专业的表达,例如:"
"「我理解您的问题,我们一起想办法解决」"
),
ModerationCategory.POLITICS: (
"请避免讨论政治话题,保持服务专业性"
),
ModerationCategory.PORN: "请使用正式语言",
ModerationCategory.AD: "请勿发送广告内容",
ModerationCategory.PRIVACY: (
"请勿发送员工隐私信息(电话/身份证),如需联系请走企微"
),
ModerationCategory.OTHER: "请检查并修改表达",
}
return suggestions_map.get(category, "请检查并修改表达")
@staticmethod
def _get_fallback_question(keywords: List[str]) -> dict:
"""Dify 失败时的兜底题(从预置题池随机抽一道)。
注意:这里写死 10 道 IT 基础题,生产环境可改成查 quiz_questions.source='manual'
"""
import random
fallback_pool = [
{
"question": "电脑突然黑屏,最安全的做法是?",
"options": ["强制关机重启", "拔电源重启", "等几分钟看是否恢复", "砸电脑"],
"correct_index": 0,
"hint": "想想最稳妥的第一步",
"explanation": "黑屏可能是系统卡死,强制重启通常能恢复,拔电源可能损坏硬件",
"source": "manual",
},
{
"question": "打印机不响应,首先应该检查?",
"options": ["打印机电源", "重装系统", "换台电脑", "直接呼叫维修"],
"correct_index": 0,
"hint": "最基础的物理连接",
"explanation": "80% 故障是电源/线缆问题,先排除最简单的再考虑复杂方案",
"source": "manual",
},
{
"question": "密码忘了应该怎么办?",
"options": ["自己猜", "暴力破解", "找 IT 重置", "不用了"],
"correct_index": 2,
"hint": "走正规流程最安全",
"explanation": "找 IT 重置是最快最安全的做法,自己猜可能锁账号,暴力破解违法",
"source": "manual",
},
{
"question": "无法连接公司 VPN,首选排查?",
"options": ["检查网络是否通", "重装系统", "换电脑", "联系运营商"],
"correct_index": 0,
"hint": "从外到内排查",
"explanation": "先确认能上网,再排查 VPN 客户端,最后才是公司 VPN 服务器",
"source": "manual",
},
{
"question": "Outlook 收不到邮件,先看哪里?",
"options": ["垃圾邮件箱", "重装 Office", "换邮箱", "打电话给 IT"],
"correct_index": 0,
"hint": "最容易被忽略的",
"explanation": "新邮件被误判到垃圾箱是常见原因,先看再排查服务器",
"source": "manual",
},
{
"question": "Office 软件打开慢,先做什么?",
"options": ["清理开机启动项", "换电脑", "买新硬盘", "卸载重装"],
"correct_index": 0,
"hint": "性能问题先减负",
"explanation": "开机启动项太多会拖慢所有应用,清理后再观察",
"source": "manual",
},
{
"question": "电脑提示磁盘空间不足,应该?",
"options": ["清理回收站和临时文件", "关机", "重装系统", "不处理"],
"correct_index": 0,
"hint": "先释放空间再判断",
"explanation": "90% 的情况清理回收站 + temp 目录就能解决,严重才需要重装",
"source": "manual",
},
{
"question": "网页打不开,首先排查?",
"options": ["检查网络连接", "换浏览器", "重装系统", "砸键盘"],
"correct_index": 0,
"hint": "从最基础的开始",
"explanation": "先看能不能打开其他网页,排除是网站问题还是网络问题",
"source": "manual",
},
{
"question": "U 盘插入电脑没反应,先检查?",
"options": ["换个 USB 接口", "格式化 U 盘", "扔了", "拆电脑"],
"correct_index": 0,
"hint": "先排除最简单的问题",
"explanation": "USB 接口可能松动或供电不足,先换接口试,不要先动数据",
"source": "manual",
},
{
"question": "电脑突然变卡,第一步应该?",
"options": ["看任务管理器占用", "砸电脑", "重装系统", "关机睡觉"],
"correct_index": 0,
"hint": "数据先行",
"explanation": "任务管理器能看到 CPU/内存/磁盘占用,定位是哪个进程在吃资源",
"source": "manual",
},
]
chosen = random.choice(fallback_pool)
return chosen
def add_custom_word(self, word: str) -> None:
"""动态添加敏感词(运营后台调用)。"""
self.wf.addWords([word])
if word not in self.custom_sensitive_words:
self.custom_sensitive_words.append(word)
def remove_custom_word(self, word: str) -> None:
"""动态删除敏感词。"""
# wordfilter 没有 remove API,降级用 replace 占位
# wordfilter.remove(word) # 实际库不一定支持
if word in self.custom_sensitive_words:
self.custom_sensitive_words.remove(word)
# 单例
_moderation_service: Optional[ContentModerationService] = None
def get_moderation_service() -> ContentModerationService:
"""获取内容审核服务单例。"""
global _moderation_service
if _moderation_service is None:
_moderation_service = ContentModerationService()
return _moderation_service
-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}")
-473
View File
@@ -1,473 +0,0 @@
# =============================================================================
# 企微IT智能服务台 — H5 员工端 AI 回复后台任务
# =============================================================================
# 背景:原 h5_send_message 在同步 HTTP 请求内 await AI 推理(Dify 3~15s),
# 整条请求被阻塞,前端表现为"发送中"长时间卡顿。
# 本模块将 AI 推理移出请求,改为 asyncio 后台任务,结果经 WebSocket
# 流式推回(ai_reply_chunk / ai_reply),发送瞬时完成。
#
# 关键约束(详见 docs/02-需求分析/技术架构演进/员工端消息发送延时改造方案.md):
# 1. 必须单 worker 运行(docker-compose --workers 1):
# ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在
# 同进程,broadcast 会静默丢失(约 50%)。
# 2. 使用独立 DB session_get_session_factory),不可复用请求的 db
# (请求返回后该 session 会被关闭)。
# =============================================================================
import logging
from datetime import datetime
from app.api.byod import _byod_keyword_prefilter
from app.database import _get_session_factory
from app.dependencies import get_shared_ai_handler
from app.models.conversation import Conversation
from app.models.message import Message
from app.services.routing_service import (
routing_keyword_prefilter,
detect_routing_intent,
get_contact_by_category,
send_contact_card,
record_routing_event,
_keyword_fallback_category,
)
from app.services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__)
async def _persist_and_push(
db,
conversation: Conversation,
employee_id: str,
content: str,
is_guidance: bool,
should_count: bool,
should_transfer: bool,
dify_conversation_id,
):
"""持久化 AI 回复并推送给员工端 + 广播坐席端。
做什么:
1. 存 AI 消息到 DB
2. 更新会话状态(dify 上下文 / 计数 / 转人工)
3. 经 WS 向员工推 ai_reply 终态(前端据此替换打字机气泡)
4. 经 WS 向坐席端广播 new_message + conversation_updated
为什么:把"落库 + 推送"封装为单点,供同步路径与流式路径复用。
"""
# 1. 存 AI 消息
ai_message = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=content,
msg_type="text",
is_read=True,
)
db.add(ai_message)
await db.flush()
# 2. 更新会话状态
if dify_conversation_id:
conversation.dify_conversation_id = dify_conversation_id
if should_count:
conversation.ai_substantive_reply_count += 1
if should_transfer:
conversation.status = "queued"
conversation.updated_at = datetime.now()
db.add(conversation)
await db.flush()
await db.commit()
# 3. 推 ai_reply 终态给员工(前端替换打字机气泡)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": content,
"msg_type": "text",
"is_guidance": is_guidance,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
# 4. 广播坐席端(new_message + conversation_updated
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(ai_message.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": content,
"msg_type": "text",
},
})
await ws_manager.broadcast({
"type": "conversation_updated",
"data": {
"conversation_id": str(conversation.id),
"status": conversation.status,
"assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None,
},
})
except Exception as ws_err:
# WS 广播失败不阻塞消息存储,只记录 warning
logger.warning(f"WS 广播 AI 回复给坐席失败(消息已存储): {ws_err}")
async def _handle_byod_query(db, conversation, employee_id, content):
"""处理 BYOD 自备电脑补贴查询。
在 H5 聊天消息流中拦截 BYOD 关键词后执行资格检查,并以 byod_card
卡片消息形式推送给员工端(前端 MessageBubble 据 msg_type 渲染
ByodSubsidyCard)。
流程:
1. 通过 WecomService 获取员工岗位(position
2. 与 BYOD 资格清单匹配(_match_position
3. 创建 byod_card 类型 AI 消息并落库
4. 经 WS 推送 ai_reply 给员工端(携带 extra_data.byod_result
5. 广播 new_message + conversation_updated 给坐席端(与 _persist_and_push 一致)
Args:
db: 异步 DB 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,
):
"""H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。
流程:
- 本地快判断(打招呼 / 呼叫人工)→ 同步结果,整段推送(不调 Dify)
- 否则流式调 Dify,逐 chunk 推 ai_reply_chunk,流结束推 ai_reply 终态
- 任意异常 → 推 ai_reply_failed,不阻塞用户
"""
ai_handler = get_shared_ai_handler()
factory = _get_session_factory()
async with factory() as db:
try:
conversation = await db.get(Conversation, conversation_id)
if not conversation:
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
return
is_guidance = False
should_count = False
should_transfer = False
new_dify_conv_id = dify_conversation_id
full_parts: list = []
# === BYOD 关键词拦截 ===
# 在打招呼/呼叫人工判断之前,先检查是否为 BYOD(自备电脑补贴)意图。
# 命中关键词 → 执行 BYOD 资格检查并推送 byod_card 卡片,不走正常 AI 流程。
if _byod_keyword_prefilter(content):
await _handle_byod_query(db, conversation, employee_id, content)
return # BYOD 处理完毕,直接返回
# === 业务路由检测(新增)===
# 在 BYOD 检测之后、打招呼/呼叫人工检测之前,检查是否为非IT业务路由。
# 命中路由关键词 → 调用 Dify 统一意图识别 → non_it_routing && confidence≥0.7
# → 发送名片三段式消息(路由文本 + contact_card + 系统提示)
# 置信度不足或非路由意图 → 继续往下走正常 AI 流程
if routing_keyword_prefilter(content):
routed = await _handle_routing(db, conversation, employee_id, content)
if routed:
return # 路由名片已发送,直接返回
# 本地快判断(不打 Dify):打招呼 / 呼叫人工 → 同步路径
if ai_handler.is_greeting(content) or ai_handler.is_call_human(content):
result = await ai_handler.handle_message(
content=content,
dify_conversation_id=dify_conversation_id,
user_id=employee_id,
)
await _persist_and_push(
db, conversation, employee_id, result.content,
result.is_guidance, result.should_count,
result.should_transfer, result.dify_conversation_id,
)
return
# 流式调 Difyget_reply_stream 内部已处理真 SSE / 非流式 fallback
# 注意:首参是 message(用户文本),不是 content
async for chunk in ai_handler.ai_service.get_reply_stream(
message=content,
conversation_id=dify_conversation_id,
user_id=employee_id,
):
delta = chunk.get("delta", "")
if delta:
full_parts.append(delta)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_chunk",
"data": {
"conversation_id": conversation_id,
"chunk": delta,
},
})
if chunk.get("finished"):
new_dify_conv_id = chunk.get("conversation_id") or dify_conversation_id
hit = chunk.get("hit")
# 命中 → 计数;未命中 → 转人工
should_count = bool(hit)
should_transfer = not bool(hit)
content_ai = "".join(full_parts)
if not content_ai:
# 流式无内容(极端情况),给降级提示,不转人工
content_ai = "⚠️ AI 暂时没有返回内容,请输入「IT」转人工。"
should_count = False
should_transfer = False
await _persist_and_push(
db, conversation, employee_id, content_ai,
is_guidance, should_count, should_transfer, new_dify_conv_id,
)
except Exception as e:
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",
"data": {
"conversation_id": conversation_id,
"message": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
},
})
except Exception:
# 推送失败也无所谓,员工端 3 秒轮询兜底
pass
-100
View File
@@ -1,100 +0,0 @@
# =============================================================================
# 企微IT智能服务台 — 超时提醒定时任务
# =============================================================================
# 说明:定时检查超时未回复的会话,发送企微提醒消息
# 运行频率:每 30 秒执行一次
# 超时逻辑:
# 1. 坐席回复后 3 分钟员工未回复 -> 发送企微提醒(只发 1 次)
# 2. 坐席回复后 10 分钟员工未回复 -> 标记会话为 pending_close
# =============================================================================
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from app.models.conversation import Conversation
from app.services.reminder_service import send_reminder_message
logger = logging.getLogger(__name__)
# 超时配置(分钟)
REMINDER_TIMEOUT_MINUTES = 3 # 未回复超时时间
CLOSE_TIMEOUT_MINUTES = 10 # 自动待关闭时间
async def check_unreplied_sessions():
"""检查超时未回复的会话,发送提醒并标记待关闭。
此函数由 APScheduler 定时调用(每 30 秒)。
执行流程:
1. 查找需要发送提醒的会话(坐席回复超过3分钟,员工未回复且未发送过提醒)
2. 发送企微提醒消息
3. 标记已发送提醒
4. 查找需要标记待关闭的会话(坐席回复超过10分钟)
5. 更新会话状态为 pending_close
"""
# 导入数据库 session 工厂
from app.database import _get_session_factory
async_session_factory = _get_session_factory()
async with async_session_factory() as db:
try:
# 1. 查找需要发送提醒的会话
# 条件:active 状态 + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒
reminder_threshold = datetime.now() - timedelta(minutes=REMINDER_TIMEOUT_MINUTES)
reminder_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < reminder_threshold,
Conversation.reminder_sent == False,
)
result = await db.execute(reminder_stmt)
sessions_to_remind = result.scalars().all()
logger.info(f"发现 {len(sessions_to_remind)} 个需要发送提醒的会话")
# 2. 发送企微提醒消息
for session in sessions_to_remind:
try:
success = await send_reminder_message(session.employee_id)
if success:
# 3. 标记已发送提醒
session.reminder_sent = True
session.reminder_sent_at = datetime.now()
logger.info(f"会话 {session.id} 已发送提醒: employee_id={session.employee_id}")
else:
logger.warning(f"会话 {session.id} 提醒发送失败,跳过")
except Exception as e:
logger.error(f"会话 {session.id} 发送提醒异常: {e}")
continue
# 4. 查找需要标记待关闭的会话
# 条件:active 状态 + 有坐席回复 + 超过10分钟未回复
close_threshold = datetime.now() - timedelta(minutes=CLOSE_TIMEOUT_MINUTES)
close_stmt = select(Conversation).where(
Conversation.status == "serving",
Conversation.last_agent_reply_at.isnot(None),
Conversation.last_agent_reply_at < close_threshold,
)
result = await db.execute(close_stmt)
sessions_to_close = result.scalars().all()
logger.info(f"发现 {len(sessions_to_close)} 个需要标记待关闭的会话")
# 5. 更新会话状态为 pending_close
for session in sessions_to_close:
session.status = "pending_close"
logger.info(f"会话 {session.id} 已标记为待关闭: employee_id={session.employee_id}")
# 提交数据库变更
await db.commit()
logger.info("超时检查任务执行完成")
except Exception as e:
await db.rollback()
logger.error(f"超时检查任务执行异常: {e}")
raise
-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

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