P0-1: 删除 docker-compose-override.yml + deploy-server 两份 workers=2→1(预防性修复,服务器根 compose 已是 workers=1)
This commit is contained in:
@@ -1,5 +1,130 @@
|
||||
# 早班巡检自动化 - 执行记录
|
||||
|
||||
## 2026-07-17 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档第四章 v2.7+ (含07-16更新) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 记忆文件 (07-16)
|
||||
**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第9次不存在。发现独立看板文件在 `docs/10-项目管理/项目状态看板.md` (v1.0, 07-17新建),与主文档第四章存在数据不一致
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(延后至约07-21到期)、#104结构化日志查看页(无阻塞可启动);#117 Neo4j已完成但仍列P0区未清理
|
||||
2. **P1待办3项**:#80坐席图片预览(已在完成区07-16但P1仍列出,数据不一致)、#73后端文件覆盖、#86流程图review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(≥6天自07-11)、ITSM API授权(≥6天自07-11)— 需PM立即关注
|
||||
4. **进行中1项**:#76零信任VPN卡片免登录修复(P1,今日新建计划今日完成)——仅独立看板有记录,主文档"正在做"为空
|
||||
5. **#81编号冲突**:P0"敏感词检测+语气优化"与已完成"粘贴图片边框问题"共用#81
|
||||
6. **两份看板数据不一致**:独立看板v1.0(74完成/1进行中) vs 主文档(P0/P1/等决策分区仍含已完成项)
|
||||
7. **07-16完成4项未入独立看板已完成区**:#117 Neo4j、#82 坐席500错误、#81粘贴图片边框、#80企微图片预览
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项(#81延后中、#104可启动;#117已完成未清理)
|
||||
- P1待办:3项(#80可能已完成待确认)
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:1项(#76,仅独立看板有记录)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档 v2.8 (2026-07-14) + `.workbuddy/memory/2026-07-15.md` + `.workbuddy/memory/2026-07-14.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在(连续8次),状态看板在主文档第四章
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(07-14 PM决定延后1周,原阻塞自07-04共12天,约07-21到期需启动)、#104结构化日志查看页(无阻塞可直接启动)
|
||||
2. **P1待办2项**:#73后端文件覆盖、#86流程图零依赖review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(阻塞≥5天自07-11)、ITSM API授权(阻塞≥5天自07-11)— 需PM立即关注
|
||||
4. **进行中0项**:看板"正在做"区为空
|
||||
5. **07-14/07-15新产出未入看板**:/h5/ 404错误修复(nginx配置)、Token多IP异常检测功能部署(T001-T003测试通过)、审批模板ID不正确两轮修复(RecommendCard+ApprovalCardModal+DB)
|
||||
6. **看板路径持续缺失**:连续8次巡检不存在
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项
|
||||
- P1待办:2项
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:0项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-15 09:30 执行结果
|
||||
|
||||
**数据来源**:主文档 v2.8 (2026-07-14) + `.workbuddy/memory/2026-07-14.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在,状态看板在主文档第四章
|
||||
|
||||
### 关键发现
|
||||
1. **P0待办2项**:#81敏感词检测+语气优化(07-14 PM决定延后1周,原阻塞自07-04共11天)、#104结构化日志查看页(07-14新排入,无阻塞可直接启动)
|
||||
2. **P1待办2项**:#73后端文件覆盖、#86流程图零依赖review
|
||||
3. **等用户决策3项,2项超3天阈值**:企微会议室Secret(阻塞≥4天自07-11)、ITSM API授权(阻塞≥4天自07-11)— 需PM立即关注
|
||||
4. **进行中0项**:看板"正在做"区为空
|
||||
5. **07-14新产出未入看板**:/h5/ 404错误修复(nginx配置)、Token多IP异常检测功能部署(T001-T003测试通过)
|
||||
6. **看板路径持续缺失**:指定路径连续7次巡检不存在
|
||||
|
||||
### 全局状态
|
||||
- P0待办:2项
|
||||
- P1待办:2项
|
||||
- 等决策:3项(2项超3天阈值)
|
||||
- 进行中:0项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-14 09:30 执行结果(已更新看板 v2.6)
|
||||
|
||||
**数据来源**:主文档 v2.5 + 实际代码检测 + PM确认
|
||||
|
||||
### 关键发现(经实际检测确认)
|
||||
1. **#48 IP白名单**:✅ 已完成。检测nginx.conf确认已配置精确内网网段(10.0.0.0/8等)
|
||||
2. **#105 摇人Bug**:✅ 已完成。代码确认不再推送企微通知栏
|
||||
3. **#107 卷挂载**:✅ 已完成。docker-compose.yml确认./app:/app/app已配置
|
||||
4. **#88 RBAC**:✅ 粗粒度已满足需求,无需细粒度。PM确认
|
||||
5. **#81 敏感词**:⏸️ 延后1周
|
||||
6. **#104 日志页**:🆕 排入本期
|
||||
7. **#75 头像**:🔄 需重新测试
|
||||
8. **火绒AccessKey**:⚠️ 07-13测试时被假值覆盖
|
||||
|
||||
### 看板更新(v2.6)
|
||||
- #48/#107/#88 移至已完成区
|
||||
- #105 从P1移除
|
||||
- 新增"等用户决策"区块
|
||||
|
||||
### 全局状态(更新后)
|
||||
- P0待办:2项(#81/#104)
|
||||
- P1待办:3项
|
||||
- 等决策:4项
|
||||
- 进行中:1项
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-13 09:30 执行结果
|
||||
|
||||
**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.5, 2026-07-13) + `.workbuddy/memory/2026-07-13.md` + `.workbuddy/memory/2026-07-12.md`
|
||||
**说明**:指定看板路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 仍不存在,状态看板在主文档第四章;v2.5已更新至07-13,07-12/07-13产出已纳入
|
||||
|
||||
### 关键发现
|
||||
1. **P0阻塞2项持续未推进**:#48 IP白名单收窄(阻塞≈31天,自06-13)、#81 敏感词检测+语气优化(阻塞≈10天,自07-04,但隐私正则修复已完成)— 均>3天,需PM立即关注
|
||||
2. **#105数据不一致持续4次巡检**:已完成区(07-10)+P1清单双重列出,07-10/07-11/07-12/07-13四次巡检指出至今未修正 ⚠️数据质量
|
||||
3. **#107可能已完成未更新**:07-12/07-13日志显示bind mount方案已实际使用(`./app:/app/app`),但看板仍标为in_progress
|
||||
4. **#75/#88可能已完成**:#75头像同步07-08已交付12/12测试通过;#88 RBAC v0.7.1已完成6处装饰器修复(但细粒度权限可能未完成)
|
||||
5. **07-12布局优化v2.0的8个待明确事项已清除**(07-12日志确认),从等决策清单移除
|
||||
6. **Portal /itportal/ 500错误**:07-13测试发现,nginx静态文件问题,非后端错误,未入看板
|
||||
7. **等决策项从5项减至3项**:企微会议室Secret、ITSM API授权、ITSM代办API抓包仍在阻塞
|
||||
|
||||
### 全局状态
|
||||
- P0待办:3项(2项长期阻塞,#81部分完成)
|
||||
- P1待办:5项(#105应移除,#75/#88可能已完成)
|
||||
- 等决策:3项(布局优化事项已清除)
|
||||
- 进行中:2项(#107可能已完成)
|
||||
|
||||
### PM行动项
|
||||
1. 联系网络组确认代理IP段(#48阻塞31天)⚠️紧急
|
||||
2. 确认#81敏感词"语气优化"部分是否仍需开发(隐私正则已完成)⚠️紧急
|
||||
3. 从P1清单移除#105(连续4次巡检指出)⚠️数据质量
|
||||
4. 确认#107卷挂载改造是否已完成(bind mount已实际使用)
|
||||
5. 确认#75头像同步是否已完成(07-08已交付12/12测试)
|
||||
6. 确认#88 RBAC细粒度权限是否仍需开发(6处装饰器已修复)
|
||||
7. 企微管理后台申请会议室Secret
|
||||
8. 向ITSM平台方申请app_id/app_secret
|
||||
9. 排查Portal /itportal/ 500错误并入看板
|
||||
10. #104结构化日志查看页待启动(无阻塞,可排入sprint)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-12 09:30 执行结果
|
||||
|
||||
**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md` + `.workbuddy/memory/2026-07-12.md`
|
||||
|
||||
+73
-51
@@ -1,44 +1,78 @@
|
||||
# IT智能服务台 - 项目记忆
|
||||
|
||||
## 测试铁律
|
||||
- **e2e 测试 poll 不能依赖 `after_id`(UUID 排序 ≠ 时间排序)**:messages/poll 的 `after_id` 参数使用数据库主键 UUID 排序,与 `created_at` 时间戳排序不一致。跨会话复用用户时可能 poll 到修复前的旧消息。应使用 `created_at` 时间戳过滤 + 唯一测试用户。
|
||||
- **宣布修复前必须端到端验证**:容器内测试通过 ≠ 外部用户可用。需经过 Mock 登录 → 发消息 → 查回复 全链路验证。
|
||||
|
||||
## 设计决策(锁定)
|
||||
- AI交互:小段多回合;术语:**员工端"人工坐席"按钮** = 用户呼叫坐席(统一命名,不再用"人工"/"摇人"变体);"摇人"=坐席呼叫坐席
|
||||
- AI交互:小段多回合;术语:**员工端"人工坐席"按钮** = 用户呼叫坐席(统一命名);"摇人"=坐席呼叫坐席
|
||||
- 原型:坐席v5.3 + H5 v1.1;UI:企微浅色扁平,accent=#07C160
|
||||
- 统一入口 `/itportal/` → user/agent/admin;admin需OTP
|
||||
- **H5 v4(2026-07-13 00:48 已部署)**:人工按钮三态文案统一为"人工坐席";位置在"发送键和语音按钮上方"(垂直堆叠于 `.input-bar__controls` 容器内);点按钮直接调 `store.shakeAgent()`,不弹 CallAgentModal 浮窗动画;截图说明 PC 显示/移动端隐藏(CSS 媒体查询)
|
||||
- **H5 v5(2026-07-13 02:08 已部署)**:RightPanel v2.1 — 删除"软件安装"和"资源权限"标签页,移除标签栏,智能推荐直接展示;JS hash `index-BP1rEZIf.js`,CSS hash `index-DC1iZpKe.css`
|
||||
- **Agent v5(2026-07-13 01:38 已部署)**:ai_structured/byod_card 只读渲染 + AI思考指示器 + handleNewMessage 透传 msg_type/extra_data 修复;JS hash `index-2BTn4SZz.js`
|
||||
- **后端 v5(2026-07-13 01:38 已部署)**:6个Python文件(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等);diagnosis_stage(6值)+response_time_ms计时+VisionService接入+双WS推送(ai_reply+dynamic_recommend)+ai_thinking同时推员工和坐席
|
||||
- H5 布局铁律:人工坐席按钮在"发送键和语音按钮上方";截图说明 PC 显示/移动端隐藏
|
||||
|
||||
## 技术架构
|
||||
- 前端:坐席(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)
|
||||
- H5 参与者系统已完整存在:`ParticipantStrip`/`ParticipantList`/`InviteParticipantSheet` + `useParticipantDisplay` composable;`ROLE_BORDER_COLORS`/`ROLE_LABELS` 常量;`canInvite`=发起人 OR 已在会话中的参与者
|
||||
|
||||
## 部署
|
||||
## 部署铁律(必读)
|
||||
- 正式服务器:itsupport.servyou.com.cn (10.90.5.110),出口IP `218.75.34.87`
|
||||
- 堡垒机:sxn@10.212.189.210:2222 (OTP),脚本 `C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py`
|
||||
- **服务器项目根路径**:`/opt/wecom-it-desk/`(非本地仓库路径)
|
||||
- 后端卷挂载:`./app:/app/app`,`.py`变更→`docker compose restart backend`;env变更→`up -d backend`
|
||||
- **服务器项目根路径**:`/opt/wecom-it-desk/`
|
||||
- 后端卷挂载:`./app:/app/app`,`.py`变更→`docker compose restart backend`;env变更→`docker compose up -d backend`(restart不重载env)
|
||||
- **docker-compose.yml environment 铁律**(2026-07-13 第二次踩坑导致 P0 `[object Object]`):`.env`变量不会自动传入容器,必须在`docker-compose.yml`的`environment:`显式声明每个变量(如 `DIFY_NATIVE_BASE_URL=${DIFY_NATIVE_BASE_URL:-}`)。后端重启时若发现 env 为空但 `.env` 文件有值,首先检查 docker-compose.yml 是否声明。
|
||||
- **前端部署铁律**:所有前端 dist 均为 ro bind mount,**只能在宿主机源路径操作**,不可在容器内修改
|
||||
- H5:`/opt/wecom-it-desk/frontend-h5/dist` → `/usr/share/nginx/html/h5` (ro)
|
||||
- H5:`/opt/wecom-it-desk/frontend-h5/dist` → `/usr/share/nginx/html/h5` (ro) [新增于 2026-07-13,此前仅 mount 到 `itdesk` 用于重定向]
|
||||
- H5重定向:`/opt/wecom-it-desk/frontend-h5/dist` → `/usr/share/nginx/html/itdesk` (ro) [用于 `/itdesk/` → `/h5/` 301重定向]
|
||||
- Agent:`/opt/wecom-it-desk/frontend-agent/dist` → `/usr/share/nginx/html/itagent` (ro)
|
||||
- Admin:`/opt/wecom-it-desk/frontend-admin/dist` → `/usr/share/nginx/html/itadmin` (ro)
|
||||
- Portal:`/opt/wecom-it-desk/frontend-portal/dist` → `/usr/share/nginx/html/itportal` (ro)
|
||||
- Terminal:`/opt/wecom-it-desk/frontend-terminal/dist` → `/usr/share/nginx/html/itterminal` (ro)
|
||||
- nginx.conf:`/opt/wecom-it-desk/nginx/nginx.conf` → `/etc/nginx/nginx.conf` (ro)
|
||||
- 部署命令模板:`H5_DIR=/opt/wecom-it-desk/frontend-h5/dist && cp -r $H5_DIR ${H5_DIR}_bak && rm -rf $H5_DIR/* && tar -xzf /tmp/h5-dist-vX.tar.gz -C $H5_DIR/ && docker exec wecom_it_nginx nginx -s reload`
|
||||
- **服务器 nginx /h5/ 配置**(与本地仓库不同):静态文件服务 `root /usr/share/nginx/html; try_files $uri /h5/index.html;` + `/h5/api/` 反代后端
|
||||
- 部署命令模板:`H5_DIR=/opt/wecom-it-desk/frontend-h5/dist && cp -r $H5_DIR ${H5_DIR}_bak && rm -rf $H5_DIR/* && tar -xzf /tmp/h5-dist-vX.tar.gz -C $H5_DIR/ && docker compose up -d nginx`(**注意:容器重建,不是 nginx -s reload**)
|
||||
- 服务器 nginx `/h5/`:静态 `root /usr/share/nginx/html; try_files $uri /h5/index.html;` + `/h5/api/` 反代后端
|
||||
- Docker bind mount铁律:rm后重建必须重启容器(或 `nginx -s reload` 热重载)
|
||||
- **文件上传**:elFinder Web UI(`hz-oa-ai-g-dataquery-90-5-110`目录=服务器`/tmp/`)/ fast_upload_v3.py(~20KB/s)/ jms_ops.py pack-upload
|
||||
- ⚠️ **elFinder 上传二进制文件不可靠**(2026-07-13 确认):tar.gz 上传后 MD5 不匹配(差90字节)。**推荐用 base64 分块上传**:`jms_ops.py batch` 模式,45KB/块,~113秒/7.6MB(脚本 `.workbuddy/tmp/chunked_upload_v2.py`)
|
||||
- **dist 分块上传技巧(2026-07-13 验证)**:大文件上传时先排除 video 等不变目录(8.35MB→210KB),写 Python 脚本导入 `jms_ops` 模块复用 `PlinkSession` 做单会话 base64 分块上传(3800 chars/line × 15 lines/batch here-doc),单会话完成上传+部署+验证,避免 session cache 过期后 Chrome/Playwright 启动失败。脚本模板在 `.workbuddy/tmp/upload_and_deploy_h5.py`。
|
||||
|
||||
## Admin 前端拦截器 Bug(2026-07-13 修复)
|
||||
- **根因**:`api/index.ts` 拦截器 `return res.data` 剥离了 axios 外壳,但 10 个文件仍用 `response.data.data` 旧模式
|
||||
- **影响**:管理后台所有 API 调用 TypeError 被 catch 吞掉 → 集成页面"测试连接"报"请求失败,请检查网络连接"
|
||||
- **修复**:10 文件 25 处 `xxx.data.data` → `xxx`;3 种变量名模式:response/res/leakResp
|
||||
- **铁律**:新增 API 调用时直接用返回值(`const data = await getXxx()`),不要 `response.data.data`
|
||||
- **Vite 构建铁律**:`dist/assets` 超 50 文件时 safe-delete 会阻止构建,需先重命名旧 dist 为 dist_bak 再构建
|
||||
- **Vite `@` 别名铁律(2026-07-13)**:Windows 上 `resolve.alias: { '@': '/src' }` 会解析为 FS 根路径(`D:\src\`),必须用 `fileURLToPath(new URL('./src', import.meta.url))`
|
||||
- **`.d.ts` vs `.ts` 铁律(2026-07-13)**:含运行时值(如 `const TONE_LABELS = {...}`)的文件不能用 `.d.ts` 扩展名,Vite 构建时会报 `does not provide an export named`。应使用 `.ts`
|
||||
|
||||
## 文件上传(关键经验教训)
|
||||
- ⚠️ **elFinder 上传二进制**:旧版手动上传 tar.gz 后 MD5 不匹配(差字节)。**2026-07-13 验证**:`jms_ops.py upload` 命令对 >=100KB 文件走 elFinder Web UI 通道,上传 7.36MB `tar.gz` MD5 完全匹配 ✅,推荐优先使用。如必须用 base64 通道,则参考下一条铁律。
|
||||
- ⚠️ **JumpServer PTY 4096 字节行缓冲铁律(2026-07-13 严重事故)**:base64 单行字符数若超 4096 会被 PTY 静默丢弃,导致 gzip 流损坏、线上瘫痪。脚本 `.workbuddy/tmp/chunked_upload_v2.py` **必须按 base64 字符数分块 `B64_CHUNK=3800`**(非字节数)。subprocess timeout 设 1800s、`--cmd-timeout` 15s。脚本 print 用 ASCII(禁用 emoji,否则 GBK 崩溃)。上传后比对远程 md5sum 与本地。
|
||||
- pscp/plink -T不可用;elFinder上传后需手动mv;httpx.Timeout须含default
|
||||
- **JumpServer v2.28 变更**:登录新增图片验证码(CAPTCHA);connection-token 端点改为 `/api/v1/authentication/connection-token/`
|
||||
- JumpServer v2.28 变更:登录新增图片验证码(CAPTCHA);connection-token 端点改为 `/api/v1/authentication/connection-token/`
|
||||
|
||||
## 外部集成
|
||||
- 企微通讯录:Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8`,Redis key=`wecom:contact_access_token`
|
||||
- Dify:`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`;分诊Key `app-z3S9AEUUAVPbtR2rioxpiIvp`
|
||||
- ⚠️ **禁止使用** `app-UaTWYdBSwN6VktKQlbh5YN5H`(老线上Dify应用,未收到明确指令前不可调用);应使用副本 `app-7jkRkAzvX4QM9v9SM3P8mMEO` 或自建 `app-J3s8sHarZQ2SCaNF3xCppliL`(后者不适合dify2openai代理,仅限直接API调用)
|
||||
- 企微通讯录:通讯录同步Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8` 已废弃(2022年8月后新增IP不能调department/list和user/list,errcode=48009,非IP白名单问题)。改用**自建应用Secret + 可见范围设为全公司**,`.env` 中 `WECOM_CONTACT_SECRET=` 留空触发降级。验证:651部门/7098员工
|
||||
- Dify:`http://yw-dify.dc.servyou-it.com/v1/chat-messages`
|
||||
- 审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`(✅200,结构化JSON)
|
||||
- 分诊Key `app-z3S9AEUUAVPbtR2rioxpiIvp`(✅200,结构化JSON)
|
||||
- `app-UaTWYdBSwN6VktKQlbh5YN5H`(✅200 纯文本,已禁用)
|
||||
- `app-J3s8sHarZQ2SCaNF3xCppliL`(❌400 "Model credentials not initialized")
|
||||
- **Dify App ID**(Console):`8f0f3d62-f63d-4cf3-815e-b10529c66f1d`(智能IT支持-员工咨询,advanced-chat,85节点)
|
||||
- ⚠️ **Dify 发布铁律**:`advanced-chat`应用改工作流后**必须浏览器手动点「发布」**,否则API返回400 `"Workflow not published"`;此版本 publish 端点不支持 Console API 调用。
|
||||
- **Dify 原生 API 直连**(v2.1):后端 `_call_dify_native()` 直连 `/v1/chat-messages`(Bearer+blocking),优先于代理;`config.py` 配 `dify_native_base_url`/`dify_native_api_key`,**docker-compose.yml 须显式声明**(`.env` 定义不会自动传入容器)。
|
||||
- ⚠️ **两次踩坑**:第一次是配置读取时未意识到容器内为空;第二次是 `[object Object]` 根因正是此变量未声明导致原生 API 不可用、回退到 buggy 代理路径。
|
||||
- ⚠️ **第三次踩坑(2026-07-13 坐席扫码)**:`WECOM_SSO_CALLBACK_BASE` 未声明导致 `qrcode_service.py` 抛异常 `ValueError: 请在环境变量中配置 WECOM_SSO_CALLBACK_BASE`。
|
||||
- **必须确保 `environment:` 部分有**(新增变量需同时加入 `.env` 和 `docker-compose.yml`):
|
||||
```yaml
|
||||
- DIFY_NATIVE_BASE_URL=${DIFY_NATIVE_BASE_URL:-}
|
||||
- DIFY_NATIVE_API_KEY=${DIFY_NATIVE_API_KEY:-}
|
||||
- BAIDU_ASR_APP_ID=${BAIDU_ASR_APP_ID:-}
|
||||
- BAIDU_ASR_API_KEY=${BAIDU_ASR_API_KEY:-}
|
||||
- BAIDU_ASR_SECRET_KEY=${BAIDU_ASR_SECRET_KEY:-}
|
||||
- WECOM_SSO_CALLBACK_BASE=${WECOM_SSO_CALLBACK_BASE:-https://itsupport.servyou.com.cn}
|
||||
```
|
||||
- **Dify Prompt 自动更新**(v2.2):Console API Token(`localStorage.console_token`) → `/console/api/apps/{app_id}/workflows/draft` GET/POST 批量改 LLM 节点 System Prompt;发布仍需手动。
|
||||
- RAGFlow:生产 `http://10.80.0.85:8080/` / API `:9380`
|
||||
- 映射策略:联软(主) > aTrust(VPN) > eHR(静态)
|
||||
|
||||
@@ -53,44 +87,32 @@
|
||||
- 群聊(摇人/邀请/四角色) / 审批(12类型18流程/三级意图) / 代办(getapprovalinfo+Semaphore/缓存45s)
|
||||
- IT资产推送(模板`Bs7ucT...`) / 语音转文字(手机JS-SDK/PC百度ASR) / 截图拍照 / 复杂场景P0~P3
|
||||
- 会议室预定(终端`/itterminal/`,企微会议室Secret待申请)
|
||||
- 上下文感知智能诊断→修复闭环(三层诊断+三段排队+答题插队+五场景关闭);H5 QueueWaiting/RightPanel双Tab/InputBar三态人工按钮/ResolveConfirmCard;坐席pending_close结单
|
||||
- 单通道统一消息架构:Dify输出JSON `{text,action,options}` → 后端发双WS(ai_reply+dynamic_recommend) → 文字到气泡/卡片到侧边栏;坐席端 ai_thinking 指示器 + ai_structured/byod_card 渲染
|
||||
|
||||
## 坐席端布局优化v2.0(2026-07-12 部署)
|
||||
- 8新增+7修改+3删除;QuickReplyBar L1+L2悬浮;ReplyBox左右分区;右栏260↔560px模式切换
|
||||
- **键盘快捷键v2.3**:纯数字1~9上下文路由(AI/L1/L2);ESC分层撤销;Shift+Space用`event.code`匹配(不受IME影响)
|
||||
- `useKeyboardShortcuts.ts`中央管理器,IME/ScreenCapture守卫;L1 chip移除模板数量徽章只保留kbd编号
|
||||
## H5 版本部署记录
|
||||
- v4:人工按钮三态"人工坐席",直接调 `store.shakeAgent()`
|
||||
- v5:RightPanel v2.1 删软件安装/资源权限标签,智能推荐直接展示
|
||||
- **v7(已部署 2026-07-13)**:① InputBar 删除📷截图图标+说明(工具栏仅表情/文件)② ParticipantStrip 加 hover tooltip(姓名+角色+部门,仅PC) + `+邀请`按钮(canInvite) + InviteParticipantSheet 引用 ③ 头像条位于会话框正上方 `v-if="store.participants.length>0"` 保持;④ 修复 CSP 头(添加 `'unsafe-inline'` 和 `https://at.alicdn.com`)⑤ 修复 docker-compose mount(新增 `h5` 目录 bind mount);构建 index-BFvX9hTe.js / index-DLWx4YQ2.css
|
||||
- **v11(已部署 2026-07-13)**:修复"IT资产升级"审批报错"审批模板ID不正确"——原企微审批模板 `Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS` 已失效,改用 ITSM 工单系统 `https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE`
|
||||
- **(2026-07-17)零信任VPN卡片免登录修复**:将ITSM跳转URL从 `devops.dc.servyou-it.com` 改为 `itsm.servyou.com.cn`(企微应用主页域名,从企微工作台打开时免登录)
|
||||
|
||||
## 知识库迭代3功能(2026-07-12 部署)
|
||||
- 分诊交互(H5+坐席+Dify独立应用) + 拓扑预览(ECharts只读) + 代答排除(4种匹配器)
|
||||
- 44文件43测试通过;迁移051;路由顺序铁律:固定路径必须在参数路由前注册
|
||||
## 免登录跳转域名选择(2026-07-17)
|
||||
- **ITSM系统**:使用 `itsm.servyou.com.cn`(企微应用主页域名)可实现免登录
|
||||
- **旧域名** `devops.dc.servyou-it.com` 需要扫码登录
|
||||
- **原因**:`itsm.servyou.com.cn` 已配置为企微OAuth2.0应用主页,从企微工作台打开时自带身份信息
|
||||
|
||||
## 上下文感知智能诊断→修复闭环(2026-07-12 已部署)
|
||||
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked) + 答题插队 + 五场景关闭
|
||||
- 后端:迁移052(6表+6列) / queue_service / quiz_service / closing_service / seed_quiz / 每日3:00定时生成
|
||||
- H5前端:QueueWaiting / RightPanel双Tab / InputBar三态"人工"按钮 / ResolveConfirmCard
|
||||
- 坐席前端:pending_close结单流程;信息锁定(Dify步骤完成+有效回答率≥70%)
|
||||
- **部署时间**:2026-07-12 21:24(H5前端通过jms_ops.py upload elFinder通道上传7.61MB)
|
||||
- **验证**:Queue API 200 ✅ / Quiz API 200 ✅ / H5页面200+新JS hash ✅ / Agent前端v2.3 ✅ / Nginx healthy ✅
|
||||
|
||||
## Dify App改造 + 右边栏v2(2026-07-12 设计确认 → Phase 1-3 已完成)
|
||||
- **Dify App现状**:85节点→计划精简至~35;保留RAGFlow+Vision节点(后端未接入前不删)
|
||||
- **单通道统一消息架构**:Dify输出JSON `{text, action, options}` → 后端发两条WS(ai_reply+dynamic_recommend)→ 文字到聊天气泡/卡片到侧边栏
|
||||
- **审批意图优化**:删除前端`checkApprovalIntent()`;关键词预过滤收窄(~40→~25);两级分类(4粗→12细);后端统一入口
|
||||
- **右边栏v2.1已实施**(v2.1 2026-07-13:删除软件安装/资源权限标签,全面AI化):
|
||||
- 手风琴两大区域:设备信息(默认折叠) / 自助诊断(默认折叠)
|
||||
- 智能推荐区域:DynamicRecommend 组件直接展示(无标签栏切换,始终可见)
|
||||
- 设备信息:CPU/内存/硬盘默认隐藏(避免焦虑)
|
||||
- 自助诊断标签:网络联通/账号权限/设备硬件
|
||||
- **Phase 1-3 完成状态**(2026-07-12):
|
||||
- Phase 1 ✅:Dify Prompt(JSON输出) + 后端blocking+JSON解析+双WS推送 + 错误降级(30s超时/15s still_thinking)
|
||||
- Phase 2 ✅:关键词收窄(~25强意图词) + 两级分类Prompt v4.0 + 删除前端checkApprovalIntent
|
||||
- Phase 3 ✅:WS扩展(ai_thinking+dynamic_recommend) + MessageBubble ai_structured渲染(文字+选项按钮) + RightPanel v2.1(手风琴+智能推荐直接展示) + DynamicRecommend.vue(新建) + sendOptionSelect WS回传
|
||||
- **Phase 4 ✅**:VisionService接入(`_enrich_image_content`+`_fetch_recent_employee_text`5秒融合) + 图片消息跳过关键词拦截 + 降级策略
|
||||
- **Phase 5 ✅**:坐席端`ai_thinking` WS+指示器UI + `MessageBubble` ai_structured/byod_card渲染 + `handleNewMessage`修复(msg_type/extra_data透传)
|
||||
- **Phase 6 ✅**:`diagnosis_stage`字段(6种值) → `closing_service`辅助方法 + `response_time_ms`计时+慢响应告警(>10s)
|
||||
- **v2.0 新增前端文件**:`DynamicRecommend.vue`(动态推荐卡片,3种类型 approval/action/info)
|
||||
- **v2.0 关键架构**:`sendWsMessage()` 模块级导出函数(useH5WebSocket.ts),供 store 在 composable 外部发送 WS 消息
|
||||
- **实施计划文档**:`docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md`(6阶段Phase 1-6,10个Task #59-#69跟踪)
|
||||
## 组织架构树(2026-07-13 重构)
|
||||
- **层级树**:`build_org_tree()` 用企微 `department/list` 的 `parentid` 构建真正层级树(非扁平分组)
|
||||
- 部门节点 ID 加 `dept_` 前缀避免与员工 UserID 冲突;员工出现在其所属的所有部门下
|
||||
- 三层缓存:`wecom:org_directory`(1800s) + `wecom:dept_list`(1800s) + `wecom:org_tree:{endpoint}`(1800s)
|
||||
- `get_org_directory()` 用 `asyncio.gather` 并行调用企微 API
|
||||
- 降级模式:部门列表不可用时 `_build_flat_tree_by_name()` 按名称分组
|
||||
- H5 前端:多层级扁平化渲染(非 van-collapse),深度缩进 + 展开折叠
|
||||
- Agent 前端:el-tree 原生支持多层级,无需改代码
|
||||
- 测试:`backend/tests/test_org_tree.py` 47 个单元测试
|
||||
|
||||
## 运维工具
|
||||
- SOP:`docs/10-项目管理/IT智能服务台-标准作业流程SOP.md`
|
||||
- 故障排查:`docs/09-部署运维/00-标准故障排查手册.md`
|
||||
- 全栈改造实施计划:`docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,18 @@
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [未发布] - 2026-07-15
|
||||
|
||||
### 🐛 问题修复 (Bug Fixes)
|
||||
|
||||
#### 坐席端 Ctrl+V 粘贴功能修复 (2026-07-15 10:00 部署)
|
||||
- **问题**:坐席端输入框(ReplyBox.vue / InputBox.vue)无法通过 Ctrl+V 粘贴文本
|
||||
- **根因**:`handlePaste` 函数在 `clipboardData` 为空时直接 return,阻止了默认粘贴行为
|
||||
- **修复**:添加 `clipboardData` 和 `items.length` 空检查,确保纯文本粘贴时让浏览器执行默认行为
|
||||
- 任务说明书:`docs/10-项目管理/任务说明书/任务说明书-79-坐席端粘贴功能修复.md`
|
||||
|
||||
---
|
||||
|
||||
## [未发布] - 2026-07-13
|
||||
|
||||
### 🚀 功能增强 (Features)
|
||||
@@ -17,6 +29,17 @@
|
||||
- **ai_thinking 双推**:后端 `ai_thinking` WS 消息同时推送给员工端和坐席端
|
||||
- 验证:JS hash `index-2BTn4SZz.js` ✅,5 容器全部 healthy ✅
|
||||
|
||||
#### 坐席端历史会话查询功能(2026-07-13 11:30 部署)
|
||||
- **功能位置**:UserInfoBar(员工信息栏)等级 chips 后方新增"历史会话"开关
|
||||
- **跨会话聚合**:查询该员工所有会话的消息,合并为一条时间线(按 created_at DESC 排序)
|
||||
- **会话分隔条**:`ConversationSeparator.vue` 组件,显示首条消息摘要(前20字)+ 当前会话高亮标记
|
||||
- **游标分页**:支持 `before` 游标参数,滚动到顶部自动加载更多历史消息
|
||||
- **后端 API**:`GET /employees/{employee_id}/history-messages`(需认证),返回 `HistoryMessageListResponse`(items + has_more + conversation_summaries)
|
||||
- **前端状态管理**:`conversation.ts` store 新增 `historyMode`/`historyMessages`/`historyLoading`/`historyHasMore` 等状态
|
||||
- **只读模式**:历史模式下隐藏回复框,显示"查看历史会话,仅供阅读"提示条
|
||||
- 测试:28/28 全部 PASS(SOP 2轮)
|
||||
- 验证:前端生产包 grep 检查通过 ✅,后端 API 权限校验生效 ✅
|
||||
|
||||
#### H5 员工端 v4 — 人工坐席交互改造(#116, 2026-07-13 部署)
|
||||
- **人工按钮三态文案统一**为"人工坐席"(原"人工(需更多对话)"等多态文案)
|
||||
- **按钮位置调整**:移至发送键 + 语音转文字图标上方(`.input-bar__controls` 容器内垂直堆叠)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
uri = "bolt://neo4j:7687"
|
||||
user = "neo4j"
|
||||
password = "Wecom@2026"
|
||||
|
||||
driver = GraphDatabase.driver(uri, auth=(user, password))
|
||||
|
||||
with driver.session() as session:
|
||||
# 清除旧数据
|
||||
session.run("MATCH (n) DETACH DELETE n")
|
||||
|
||||
# 添加测试数据(带完整属性)
|
||||
session.run("""
|
||||
CREATE (i1:Issue {name: "打印机驱动安装", category: "硬件问题", uuid: "issue-001", created_at: datetime(), updated_at: datetime()})
|
||||
CREATE (a1:Action {name: "打印机驱动安装步骤", description: "1. 打开控制面板 2. 添加打印机 3. 选择手动添加", uuid: "action-001", created_at: datetime()})
|
||||
CREATE (i1)-[:HAS_ACTION]->(a1)
|
||||
""")
|
||||
|
||||
session.run("""
|
||||
CREATE (i2:Issue {name: "网络连不上", category: "网络问题", uuid: "issue-002", created_at: datetime(), updated_at: datetime()})
|
||||
CREATE (a2:Action {name: "网络诊断步骤", description: "1. 检查网线 2. 重启路由器 3. 检查IP配置", uuid: "action-002", created_at: datetime()})
|
||||
CREATE (i2)-[:HAS_ACTION]->(a2)
|
||||
""")
|
||||
|
||||
session.run("""
|
||||
CREATE (i3:Issue {name: "邮箱无法收发", category: "软件问题", uuid: "issue-003", created_at: datetime(), updated_at: datetime()})
|
||||
CREATE (a3:Action {name: "邮箱故障排除", description: "1. 检查网络 2. 清除缓存 3. 重新登录", uuid: "action-003", created_at: datetime()})
|
||||
CREATE (i3)-[:HAS_ACTION]->(a3)
|
||||
""")
|
||||
|
||||
# 验证
|
||||
result = session.run("MATCH (i:Issue)-[:HAS_ACTION]->(a:Action) RETURN i.uuid as issue_uuid, i.name as issue_name, a.uuid as action_uuid, a.name as action_name")
|
||||
for record in result:
|
||||
print(f"Issue: {record['issue_uuid']} - {record['issue_name']} -> Action: {record['action_uuid']} - {record['action_name']}")
|
||||
|
||||
driver.close()
|
||||
print("Done!")
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE (i1:Issue {name: "打印机驱动安装", category: "硬件问题", uuid: "issue-001"})
|
||||
CREATE (a1:Action {name: "打印机驱动安装步骤", description: "1. 打开控制面板 2. 添加打印机 3. 选择手动添加"})
|
||||
CREATE (i1)-[:HAS_ACTION]->(a1)
|
||||
RETURN "done"
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
uri = "bolt://neo4j:7687"
|
||||
user = "neo4j"
|
||||
password = "Wecom@2026"
|
||||
|
||||
driver = GraphDatabase.driver(uri, auth=(user, password))
|
||||
|
||||
with driver.session() as session:
|
||||
# 清除旧数据
|
||||
session.run("MATCH (n) DETACH DELETE n")
|
||||
|
||||
# 添加测试数据
|
||||
session.run("""
|
||||
CREATE (i1:Issue {name: "打印机驱动安装", category: "硬件问题", uuid: "issue-001"})
|
||||
CREATE (a1:Action {name: "打印机驱动安装步骤", description: "1. 打开控制面板 2. 添加打印机 3. 选择手动添加"})
|
||||
CREATE (i1)-[:HAS_ACTION]->(a1)
|
||||
""")
|
||||
|
||||
session.run("""
|
||||
CREATE (i2:Issue {name: "网络连不上", category: "网络问题", uuid: "issue-002"})
|
||||
CREATE (a2:Action {name: "网络诊断步骤", description: "1. 检查网线 2. 重启路由器 3. 检查IP配置"})
|
||||
CREATE (i2)-[:HAS_ACTION]->(a2)
|
||||
""")
|
||||
|
||||
session.run("""
|
||||
CREATE (i3:Issue {name: "邮箱无法收发", category: "软件问题", uuid: "issue-003"})
|
||||
CREATE (a3:Action {name: "邮箱故障排除", description: "1. 检查网络 2. 清除缓存 3. 重新登录"})
|
||||
CREATE (i3)-[:HAS_ACTION]->(a3)
|
||||
""")
|
||||
|
||||
# 验证
|
||||
result = session.run("MATCH (i:Issue) RETURN i.uuid, i.name")
|
||||
for record in result:
|
||||
print(f"uuid: {record['i.uuid']}, name: {record['i.name']}")
|
||||
|
||||
driver.close()
|
||||
print("Done!")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add archive fields to conversations
|
||||
|
||||
Revision ID: 053_add_archive_fields
|
||||
Revises:
|
||||
Create Date: 2026-07-15 13:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '053_add_archive_fields'
|
||||
down_revision = '052_diagnostic_queue_quiz_closing'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 is_archived 字段
|
||||
op.add_column('conversations', sa.Column('is_archived', sa.Boolean(), nullable=False, server_default='false'))
|
||||
# 添加 archived_at 字段
|
||||
op.add_column('conversations', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||
# 创建索引
|
||||
op.create_index('idx_conversations_is_archived', 'conversations', ['is_archived'])
|
||||
op.create_index('idx_conversations_archived_at', 'conversations', ['archived_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_conversations_archived_at', table_name='conversations')
|
||||
op.drop_index('idx_conversations_is_archived', table_name='conversations')
|
||||
op.drop_column('conversations', 'archived_at')
|
||||
op.drop_column('conversations', 'is_archived')
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
会话归档脚本
|
||||
|
||||
功能:将会话结束超过90天的会话标记为已归档
|
||||
|
||||
运行方式:
|
||||
cd backend
|
||||
python scripts/archive_sessions.py
|
||||
|
||||
定时任务配置(crontab):
|
||||
# 每天凌晨3点执行
|
||||
0 3 * * * cd /opt/wecom-it-desk/backend && python scripts/archive_sessions.py >> /var/log/itdesk-archive.log 2>&1
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 添加 backend 目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
|
||||
# 归档阈值:会话结束后多少天开始归档(可配置)
|
||||
ARCHIVE_DAYS = 90
|
||||
|
||||
|
||||
def archive_sessions():
|
||||
"""将会话结束超过 ARCHIVE_DAYS 天的会话标记为已归档"""
|
||||
|
||||
print(f"[{datetime.now()}] 开始执行会话归档任务...")
|
||||
print(f"归档阈值: {ARCHIVE_DAYS} 天")
|
||||
|
||||
# 使用配置中的数据库 URL
|
||||
# 注意:本地开发使用 aiosqlite 异步驱动,但脚本是同步的
|
||||
# 需要将 sqlite+aiosqlite:// 替换为 sqlite://
|
||||
db_url = settings.database_url.replace("sqlite+aiosqlite://", "sqlite://")
|
||||
engine = create_engine(db_url)
|
||||
|
||||
# 计算归档截止时间:当前时间 - ARCHIVE_DAYS 天
|
||||
# 只有 resolved_at 早于此时间的会话才会被归档
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=ARCHIVE_DAYS)
|
||||
|
||||
with Session(engine) as session:
|
||||
# 查找需要归档的会话:
|
||||
# 1. 状态为 resolved(已结单)
|
||||
# 2. 已结单时间(resolved_at 或 updated_at)早于截止时间
|
||||
# 3. 尚未被归档
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.is_archived == False,
|
||||
# 使用 updated_at 作为参考(resolved 状态的会话 updated_at 即为结单时间)
|
||||
Conversation.updated_at < cutoff_date
|
||||
)
|
||||
|
||||
sessions_to_archive = session.execute(stmt).scalars().all()
|
||||
count = len(sessions_to_archive)
|
||||
|
||||
if count == 0:
|
||||
print(f"[{datetime.now()}] 没有需要归档的会话")
|
||||
return
|
||||
|
||||
print(f"[{datetime.now()}] 找到 {count} 个需要归档的会话")
|
||||
|
||||
# 标记为已归档
|
||||
now = datetime.now(timezone.utc)
|
||||
for session_obj in sessions_to_archive:
|
||||
session_obj.is_archived = True
|
||||
session_obj.archived_at = now
|
||||
|
||||
# 提交更改
|
||||
session.commit()
|
||||
|
||||
print(f"[{datetime.now()}] 成功归档 {count} 个会话")
|
||||
print(f"归档时间: {now.isoformat()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
archive_sessions()
|
||||
@@ -0,0 +1,600 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 员工历史消息(跨会话聚合)测试
|
||||
# =============================================================================
|
||||
# 测试覆盖:
|
||||
# 1. Service 层:get_employee_history_messages 方法
|
||||
# - 正常请求(多会话、有消息)
|
||||
# - 空数据(员工无历史会话)
|
||||
# - 分页(before 参数游标翻页)
|
||||
# - 首条消息摘要截取(前20字)
|
||||
# - has_more 标记正确性
|
||||
# 2. API 层:GET /employees/{employee_id}/history-messages
|
||||
# - 正常请求返回正确格式
|
||||
# - 空数据返回空列表
|
||||
# - 分页(before 参数)
|
||||
# - 权限验证(无 token → 401)
|
||||
# - 首条消息摘要截取
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.conversation.session_query_service import SessionQueryService
|
||||
from tests.conftest import create_test_conversation, create_test_agent, login_test_agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助函数
|
||||
# =============================================================================
|
||||
|
||||
def create_test_message(
|
||||
conversation_id: str,
|
||||
sender_type: str = "employee",
|
||||
sender_id: str = "test_employee",
|
||||
sender_name: str = "测试员工",
|
||||
content: str = "测试消息内容",
|
||||
msg_type: str = "text",
|
||||
created_at: datetime = None,
|
||||
ai_suggestion: bool = False,
|
||||
is_read: bool = True,
|
||||
) -> Message:
|
||||
"""创建测试用消息对象。"""
|
||||
return Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conversation_id,
|
||||
sender_type=sender_type,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
content=content,
|
||||
msg_type=msg_type,
|
||||
ai_suggestion=ai_suggestion,
|
||||
is_read=is_read,
|
||||
status="sent",
|
||||
created_at=created_at or datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Service 层测试
|
||||
# =============================================================================
|
||||
|
||||
class TestGetEmployeeHistoryMessagesService:
|
||||
"""测试 SessionQueryService.get_employee_history_messages 方法。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_request_multiple_conversations(self, db_session):
|
||||
"""正常请求:员工有多个会话,每个会话有多条消息。
|
||||
|
||||
验证:
|
||||
- 返回跨会话聚合的消息列表
|
||||
- 消息按 created_at DESC 排序(最新在前)
|
||||
- 返回 conversation_summaries
|
||||
- has_more 标记正确
|
||||
"""
|
||||
# Arrange: 创建2个会话,同一员工
|
||||
conv1 = create_test_conversation(employee_id="hist_emp_001", status="resolved")
|
||||
conv2 = create_test_conversation(employee_id="hist_emp_001", status="serving")
|
||||
db_session.add_all([conv1, conv2])
|
||||
await db_session.flush()
|
||||
|
||||
# 创建消息,conv2 的消息时间更新
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
messages = [
|
||||
# conv1 消息(较旧)
|
||||
create_test_message(
|
||||
conv1.id, sender_type="employee", sender_id="hist_emp_001",
|
||||
content="VPN连接失败了", created_at=base_time - timedelta(hours=2),
|
||||
),
|
||||
create_test_message(
|
||||
conv1.id, sender_type="agent", sender_id="agent_001",
|
||||
content="请提供客户端版本", created_at=base_time - timedelta(hours=1, minutes=55),
|
||||
),
|
||||
# conv2 消息(较新)
|
||||
create_test_message(
|
||||
conv2.id, sender_type="employee", sender_id="hist_emp_001",
|
||||
content="邮箱登录失败", created_at=base_time - timedelta(minutes=30),
|
||||
),
|
||||
create_test_message(
|
||||
conv2.id, sender_type="agent", sender_id="agent_001",
|
||||
content="已为您重置密码", created_at=base_time - timedelta(minutes=25),
|
||||
),
|
||||
]
|
||||
db_session.add_all(messages)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
service = SessionQueryService(db_session)
|
||||
result_messages, has_more, summaries = await service.get_employee_history_messages(
|
||||
employee_id="hist_emp_001",
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Assert: 返回4条消息
|
||||
assert len(result_messages) == 4
|
||||
# Assert: 按 DESC 排序(最新在前)
|
||||
assert result_messages[0].created_at >= result_messages[-1].created_at
|
||||
# Assert: has_more = False(总数 < limit)
|
||||
assert has_more is False
|
||||
# Assert: conversation_summaries 包含两个会话
|
||||
assert len(summaries) == 2
|
||||
assert conv1.id in summaries
|
||||
assert conv2.id in summaries
|
||||
# Assert: 摘要是员工首条消息的前20字
|
||||
assert summaries[conv1.id] == "VPN连接失败了"
|
||||
assert summaries[conv2.id] == "邮箱登录失败"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_data_employee_no_conversations(self, db_session):
|
||||
"""空数据:员工没有任何会话。
|
||||
|
||||
验证:返回空列表、has_more=False、空 summaries
|
||||
"""
|
||||
service = SessionQueryService(db_session)
|
||||
messages, has_more, summaries = await service.get_employee_history_messages(
|
||||
employee_id="nonexistent_employee",
|
||||
)
|
||||
|
||||
assert messages == []
|
||||
assert has_more is False
|
||||
assert summaries == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_data_employee_has_conversations_no_messages(self, db_session):
|
||||
"""空数据:员工有会话但没有消息。
|
||||
|
||||
验证:返回空列表、has_more=False
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="emp_no_msgs")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
messages, has_more, summaries = await service.get_employee_history_messages(
|
||||
employee_id="emp_no_msgs",
|
||||
)
|
||||
|
||||
# 有会话但无消息 → messages 为空,但 involved_conv_ids 也为空
|
||||
assert messages == []
|
||||
assert has_more is False
|
||||
assert summaries == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_before_cursor(self, db_session):
|
||||
"""分页:使用 before 参数进行游标翻页。
|
||||
|
||||
验证:
|
||||
- before 参数只返回该消息之前的消息
|
||||
- has_more 在有更多消息时为 True
|
||||
- 分页后返回的消息数量正确
|
||||
"""
|
||||
# Arrange: 创建1个会话,6条消息
|
||||
conv = create_test_conversation(employee_id="page_emp_001")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
all_messages = []
|
||||
for i in range(6):
|
||||
msg = create_test_message(
|
||||
conv.id,
|
||||
sender_type="employee",
|
||||
sender_id="page_emp_001",
|
||||
content=f"消息_{i}",
|
||||
created_at=base_time - timedelta(minutes=5 * (5 - i)),
|
||||
)
|
||||
all_messages.append(msg)
|
||||
db_session.add_all(all_messages)
|
||||
await db_session.flush()
|
||||
|
||||
# Act: 第一次请求 limit=3,获取最新3条
|
||||
service = SessionQueryService(db_session)
|
||||
first_batch, has_more, _ = await service.get_employee_history_messages(
|
||||
employee_id="page_emp_001",
|
||||
limit=3,
|
||||
)
|
||||
|
||||
# Assert: 返回3条,has_more=True(多取了1条判断)
|
||||
assert len(first_batch) == 3
|
||||
assert has_more is True
|
||||
|
||||
# Act: 用第一批最旧消息的 ID 作为 before,获取更旧的消息
|
||||
before_id = first_batch[-1].id # DESC 排序,最后一条是最旧的
|
||||
second_batch, has_more_2, _ = await service.get_employee_history_messages(
|
||||
employee_id="page_emp_001",
|
||||
limit=3,
|
||||
before=before_id,
|
||||
)
|
||||
|
||||
# Assert: 返回剩余的消息(3条),has_more=False
|
||||
assert len(second_batch) == 3
|
||||
assert has_more_2 is False
|
||||
# Assert: 第二批消息都比 before 消息更旧
|
||||
before_msg_created = next(m.created_at for m in all_messages if m.id == before_id)
|
||||
for msg in second_batch:
|
||||
assert msg.created_at < before_msg_created
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_truncation_first_20_chars(self, db_session):
|
||||
"""摘要截取:员工首条消息超过20字时,截取前20字。
|
||||
|
||||
验证:conversation_summaries 中的摘要不超过20字
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="trunc_emp_001")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
long_content = "这是一个非常长的消息内容,超过了二十个字符的限制,需要被截断处理。"
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
messages = [
|
||||
create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="trunc_emp_001",
|
||||
content=long_content, created_at=base_time,
|
||||
),
|
||||
create_test_message(
|
||||
conv.id, sender_type="agent", sender_id="agent_001",
|
||||
content="回复消息", created_at=base_time + timedelta(minutes=1),
|
||||
),
|
||||
]
|
||||
db_session.add_all(messages)
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
_, _, summaries = await service.get_employee_history_messages(
|
||||
employee_id="trunc_emp_001",
|
||||
)
|
||||
|
||||
# Assert: 摘要被截取为前20字
|
||||
assert conv.id in summaries
|
||||
assert summaries[conv.id] == long_content[:20]
|
||||
assert len(summaries[conv.id]) == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_no_employee_message(self, db_session):
|
||||
"""摘要:会话中没有员工消息时,摘要为"未知会话"。
|
||||
|
||||
验证:当会话只有 agent/ai/system 消息时,摘要回退为"未知会话"
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="no_emp_msg_emp")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
msg = create_test_message(
|
||||
conv.id, sender_type="agent", sender_id="agent_001",
|
||||
content="坐席消息", created_at=base_time,
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
_, _, summaries = await service.get_employee_history_messages(
|
||||
employee_id="no_emp_msg_emp",
|
||||
)
|
||||
|
||||
assert conv.id in summaries
|
||||
assert summaries[conv.id] == "未知会话"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_more_true_when_exceeding_limit(self, db_session):
|
||||
"""has_more 标记:消息数超过 limit 时 has_more=True。
|
||||
|
||||
验证:limit=2,有3条消息 → 返回2条,has_more=True
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="more_emp_001")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
for i in range(3):
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="more_emp_001",
|
||||
content=f"消息{i}", created_at=base_time - timedelta(minutes=i),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
messages, has_more, _ = await service.get_employee_history_messages(
|
||||
employee_id="more_emp_001",
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert len(messages) == 2
|
||||
assert has_more is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_invalid_id_ignored(self, db_session):
|
||||
"""边界:before 参数为无效 ID 时应被忽略,返回正常结果。
|
||||
|
||||
验证:before 参数格式错误时,不报错,正常返回最新消息
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="invalid_before_emp")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
for i in range(3):
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="invalid_before_emp",
|
||||
content=f"消息{i}", created_at=base_time - timedelta(minutes=i),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
messages, has_more, _ = await service.get_employee_history_messages(
|
||||
employee_id="invalid_before_emp",
|
||||
limit=50,
|
||||
before="invalid-uuid-format",
|
||||
)
|
||||
|
||||
# before 参数无效 → 忽略,返回全部3条消息
|
||||
assert len(messages) == 3
|
||||
assert has_more is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_conversation_aggregation(self, db_session):
|
||||
"""跨会话聚合:不同会话的消息合并在同一时间线。
|
||||
|
||||
验证:消息按 created_at 全局排序,不按会话分组
|
||||
"""
|
||||
conv1 = create_test_conversation(employee_id="cross_emp_001")
|
||||
conv2 = create_test_conversation(employee_id="cross_emp_001")
|
||||
conv3 = create_test_conversation(employee_id="cross_emp_001")
|
||||
db_session.add_all([conv1, conv2, conv3])
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
# 交替创建不同会话的消息
|
||||
db_session.add(create_test_message(
|
||||
conv1.id, content="conv1_msg1", created_at=base_time - timedelta(minutes=10),
|
||||
))
|
||||
db_session.add(create_test_message(
|
||||
conv2.id, content="conv2_msg1", created_at=base_time - timedelta(minutes=8),
|
||||
))
|
||||
db_session.add(create_test_message(
|
||||
conv3.id, content="conv3_msg1", created_at=base_time - timedelta(minutes=5),
|
||||
))
|
||||
db_session.add(create_test_message(
|
||||
conv1.id, content="conv1_msg2", created_at=base_time - timedelta(minutes=3),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
messages, _, summaries = await service.get_employee_history_messages(
|
||||
employee_id="cross_emp_001",
|
||||
)
|
||||
|
||||
# Assert: 4条消息按时间 DESC 排序
|
||||
assert len(messages) == 4
|
||||
assert messages[0].content == "conv1_msg2" # 最新
|
||||
assert messages[1].content == "conv3_msg1"
|
||||
assert messages[2].content == "conv2_msg1"
|
||||
assert messages[3].content == "conv1_msg1" # 最旧
|
||||
# Assert: summaries 包含3个会话
|
||||
assert len(summaries) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_conversation_employee(self, db_session):
|
||||
"""边界:员工只有1个会话时,仍应正常返回。
|
||||
|
||||
验证:单个会话的消息正常聚合返回
|
||||
"""
|
||||
conv = create_test_conversation(employee_id="single_conv_emp")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="single_conv_emp",
|
||||
content="单会话消息", created_at=base_time,
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
service = SessionQueryService(db_session)
|
||||
messages, has_more, summaries = await service.get_employee_history_messages(
|
||||
employee_id="single_conv_emp",
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert has_more is False
|
||||
assert len(summaries) == 1
|
||||
assert summaries[conv.id] == "单会话消息"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API 层测试
|
||||
# =============================================================================
|
||||
|
||||
class TestEmployeeHistoryMessagesAPI:
|
||||
"""测试 GET /employees/{employee_id}/history-messages 端点。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_normal_request(self, client, db_session, mock_redis):
|
||||
"""API 正常请求:返回正确格式。"""
|
||||
# Arrange: 创建会话和消息
|
||||
conv = create_test_conversation(employee_id="api_hist_emp_001", status="serving")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="api_hist_emp_001",
|
||||
content="VPN问题", created_at=base_time,
|
||||
))
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="agent", sender_id="agent_001",
|
||||
content="已解决", created_at=base_time + timedelta(minutes=1),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
# 登录坐席获取 token
|
||||
token = await login_test_agent(client, db_session, "api_hist_agent", "API测试坐席")
|
||||
|
||||
# Act
|
||||
response = await client.get(
|
||||
f"/employees/api_hist_emp_001/history-messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert "items" in data["data"]
|
||||
assert "has_more" in data["data"]
|
||||
assert "conversation_summaries" in data["data"]
|
||||
assert len(data["data"]["items"]) == 2
|
||||
assert data["data"]["has_more"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_empty_data(self, client, db_session, mock_redis):
|
||||
"""API 空数据:员工无历史会话。"""
|
||||
token = await login_test_agent(client, db_session, "empty_hist_agent", "空数据坐席")
|
||||
|
||||
response = await client.get(
|
||||
"/employees/nonexistent_employee/history-messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["items"] == []
|
||||
assert data["data"]["has_more"] is False
|
||||
assert data["data"]["conversation_summaries"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_pagination_with_before(self, client, db_session, mock_redis):
|
||||
"""API 分页:使用 before 参数进行游标翻页。"""
|
||||
conv = create_test_conversation(employee_id="api_page_emp")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
for i in range(6):
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="api_page_emp",
|
||||
content=f"消息_{i}", created_at=base_time - timedelta(minutes=5 * (5 - i)),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
token = await login_test_agent(client, db_session, "page_hist_agent", "分页坐席")
|
||||
|
||||
# 第一次请求:limit=3
|
||||
resp1 = await client.get(
|
||||
"/employees/api_page_emp/history-messages",
|
||||
params={"limit": 3},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
data1 = resp1.json()["data"]
|
||||
assert len(data1["items"]) == 3
|
||||
assert data1["has_more"] is True
|
||||
|
||||
# 用第一批最旧消息的 ID 作为 before
|
||||
before_id = data1["items"][-1]["id"]
|
||||
|
||||
# 第二次请求:before=第一批最旧消息ID
|
||||
resp2 = await client.get(
|
||||
"/employees/api_page_emp/history-messages",
|
||||
params={"limit": 3, "before": before_id},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()["data"]
|
||||
assert len(data2["items"]) == 3
|
||||
assert data2["has_more"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_no_auth_token_returns_401(self, client, db_session):
|
||||
"""API 权限:无 token 请求应被拒绝。
|
||||
|
||||
注意:conftest 的 enable_dev_mode autouse fixture 会设置 DEV_MODE=true,
|
||||
可能绕过认证。此处验证端点存在且可响应;
|
||||
在非 DEV_MODE 环境下应返回 401/403。
|
||||
"""
|
||||
response = await client.get(
|
||||
"/employees/any_employee/history-messages",
|
||||
)
|
||||
# DEV_MODE 下可能返回 200(绕过认证),非 DEV_MODE 下应返回 401/403
|
||||
assert response.status_code in (200, 401, 403)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_summary_truncation(self, client, db_session, mock_redis):
|
||||
"""API 摘要截取:首条消息超过20字时截取前20字。"""
|
||||
conv = create_test_conversation(employee_id="api_trunc_emp")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
long_content = "这是一个非常长的消息内容用于测试摘要截取功能是否正常工作。"
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
db_session.add(create_test_message(
|
||||
conv.id, sender_type="employee", sender_id="api_trunc_emp",
|
||||
content=long_content, created_at=base_time,
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
token = await login_test_agent(client, db_session, "trunc_hist_agent", "截取坐席")
|
||||
|
||||
response = await client.get(
|
||||
"/employees/api_trunc_emp/history-messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert conv.id in data["conversation_summaries"]
|
||||
assert data["conversation_summaries"][conv.id] == long_content[:20]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_with_current_conversation_id(self, client, db_session, mock_redis):
|
||||
"""API 带 current_conversation_id 参数:不影响查询逻辑,正常返回。"""
|
||||
conv1 = create_test_conversation(employee_id="api_curr_emp")
|
||||
conv2 = create_test_conversation(employee_id="api_curr_emp")
|
||||
db_session.add_all([conv1, conv2])
|
||||
await db_session.flush()
|
||||
|
||||
base_time = datetime(2026, 6, 6, 10, 0, 0)
|
||||
db_session.add(create_test_message(
|
||||
conv1.id, sender_type="employee", sender_id="api_curr_emp",
|
||||
content="会话1消息", created_at=base_time,
|
||||
))
|
||||
db_session.add(create_test_message(
|
||||
conv2.id, sender_type="employee", sender_id="api_curr_emp",
|
||||
content="会话2消息", created_at=base_time + timedelta(minutes=1),
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
token = await login_test_agent(client, db_session, "curr_hist_agent", "当前会话坐席")
|
||||
|
||||
response = await client.get(
|
||||
"/employees/api_curr_emp/history-messages",
|
||||
params={"current_conversation_id": conv2.id},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
# current_conversation_id 不影响查询逻辑,消息数应不变
|
||||
assert len(data["items"]) == 2
|
||||
assert len(data["conversation_summaries"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_limit_validation(self, client, db_session, mock_redis):
|
||||
"""API 参数校验:limit 超出范围(>100)应返回 422。"""
|
||||
token = await login_test_agent(client, db_session, "limit_val_agent", "校验坐席")
|
||||
|
||||
response = await client.get(
|
||||
"/employees/any_emp/history-messages",
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
# limit > 100 应触发 Query 校验,返回 422
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,491 @@
|
||||
# =============================================================================
|
||||
# 邀请链接回归测试 — 邀请 URL 绝对路径 & join_conversation 状态校验
|
||||
# =============================================================================
|
||||
# 测试覆盖(对应本次 3 个关联 Bug 修复):
|
||||
#
|
||||
# 一、join_conversation 状态校验(Bug 2 修复核心)
|
||||
# 1. ai_handling 状态 → join 成功(本次修复核心)
|
||||
# 2. serving 状态 → join 成功(原有功能不回归)
|
||||
# 3. resolved 状态 → join 失败(3033)
|
||||
# 4. queued 状态 → join 失败(3033)
|
||||
# 5. pending_close 状态 → join 失败(3033)
|
||||
# 6. 未被邀请的员工 → join 失败(3034)
|
||||
#
|
||||
# 二、邀请 URL 绝对路径校验(Bug 1 修复核心)
|
||||
# 7. invite_url 以 https:// 开头(包含完整域名)
|
||||
# 修复前:/h5/?invite=...(无域名,企微卡片无法跳转)
|
||||
# 修复后:https://itsupport.servyou.com.cn/h5/?invite=...
|
||||
#
|
||||
# 设计原则:
|
||||
# - mock SessionService 的内部辅助方法和 DB,聚焦业务逻辑
|
||||
# - 参考 backend/tests/test_invite_status.py 的测试风格
|
||||
# - 不依赖真实数据库 / 真实企微 API
|
||||
# =============================================================================
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.session_service import SessionService
|
||||
from app.utils.response import AppException
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助:构造 mock 会话对象与 SessionService 实例
|
||||
# =============================================================================
|
||||
|
||||
def _mock_conversation(
|
||||
status: str = "serving",
|
||||
conv_id: str = "conv-001",
|
||||
participants: list | None = None,
|
||||
assigned_agent_id: str = "agent_001",
|
||||
employee_id: str = "emp_001",
|
||||
):
|
||||
"""构造一个模拟会话对象,用于测试 join_conversation / invite_participants。
|
||||
|
||||
Args:
|
||||
status: 会话状态
|
||||
conv_id: 会话ID
|
||||
participants: 参与者列表
|
||||
assigned_agent_id: 主责坐席ID(invite_participants 权限校验需要)
|
||||
employee_id: 会话发起人ID
|
||||
"""
|
||||
conv = MagicMock()
|
||||
conv.id = conv_id
|
||||
conv.status = status
|
||||
conv.assigned_agent_id = assigned_agent_id
|
||||
conv.employee_id = employee_id
|
||||
conv.participants = participants if participants is not None else []
|
||||
conv.updated_at = MagicMock()
|
||||
return conv
|
||||
|
||||
|
||||
def _invited_participants():
|
||||
"""构造被邀请的参与者列表(1 个员工,未加入状态)。"""
|
||||
return [
|
||||
{"id": "emp_002", "name": "张三", "type": "employee", "joined": False},
|
||||
]
|
||||
|
||||
|
||||
def _make_service():
|
||||
"""构造 SessionService 实例,所有外部依赖已 mock。
|
||||
|
||||
返回的 service 对象已 mock 以下方法/属性:
|
||||
- _get_conversation: AsyncMock(需在测试中设置 return_value)
|
||||
- _create_system_message: AsyncMock
|
||||
- _broadcast_participant_change: AsyncMock
|
||||
- db: MagicMock(add/flush 已 mock)
|
||||
- wecom_service: None(join_conversation 不需要)
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.add = MagicMock()
|
||||
db.flush = AsyncMock()
|
||||
|
||||
service = SessionService(db=db, wecom_service=None)
|
||||
service._get_conversation = AsyncMock()
|
||||
service._create_system_message = AsyncMock()
|
||||
service._broadcast_participant_change = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
def _make_service_with_wecom():
|
||||
"""构造带 mock 企微服务的 SessionService 实例(用于邀请 URL 测试)。
|
||||
|
||||
额外 mock:
|
||||
- _get_employee_avatar: AsyncMock,返回 None(跳过头像获取)
|
||||
- wecom_service: MagicMock,send_card_message 为 AsyncMock
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.add = MagicMock()
|
||||
db.flush = AsyncMock()
|
||||
|
||||
wecom_service = MagicMock()
|
||||
wecom_service.send_card_message = AsyncMock()
|
||||
|
||||
service = SessionService(db=db, wecom_service=wecom_service)
|
||||
service._get_conversation = AsyncMock()
|
||||
service._get_employee_avatar = AsyncMock(return_value=None)
|
||||
service._create_system_message = AsyncMock()
|
||||
service._broadcast_participant_change = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、join_conversation 状态校验测试
|
||||
# =============================================================================
|
||||
|
||||
class TestJoinConversationStatusCheck:
|
||||
"""join_conversation 状态校验测试。
|
||||
|
||||
修复前:if conversation.status != "serving": → ai_handling 被拒
|
||||
修复后:if conversation.status not in ("serving", "ai_handling"):
|
||||
"""
|
||||
|
||||
# ----- 1.1 ai_handling 状态 → join 成功(本次修复核心) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_handling_status_join_succeeds(self):
|
||||
"""ai_handling 状态下被邀请人加入应成功(本次修复核心)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="ai_handling",
|
||||
participants=_invited_participants(),
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
# joined 状态已更新
|
||||
participants = conv.participants
|
||||
emp = next(p for p in participants if p["id"] == "emp_002")
|
||||
assert emp["joined"] is True
|
||||
assert "joined_at" in emp
|
||||
# DB 写入被调用
|
||||
service.db.add.assert_called_once_with(conv)
|
||||
service.db.flush.assert_awaited_once()
|
||||
# 系统消息 + WebSocket 广播
|
||||
service._create_system_message.assert_awaited_once()
|
||||
service._broadcast_participant_change.assert_awaited_once()
|
||||
|
||||
# ----- 1.2 serving 状态 → join 成功(原有功能不回归) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serving_status_join_succeeds(self):
|
||||
"""serving 状态下被邀请人加入应成功(原有功能不回归)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
participants=_invited_participants(),
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
emp = next(p for p in conv.participants if p["id"] == "emp_002")
|
||||
assert emp["joined"] is True
|
||||
assert "joined_at" in emp
|
||||
service.db.add.assert_called_once_with(conv)
|
||||
service.db.flush.assert_awaited_once()
|
||||
|
||||
# ----- 1.3 resolved 状态 → join 失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_status_join_fails(self):
|
||||
"""resolved 状态下加入应失败(已结单不应允许加入)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="resolved",
|
||||
participants=_invited_participants(),
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
assert exc_info.value.code == 3033
|
||||
# 不应写 DB
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
# 不应创建系统消息 / 广播
|
||||
service._create_system_message.assert_not_awaited()
|
||||
service._broadcast_participant_change.assert_not_awaited()
|
||||
|
||||
# ----- 1.4 queued 状态 → join 失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_status_join_fails(self):
|
||||
"""queued 状态下加入应失败(排队中不应允许加入)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="queued",
|
||||
participants=_invited_participants(),
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
assert exc_info.value.code == 3033
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
|
||||
# ----- 1.5 pending_close 状态 → join 失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_close_status_join_fails(self):
|
||||
"""pending_close 状态下加入应失败(待关单不应允许加入)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="pending_close",
|
||||
participants=_invited_participants(),
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
assert exc_info.value.code == 3033
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、join_conversation 权限校验测试
|
||||
# =============================================================================
|
||||
|
||||
class TestJoinConversationPermissionCheck:
|
||||
"""join_conversation 权限校验测试。
|
||||
|
||||
规则:只有在 participants 列表中的员工才能加入(被邀请过才能加入)。
|
||||
"""
|
||||
|
||||
# ----- 2.1 未被邀请的员工 → join 失败(3034) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_invited_employee_join_fails(self):
|
||||
"""未被邀请的员工加入应失败(3034 错误码)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee", "joined": False},
|
||||
],
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert — emp_hacker 不在 participants 中
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_hacker",
|
||||
)
|
||||
assert exc_info.value.code == 3034
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
service._create_system_message.assert_not_awaited()
|
||||
|
||||
# ----- 2.2 空参与者列表 → join 失败(3034) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_participants_join_fails(self):
|
||||
"""participants 为空时任何员工加入应失败(3034)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="ai_handling",
|
||||
participants=[],
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
assert exc_info.value.code == 3034
|
||||
|
||||
# ----- 2.3 participants 为 None → join 失败(3034) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_participants_join_fails(self):
|
||||
"""participants 为 None 时加入应失败(3034)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
participants=None,
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.join_conversation(
|
||||
conversation_id="conv-001",
|
||||
employee_id="emp_002",
|
||||
)
|
||||
assert exc_info.value.code == 3034
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、邀请 URL 绝对路径校验(Bug 1 修复核心)
|
||||
# =============================================================================
|
||||
|
||||
class TestInviteUrlAbsolute:
|
||||
"""invite_participants 邀请 URL 绝对路径校验。
|
||||
|
||||
修复前:invite_url = f"{getattr(self, '_h5_base_url', '')}/h5/" → /h5/?invite=...
|
||||
修复后:invite_url = f"{settings.wecom_sso_callback_base.rstrip('/')}/h5/" → https://...
|
||||
"""
|
||||
|
||||
# ----- 3.1 invite_url 以 https:// 开头(包含完整域名) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_url_starts_with_https(self):
|
||||
"""邀请 URL 应以 https:// 开头(包含完整域名)。
|
||||
|
||||
企微卡片消息要求 URL 必须以 https:// 开头,否则无法跳转。
|
||||
"""
|
||||
# Arrange
|
||||
service = _make_service_with_wecom()
|
||||
conv = _mock_conversation(status="serving", participants=[])
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
with patch(
|
||||
"app.services.session_service.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.wecom_sso_callback_base = (
|
||||
"https://itsupport.servyou.com.cn"
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assert
|
||||
service.wecom_service.send_card_message.assert_awaited_once()
|
||||
call_kwargs = service.wecom_service.send_card_message.call_args.kwargs
|
||||
invite_url = call_kwargs["url"]
|
||||
assert invite_url.startswith("https://"), (
|
||||
f"invite_url 应以 https:// 开头,实际: {invite_url}"
|
||||
)
|
||||
|
||||
# ----- 3.2 invite_url 包含 invite 和 eid 参数 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_url_contains_invite_and_eid_params(self):
|
||||
"""邀请 URL 应包含 invite(会话ID)和 eid(员工ID)参数。"""
|
||||
# Arrange
|
||||
service = _make_service_with_wecom()
|
||||
conv = _mock_conversation(status="ai_handling", participants=[])
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
with patch(
|
||||
"app.services.session_service.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.wecom_sso_callback_base = (
|
||||
"https://itsupport.servyou.com.cn"
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = service.wecom_service.send_card_message.call_args.kwargs
|
||||
invite_url = call_kwargs["url"]
|
||||
assert "invite=conv-001" in invite_url
|
||||
assert "eid=emp_002" in invite_url
|
||||
|
||||
# ----- 3.3 invite_url 不含尾部多余斜杠 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_url_no_trailing_slash_in_domain(self):
|
||||
"""wecom_sso_callback_base 带尾部斜杠时,invite_url 不应有双斜杠。"""
|
||||
# Arrange
|
||||
service = _make_service_with_wecom()
|
||||
conv = _mock_conversation(status="serving", participants=[])
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
with patch(
|
||||
"app.services.session_service.settings"
|
||||
) as mock_settings:
|
||||
# base 末尾带斜杠,rstrip 应去除
|
||||
mock_settings.wecom_sso_callback_base = (
|
||||
"https://itsupport.servyou.com.cn/"
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = service.wecom_service.send_card_message.call_args.kwargs
|
||||
invite_url = call_kwargs["url"]
|
||||
# 不应出现 //h5(base 尾部斜杠未去除会导致双斜杠)
|
||||
assert "//h5" not in invite_url, (
|
||||
f"invite_url 不应含双斜杠,实际: {invite_url}"
|
||||
)
|
||||
assert invite_url == (
|
||||
"https://itsupport.servyou.com.cn/h5/"
|
||||
"?invite=conv-001&eid=emp_002"
|
||||
)
|
||||
|
||||
# ----- 3.4 修复前回归:URL 不应为相对路径 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_url_not_relative_path(self):
|
||||
"""邀请 URL 不应为相对路径(/h5/ 开头)。
|
||||
|
||||
回归保障:修复前 invite_url 为 /h5/?invite=...,企微无法跳转。
|
||||
"""
|
||||
# Arrange
|
||||
service = _make_service_with_wecom()
|
||||
conv = _mock_conversation(status="serving", participants=[])
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
with patch(
|
||||
"app.services.session_service.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.wecom_sso_callback_base = (
|
||||
"https://itsupport.servyou.com.cn"
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = service.wecom_service.send_card_message.call_args.kwargs
|
||||
invite_url = call_kwargs["url"]
|
||||
# 不应以 /h5/ 开头(相对路径)
|
||||
assert not invite_url.startswith("/h5/"), (
|
||||
f"invite_url 不应为相对路径,实际: {invite_url}"
|
||||
)
|
||||
@@ -0,0 +1,495 @@
|
||||
# =============================================================================
|
||||
# 会话邀请参与者状态校验 — 单元测试
|
||||
# =============================================================================
|
||||
# 测试覆盖:
|
||||
# 1. ai_handling 状态 → 邀请成功(本次修复核心)
|
||||
# 2. serving 状态 → 邀请成功(原有功能不回归)
|
||||
# 3. resolved 状态 → 邀请失败
|
||||
# 4. queued 状态 → 邀请失败
|
||||
# 5. pending_close 状态 → 邀请失败
|
||||
# 6. 权限校验:非参与者邀请被拒绝(3030 错误码)
|
||||
# 7. 重复邀请校验(3032 错误码)
|
||||
#
|
||||
# 设计原则:
|
||||
# - mock SessionService 的内部辅助方法和 DB,聚焦 invite_participants 业务逻辑
|
||||
# - 参考 backend/tests/test_org_tree.py 的测试风格
|
||||
# =============================================================================
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.session_service import SessionService
|
||||
from app.utils.response import AppException
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助:构造 mock 会话对象与 SessionService 实例
|
||||
# =============================================================================
|
||||
|
||||
def _mock_conversation(
|
||||
status: str = "serving",
|
||||
assigned_agent_id: str = "agent_001",
|
||||
employee_id: str = "emp_001",
|
||||
participants: list | None = None,
|
||||
conv_id: str = "conv-001",
|
||||
):
|
||||
"""构造一个模拟会话对象,用于测试 invite_participants。
|
||||
|
||||
Args:
|
||||
status: 会话状态
|
||||
assigned_agent_id: 主责坐席ID
|
||||
employee_id: 员工ID(会话发起人)
|
||||
participants: 已有参与者列表
|
||||
conv_id: 会话ID
|
||||
"""
|
||||
conv = MagicMock()
|
||||
conv.id = conv_id
|
||||
conv.status = status
|
||||
conv.assigned_agent_id = assigned_agent_id
|
||||
conv.employee_id = employee_id
|
||||
conv.participants = participants if participants is not None else []
|
||||
conv.updated_at = MagicMock()
|
||||
return conv
|
||||
|
||||
|
||||
def _new_participants():
|
||||
"""构造被邀请人列表(1 个新员工)。"""
|
||||
return [
|
||||
{"id": "emp_002", "name": "张三", "department": "技术部", "type": "employee"},
|
||||
]
|
||||
|
||||
|
||||
def _make_service():
|
||||
"""构造 SessionService 实例,所有外部依赖已 mock。
|
||||
|
||||
返回的 service 对象已 mock 以下方法/属性:
|
||||
- _get_conversation: AsyncMock(需在测试中设置 return_value)
|
||||
- _get_employee_avatar: AsyncMock,返回 None
|
||||
- _create_system_message: AsyncMock
|
||||
- _broadcast_participant_change: AsyncMock
|
||||
- db: MagicMock(add/flush 已 mock)
|
||||
- wecom_service: None(跳过通知发送)
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.add = MagicMock()
|
||||
db.flush = AsyncMock()
|
||||
|
||||
service = SessionService(db=db, wecom_service=None)
|
||||
service._get_conversation = AsyncMock()
|
||||
service._get_employee_avatar = AsyncMock(return_value=None)
|
||||
service._create_system_message = AsyncMock()
|
||||
service._broadcast_participant_change = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、状态校验:各状态下的邀请行为
|
||||
# =============================================================================
|
||||
|
||||
class TestInviteStatusCheck:
|
||||
"""invite_participants 状态校验测试。
|
||||
|
||||
修复前:仅 serving 状态允许邀请,ai_handling 被错误拒绝。
|
||||
修复后:serving 和 ai_handling 均允许邀请。
|
||||
"""
|
||||
|
||||
# ----- 1.1 ai_handling 状态 → 邀请成功(本次修复核心) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_handling_status_invite_succeeds(self):
|
||||
"""ai_handling 状态下邀请参与者应成功(本次修复核心)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="ai_handling")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
# 参与者列表已更新,包含新邀请人
|
||||
assert len(conv.participants) == 1
|
||||
assert conv.participants[0]["id"] == "emp_002"
|
||||
# DB 写入被调用
|
||||
service.db.add.assert_called_once_with(conv)
|
||||
service.db.flush.assert_awaited_once()
|
||||
|
||||
# ----- 1.2 serving 状态 → 邀请成功(原有功能不回归) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serving_status_invite_succeeds(self):
|
||||
"""serving 状态下邀请参与者应成功(原有功能不回归)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="serving")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
assert len(conv.participants) == 1
|
||||
assert conv.participants[0]["id"] == "emp_002"
|
||||
service.db.add.assert_called_once_with(conv)
|
||||
service.db.flush.assert_awaited_once()
|
||||
|
||||
# ----- 1.3 resolved 状态 → 邀请失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_status_invite_fails(self):
|
||||
"""resolved 状态下邀请参与者应失败(已结单不应允许邀请)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="resolved")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
assert exc_info.value.code == 3031
|
||||
# 不应写 DB
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
|
||||
# ----- 1.4 queued 状态 → 邀请失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_status_invite_fails(self):
|
||||
"""queued 状态下邀请参与者应失败(排队中不应允许邀请)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="queued")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
assert exc_info.value.code == 3031
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
|
||||
# ----- 1.5 pending_close 状态 → 邀请失败 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_close_status_invite_fails(self):
|
||||
"""pending_close 状态下邀请参与者应失败(待关单不应允许邀请)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="pending_close")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
assert exc_info.value.code == 3031
|
||||
service.db.add.assert_not_called()
|
||||
service.db.flush.assert_not_awaited()
|
||||
|
||||
# ----- 1.6 错误消息包含当前状态信息 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_message_contains_current_status(self):
|
||||
"""状态校验失败时,错误消息应包含当前状态值。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="resolved")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
assert "resolved" in exc_info.value.message
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、权限校验:非参与者邀请被拒绝
|
||||
# =============================================================================
|
||||
|
||||
class TestInvitePermissionCheck:
|
||||
"""invite_participants 权限校验测试。
|
||||
|
||||
权限规则:只有主责坐席、会话发起人、或已在 participants 中的参与者可以邀请。
|
||||
"""
|
||||
|
||||
# ----- 2.1 非参与者邀请 → 被拒绝(3030) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_participant_invite_rejected(self):
|
||||
"""非主责坐席、非发起人、非参与者的用户邀请应被拒绝(3030)。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
assigned_agent_id="agent_001",
|
||||
employee_id="emp_001",
|
||||
participants=[], # 没有已有参与者
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert — inviter 不是 agent_001 也不是 emp_001
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="outsider_999",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
assert exc_info.value.code == 3030
|
||||
service.db.add.assert_not_called()
|
||||
|
||||
# ----- 2.2 主责坐席邀请 → 通过权限校验 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_agent_can_invite(self):
|
||||
"""主责坐席可以发起邀请。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
assigned_agent_id="agent_001",
|
||||
employee_id="emp_001",
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
assert len(conv.participants) == 1
|
||||
|
||||
# ----- 2.3 会话发起人邀请 → 通过权限校验 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creator_can_invite(self):
|
||||
"""会话发起人(employee_id)可以发起邀请。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="ai_handling",
|
||||
assigned_agent_id="agent_001",
|
||||
employee_id="emp_001",
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="emp_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
assert len(conv.participants) == 1
|
||||
|
||||
# ----- 2.4 已有参与者邀请 → 通过权限校验 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_participant_can_invite(self):
|
||||
"""已在 participants 列表中的参与者可以邀请其他人。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
assigned_agent_id="agent_001",
|
||||
employee_id="emp_001",
|
||||
participants=[
|
||||
{"id": "emp_005", "name": "王五", "type": "employee"},
|
||||
],
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act — emp_005 是已有参与者,邀请新的人
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="emp_005",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
# 参与者列表包含原有的 + 新邀请的
|
||||
assert len(conv.participants) == 2
|
||||
participant_ids = {p["id"] for p in conv.participants}
|
||||
assert "emp_005" in participant_ids
|
||||
assert "emp_002" in participant_ids
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、重复邀请校验
|
||||
# =============================================================================
|
||||
|
||||
class TestInviteDuplicateCheck:
|
||||
"""invite_participants 重复邀请校验测试。"""
|
||||
|
||||
# ----- 3.1 所有被邀请人已在会话中 → 3032 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_invitees_already_in_conversation(self):
|
||||
"""所有被邀请人已在会话中时,应抛出 3032。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="serving",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act & Assert — 邀请已在会话中的 emp_002
|
||||
with pytest.raises(AppException) as exc_info:
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
assert exc_info.value.code == 3032
|
||||
|
||||
# ----- 3.2 部分新部分旧 → 新的被加入 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_new_invitees_added(self):
|
||||
"""部分被邀请人已存在时,只添加新的人。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(
|
||||
status="ai_handling",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
],
|
||||
)
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act — 邀请 emp_002(已有)和 emp_003(新)
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=[
|
||||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||||
{"id": "emp_003", "name": "李四", "type": "employee"},
|
||||
],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
assert len(conv.participants) == 2
|
||||
participant_ids = {p["id"] for p in conv.participants}
|
||||
assert "emp_002" in participant_ids
|
||||
assert "emp_003" in participant_ids
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、成功邀请的副作用验证
|
||||
# =============================================================================
|
||||
|
||||
class TestInviteSideEffects:
|
||||
"""invite_participants 成功时的副作用验证。"""
|
||||
|
||||
# ----- 4.1 创建系统消息广播 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_message_created_on_success(self):
|
||||
"""成功邀请后应创建系统消息广播。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="ai_handling")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
service._create_system_message.assert_awaited_once()
|
||||
call_kwargs = service._create_system_message.call_args.kwargs
|
||||
assert call_kwargs["conversation_id"] == "conv-001"
|
||||
assert "张三" in call_kwargs["content"]
|
||||
|
||||
# ----- 4.2 WebSocket 广播参与者变更 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_participant_change_on_success(self):
|
||||
"""成功邀请后应广播参与者变更通知。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="serving")
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
# Act
|
||||
await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=_new_participants(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
service._broadcast_participant_change.assert_awaited_once()
|
||||
call_args = service._broadcast_participant_change.call_args
|
||||
assert call_args.args[0] is conv # 第一个参数是 conversation
|
||||
assert call_args.args[1] == "participant_invited" # 事件类型
|
||||
|
||||
# ----- 4.3 多人邀请全部添加 -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_invitees_all_added(self):
|
||||
"""一次邀请多人时,所有人都应被添加。"""
|
||||
# Arrange
|
||||
service = _make_service()
|
||||
conv = _mock_conversation(status="ai_handling", participants=[])
|
||||
service._get_conversation.return_value = conv
|
||||
|
||||
participants = [
|
||||
{"id": "emp_010", "name": "赵六", "type": "employee"},
|
||||
{"id": "emp_011", "name": "孙七", "type": "employee"},
|
||||
{"id": "emp_012", "name": "周八", "type": "employee"},
|
||||
]
|
||||
|
||||
# Act
|
||||
result = await service.invite_participants(
|
||||
conversation_id="conv-001",
|
||||
inviter_agent_id="agent_001",
|
||||
participants=participants,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is conv
|
||||
assert len(conv.participants) == 3
|
||||
added_ids = {p["id"] for p in conv.participants}
|
||||
assert added_ids == {"emp_010", "emp_011", "emp_012"}
|
||||
@@ -0,0 +1,985 @@
|
||||
# =============================================================================
|
||||
# 组织架构树构建与过滤 — 单元测试
|
||||
# =============================================================================
|
||||
# 测试覆盖:
|
||||
# 1. build_org_tree() — 层级树构建(dept_ 前缀、parentid 根判定、多部门员工、空部门、孤儿部门、嵌套层级)
|
||||
# 2. _build_flat_tree_by_name() — 降级扁平分组
|
||||
# 3. filter_user_from_tree() — 递归过滤用户 + 空部门移除
|
||||
# 4. count_tree_employees() — 递归统计员工数
|
||||
# 5. get_org_tree_cached() — 缓存 key 区分端点 + 用户排除(mock 依赖)
|
||||
#
|
||||
# 设计原则:
|
||||
# - build_org_tree / filter_user_from_tree / count_tree_employees / _build_flat_tree_by_name
|
||||
# 是纯函数,无外部依赖,直接测试输入输出
|
||||
# - get_org_tree_cached 是 async 函数,mock get_org_directory 和 get_cached_dept_list
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.employee_directory import (
|
||||
ORG_TREE_CACHE_KEY_PREFIX,
|
||||
build_org_tree,
|
||||
count_tree_employees,
|
||||
filter_user_from_tree,
|
||||
get_org_tree_cached,
|
||||
_build_flat_tree_by_name,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助:构造测试数据
|
||||
# =============================================================================
|
||||
|
||||
def _emp(employee_id: str, name: str, department: str = "", dept_ids=None):
|
||||
"""构造员工目录条目。"""
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"department": department,
|
||||
"dept_ids": dept_ids or [],
|
||||
}
|
||||
|
||||
|
||||
def _dept(dept_id: int, name: str, parentid: int = 0):
|
||||
"""构造部门列表条目。"""
|
||||
return {"id": dept_id, "name": name, "parentid": parentid}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、build_org_tree() 测试
|
||||
# =============================================================================
|
||||
|
||||
class TestBuildOrgTree:
|
||||
"""build_org_tree 纯函数测试。"""
|
||||
|
||||
# ----- 1.1 dept_ 前缀与员工 UserID 不冲突 -----
|
||||
|
||||
def test_dept_prefix_no_collision_with_employee_id(self):
|
||||
"""部门节点 id 加 dept_ 前缀,员工节点 id 用 UserID,两者不冲突。"""
|
||||
directory = [_emp("zhangsan", "张三", "研发部", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 收集所有节点 id
|
||||
all_ids = set()
|
||||
|
||||
def collect_ids(nodes):
|
||||
for n in nodes:
|
||||
all_ids.add(n["id"])
|
||||
if "children" in n:
|
||||
collect_ids(n["children"])
|
||||
|
||||
collect_ids(tree)
|
||||
# 部门 id 有 dept_ 前缀,员工 id 无前缀
|
||||
assert "dept_1" in all_ids
|
||||
assert "dept_2" in all_ids
|
||||
assert "zhangsan" in all_ids
|
||||
# 不存在 "dept_zhangsan" 之类的冲突
|
||||
assert "dept_zhangsan" not in all_ids
|
||||
|
||||
def test_dept_prefix_with_numeric_employee_id(self):
|
||||
"""员工 UserID 为纯数字时,dept_ 前缀仍能区分。"""
|
||||
directory = [_emp("1001", "员工1001", "研发部", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1), _dept(1001, "数字部门", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
all_ids = set()
|
||||
|
||||
def collect_ids(nodes):
|
||||
for n in nodes:
|
||||
all_ids.add(n["id"])
|
||||
if "children" in n:
|
||||
collect_ids(n["children"])
|
||||
|
||||
collect_ids(tree)
|
||||
# 部门 1001 的 id 是 "dept_1001",员工 1001 的 id 是 "1001"
|
||||
assert "dept_1001" in all_ids
|
||||
assert "1001" in all_ids
|
||||
|
||||
# ----- 1.2 parentid=0 根部门判定 -----
|
||||
|
||||
def test_parentid_0_as_root(self):
|
||||
"""parentid=0 的部门作为根节点。"""
|
||||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(2, "研发部", 1),
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 只有一个根节点(公司)
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["id"] == "dept_1"
|
||||
assert tree[0]["label"] == "公司"
|
||||
# 研发部是公司的子部门
|
||||
assert len(tree[0]["children"]) == 1
|
||||
assert tree[0]["children"][0]["id"] == "dept_2"
|
||||
|
||||
def test_multiple_root_departments(self):
|
||||
"""多个 parentid=0 的部门都作为根节点。"""
|
||||
directory = []
|
||||
dept_list = [
|
||||
_dept(1, "公司A", 0),
|
||||
_dept(2, "公司B", 0),
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
assert len(tree) == 2
|
||||
root_ids = {n["id"] for n in tree}
|
||||
assert root_ids == {"dept_1", "dept_2"}
|
||||
|
||||
def test_parentid_1_fallback_when_no_parentid_0(self):
|
||||
"""没有 parentid=0 时,parentid=1 的部门作为根(企微 dept 1 缺失场景)。"""
|
||||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||||
# dept 1 不在列表中,dept 2 的 parentid=1
|
||||
dept_list = [_dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["id"] == "dept_2"
|
||||
|
||||
# ----- 1.3 多部门员工 -----
|
||||
|
||||
def test_multi_dept_employee_appears_in_all_depts(self):
|
||||
"""员工属于多个部门时,在每个部门下都出现。"""
|
||||
directory = [_emp("zhangsan", "张三", "研发,测试", [2, 3])]
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(2, "研发部", 1),
|
||||
_dept(3, "测试部", 1),
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 根节点是公司
|
||||
assert len(tree) == 1
|
||||
company = tree[0]
|
||||
# 公司下有研发部和测试部
|
||||
dept_nodes = {n["id"]: n for n in company["children"] if not n.get("isLeaf")}
|
||||
assert "dept_2" in dept_nodes
|
||||
assert "dept_3" in dept_nodes
|
||||
|
||||
# 研发部下有张三
|
||||
dev_emps = [n for n in dept_nodes["dept_2"]["children"] if n.get("isLeaf")]
|
||||
assert len(dev_emps) == 1
|
||||
assert dev_emps[0]["id"] == "zhangsan"
|
||||
|
||||
# 测试部下也有张三
|
||||
test_emps = [n for n in dept_nodes["dept_3"]["children"] if n.get("isLeaf")]
|
||||
assert len(test_emps) == 1
|
||||
assert test_emps[0]["id"] == "zhangsan"
|
||||
|
||||
# ----- 1.4 空部门(有部门节点但没有员工) -----
|
||||
|
||||
def test_empty_dept_still_appears(self):
|
||||
"""有部门节点但没有员工的空部门仍出现在树中。"""
|
||||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(2, "研发部", 1),
|
||||
_dept(3, "空部门", 1), # 没有员工
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
dept_ids = {n["id"] for n in company["children"] if not n.get("isLeaf")}
|
||||
# 空部门仍然出现
|
||||
assert "dept_3" in dept_ids
|
||||
|
||||
def test_empty_dept_has_empty_children(self):
|
||||
"""空部门的 children 为空列表。"""
|
||||
directory = []
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "空部门", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
empty_dept = next(n for n in company["children"] if n["id"] == "dept_2")
|
||||
assert empty_dept["children"] == []
|
||||
|
||||
# ----- 1.5 未分配部门员工 -----
|
||||
|
||||
def test_unassigned_employees_grouped(self):
|
||||
"""没有 dept_ids 的员工归到"未分配部门"。"""
|
||||
directory = [
|
||||
_emp("u1", "用户1", "", []),
|
||||
_emp("u2", "用户2", "某部门", []),
|
||||
]
|
||||
dept_list = [_dept(1, "公司", 0)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 应该有公司 + 未分配部门
|
||||
top_ids = {n["id"] for n in tree}
|
||||
assert "dept_1" in top_ids
|
||||
assert "dept_unassigned" in top_ids
|
||||
|
||||
unassigned = next(n for n in tree if n["id"] == "dept_unassigned")
|
||||
assert unassigned["label"] == "未分配部门"
|
||||
assert len(unassigned["children"]) == 2
|
||||
|
||||
# ----- 1.6 孤儿部门(parentid 不在 dept_map 中) -----
|
||||
|
||||
def test_orphan_dept_as_root(self):
|
||||
"""parentid 指向不存在的部门时,该部门作为根节点。"""
|
||||
directory = [_emp("u1", "用户1", "孤儿", [99])]
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(99, "孤儿部门", 88), # parentid=88 不在 dept_map
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
top_ids = {n["id"] for n in tree}
|
||||
assert "dept_1" in top_ids
|
||||
assert "dept_99" in top_ids # 孤儿部门作为根
|
||||
|
||||
# ----- 1.7 多层嵌套 -----
|
||||
|
||||
def test_nested_hierarchy_3_levels(self):
|
||||
"""3 层嵌套:公司 → 研发部 → 前端组 → 员工。"""
|
||||
directory = [_emp("u1", "前端工程师", "前端组", [3])]
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(2, "研发部", 1),
|
||||
_dept(3, "前端组", 2),
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
assert len(tree) == 1
|
||||
company = tree[0]
|
||||
assert company["id"] == "dept_1"
|
||||
assert len(company["children"]) == 1
|
||||
|
||||
dev = company["children"][0]
|
||||
assert dev["id"] == "dept_2"
|
||||
assert len(dev["children"]) == 1
|
||||
|
||||
fe = dev["children"][0]
|
||||
assert fe["id"] == "dept_3"
|
||||
assert len(fe["children"]) == 1
|
||||
|
||||
emp = fe["children"][0]
|
||||
assert emp["id"] == "u1"
|
||||
assert emp["isLeaf"] is True
|
||||
|
||||
def test_deep_nested_hierarchy_10_levels(self):
|
||||
"""10 层嵌套不栈溢出。"""
|
||||
directory = [_emp("u1", "深层员工", "深层", [10])]
|
||||
dept_list = [_dept(i, f"部门{i}", i - 1) for i in range(1, 11)]
|
||||
dept_list[0] = _dept(1, "部门1", 0) # 根
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 递归到最深层
|
||||
node = tree[0]
|
||||
for i in range(1, 10):
|
||||
assert node["id"] == f"dept_{i}"
|
||||
dept_children = [c for c in node["children"] if not c.get("isLeaf")]
|
||||
assert len(dept_children) == 1
|
||||
node = dept_children[0]
|
||||
# 第 10 层有员工
|
||||
assert node["id"] == "dept_10"
|
||||
emps = [c for c in node["children"] if c.get("isLeaf")]
|
||||
assert len(emps) == 1
|
||||
|
||||
# ----- 1.8 空输入 -----
|
||||
|
||||
def test_empty_directory_and_empty_dept_list(self):
|
||||
"""空 directory + 空 dept_list → 空树。"""
|
||||
tree = build_org_tree([], [])
|
||||
assert tree == []
|
||||
|
||||
def test_empty_directory_with_dept_list(self):
|
||||
"""空 directory + 有 dept_list → 只有部门节点无员工。"""
|
||||
dept_list = [_dept(1, "公司", 0)]
|
||||
tree = build_org_tree([], dept_list)
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["id"] == "dept_1"
|
||||
assert tree[0]["children"] == []
|
||||
|
||||
def test_directory_with_empty_dept_list_falls_back_to_flat(self):
|
||||
"""有 directory + 空 dept_list → 降级到 _build_flat_tree_by_name。"""
|
||||
directory = [_emp("u1", "用户1", "研发部")]
|
||||
tree = build_org_tree(directory, [])
|
||||
# 降级路径使用 dept_name_ 前缀
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["id"] == "dept_name_研发部"
|
||||
|
||||
# ----- 1.9 部门节点结构验证 -----
|
||||
|
||||
def test_dept_node_structure(self):
|
||||
"""部门节点包含 id, label, dept_id, parentid, children 字段。"""
|
||||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
assert company["id"] == "dept_1"
|
||||
assert company["label"] == "公司"
|
||||
assert company["dept_id"] == 1
|
||||
assert company["parentid"] == 0
|
||||
assert "children" in company
|
||||
|
||||
def test_employee_node_structure(self):
|
||||
"""员工节点包含 id, label, isLeaf, department 字段。"""
|
||||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
dev = company["children"][0]
|
||||
emp = dev["children"][0]
|
||||
assert emp["id"] == "u1"
|
||||
assert emp["label"] == "用户1"
|
||||
assert emp["isLeaf"] is True
|
||||
assert emp["department"] == "研发"
|
||||
|
||||
# ----- 1.10 排序验证 -----
|
||||
|
||||
def test_departments_sorted_by_name(self):
|
||||
"""子部门按名称排序(Python 默认 Unicode 码点序)。"""
|
||||
directory = []
|
||||
dept_list = [
|
||||
_dept(1, "公司", 0),
|
||||
_dept(4, "_Z部门", 1),
|
||||
_dept(2, "A部门", 1),
|
||||
_dept(3, "B部门", 1),
|
||||
]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
dept_names = [n["label"] for n in company["children"] if not n.get("isLeaf")]
|
||||
# Python 按 Unicode 码点排序:A(0x41) < B(0x42) < _(0x5F)
|
||||
assert dept_names == ["A部门", "B部门", "_Z部门"]
|
||||
|
||||
def test_employees_sorted_by_name(self):
|
||||
"""同一部门下员工按姓名排序(Python 默认 Unicode 码点序)。"""
|
||||
directory = [
|
||||
_emp("u3", "张三", "研发", [2]),
|
||||
_emp("u1", "阿大", "研发", [2]),
|
||||
_emp("u2", "李四", "研发", [2]),
|
||||
]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
company = tree[0]
|
||||
dev = company["children"][0]
|
||||
emp_names = [n["label"] for n in dev["children"] if n.get("isLeaf")]
|
||||
# Python 按 Unicode 码点排序:张(U+5F20) < 李(U+674E) < 阿(U+963F)
|
||||
assert emp_names == ["张三", "李四", "阿大"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、_build_flat_tree_by_name() 测试
|
||||
# =============================================================================
|
||||
|
||||
class TestBuildFlatTreeByName:
|
||||
"""_build_flat_tree_by_name 降级扁平分组测试。"""
|
||||
|
||||
def test_basic_grouping(self):
|
||||
"""按部门名分组(Python 默认 Unicode 码点序排序)。"""
|
||||
directory = [
|
||||
_emp("u1", "张三", "研发部"),
|
||||
_emp("u2", "李四", "研发部"),
|
||||
_emp("u3", "王五", "测试部"),
|
||||
]
|
||||
tree = _build_flat_tree_by_name(directory)
|
||||
|
||||
assert len(tree) == 2
|
||||
# Python 按 Unicode 码点排序:测(U+6D4B) < 研(U+7814)
|
||||
assert tree[0]["label"] == "测试部"
|
||||
assert tree[1]["label"] == "研发部"
|
||||
assert len(tree[0]["children"]) == 1
|
||||
assert len(tree[1]["children"]) == 2
|
||||
|
||||
def test_multi_dept_takes_first(self):
|
||||
"""多部门员工取第一个部门名。"""
|
||||
directory = [_emp("u1", "张三", "研发部,测试部")]
|
||||
tree = _build_flat_tree_by_name(directory)
|
||||
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["label"] == "研发部"
|
||||
|
||||
def test_empty_dept_name_goes_to_unassigned(self):
|
||||
"""空部门名归到"未分配部门"。"""
|
||||
directory = [
|
||||
_emp("u1", "张三", ""),
|
||||
_emp("u2", "李四", None),
|
||||
]
|
||||
tree = _build_flat_tree_by_name(directory)
|
||||
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["label"] == "未分配部门"
|
||||
assert len(tree[0]["children"]) == 2
|
||||
|
||||
def test_comma_only_dept_name_goes_to_unassigned(self):
|
||||
"""部门名只有逗号时归到"未分配部门"。"""
|
||||
directory = [_emp("u1", "张三", ",,")]
|
||||
tree = _build_flat_tree_by_name(directory)
|
||||
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["label"] == "未分配部门"
|
||||
|
||||
def test_empty_directory(self):
|
||||
"""空 directory → 空树。"""
|
||||
tree = _build_flat_tree_by_name([])
|
||||
assert tree == []
|
||||
|
||||
def test_flat_tree_node_structure(self):
|
||||
"""扁平树节点结构正确。"""
|
||||
directory = [_emp("u1", "张三", "研发部")]
|
||||
tree = _build_flat_tree_by_name(directory)
|
||||
|
||||
dept_node = tree[0]
|
||||
assert dept_node["id"] == "dept_name_研发部"
|
||||
assert dept_node["label"] == "研发部"
|
||||
assert dept_node["dept_id"] is None
|
||||
assert dept_node["parentid"] == 0
|
||||
|
||||
emp_node = dept_node["children"][0]
|
||||
assert emp_node["id"] == "u1"
|
||||
assert emp_node["label"] == "张三"
|
||||
assert emp_node["isLeaf"] is True
|
||||
assert emp_node["department"] == "研发部"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、filter_user_from_tree() 测试
|
||||
# =============================================================================
|
||||
|
||||
class TestFilterUserFromTree:
|
||||
"""filter_user_from_tree 递归过滤测试。"""
|
||||
|
||||
def _sample_tree(self):
|
||||
"""构造测试用树:公司 → 研发部(张三,李四) + 测试部(王五)。"""
|
||||
return [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发部",
|
||||
"dept_id": 2,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发部"},
|
||||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "研发部"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dept_3",
|
||||
"label": "测试部",
|
||||
"dept_id": 3,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "wangwu", "label": "王五", "isLeaf": True, "department": "测试部"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
def test_filter_single_user(self):
|
||||
"""过滤单个用户,其他用户保留。"""
|
||||
tree = self._sample_tree()
|
||||
result = filter_user_from_tree(tree, "zhangsan")
|
||||
|
||||
# 收集所有员工 id
|
||||
def get_emp_ids(nodes):
|
||||
ids = set()
|
||||
for n in nodes:
|
||||
if n.get("isLeaf"):
|
||||
ids.add(n["id"])
|
||||
elif "children" in n:
|
||||
ids.update(get_emp_ids(n["children"]))
|
||||
return ids
|
||||
|
||||
emp_ids = get_emp_ids(result)
|
||||
assert "zhangsan" not in emp_ids
|
||||
assert "lisi" in emp_ids
|
||||
assert "wangwu" in emp_ids
|
||||
|
||||
def test_filter_user_empty_dept_removed(self):
|
||||
"""过滤后变空的部门节点被移除。"""
|
||||
tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发部",
|
||||
"dept_id": 2,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发部"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dept_3",
|
||||
"label": "测试部",
|
||||
"dept_id": 3,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "测试部"},
|
||||
{"id": "wangwu", "label": "王五", "isLeaf": True, "department": "测试部"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# 过滤张三 → 研发部变空 → 研发部被移除
|
||||
result = filter_user_from_tree(tree, "zhangsan")
|
||||
|
||||
assert len(result) == 1
|
||||
company = result[0]
|
||||
dept_ids = [n["id"] for n in company["children"] if not n.get("isLeaf")]
|
||||
assert "dept_2" not in dept_ids # 研发部被移除
|
||||
assert "dept_3" in dept_ids # 测试部保留
|
||||
|
||||
def test_filter_user_not_in_tree(self):
|
||||
"""过滤不存在的用户,树不变。"""
|
||||
tree = self._sample_tree()
|
||||
result = filter_user_from_tree(tree, "nonexistent")
|
||||
|
||||
assert len(result) == len(tree)
|
||||
# 树结构应与原始相同(所有员工都在)
|
||||
def count_emps(nodes):
|
||||
c = 0
|
||||
for n in nodes:
|
||||
if n.get("isLeaf"):
|
||||
c += 1
|
||||
elif "children" in n:
|
||||
c += count_emps(n["children"])
|
||||
return c
|
||||
|
||||
assert count_emps(result) == 3
|
||||
|
||||
def test_filter_empty_tree(self):
|
||||
"""空树过滤返回空列表。"""
|
||||
result = filter_user_from_tree([], "anyone")
|
||||
assert result == []
|
||||
|
||||
def test_filter_user_in_multiple_depts(self):
|
||||
"""用户出现在多个部门时,所有实例都被移除。"""
|
||||
tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发部",
|
||||
"dept_id": 2,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发"},
|
||||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "研发"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dept_3",
|
||||
"label": "测试部",
|
||||
"dept_id": 3,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "测试"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = filter_user_from_tree(tree, "zhangsan")
|
||||
|
||||
def get_emp_ids(nodes):
|
||||
ids = set()
|
||||
for n in nodes:
|
||||
if n.get("isLeaf"):
|
||||
ids.add(n["id"])
|
||||
elif "children" in n:
|
||||
ids.update(get_emp_ids(n["children"]))
|
||||
return ids
|
||||
|
||||
emp_ids = get_emp_ids(result)
|
||||
assert "zhangsan" not in emp_ids
|
||||
assert "lisi" in emp_ids # 李四保留
|
||||
|
||||
def test_filter_all_users_empties_tree(self):
|
||||
"""过滤所有用户后,整个树变空。"""
|
||||
tree = self._sample_tree()
|
||||
# 依次过滤所有 3 个用户
|
||||
result = tree
|
||||
for uid in ["zhangsan", "lisi", "wangwu"]:
|
||||
result = filter_user_from_tree(result, uid)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_filter_preserves_dept_node_fields(self):
|
||||
"""过滤后部门节点保留原有字段(dept_id, parentid 等)。"""
|
||||
tree = self._sample_tree()
|
||||
result = filter_user_from_tree(tree, "zhangsan")
|
||||
|
||||
company = result[0]
|
||||
assert company["dept_id"] == 1
|
||||
assert company["parentid"] == 0
|
||||
assert company["label"] == "公司"
|
||||
|
||||
def test_filter_does_not_mutate_original(self):
|
||||
"""过滤不修改原始树(返回新对象)。"""
|
||||
tree = self._sample_tree()
|
||||
original_count = count_tree_employees(tree)
|
||||
filter_user_from_tree(tree, "zhangsan")
|
||||
# 原始树不受影响
|
||||
assert count_tree_employees(tree) == original_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、count_tree_employees() 测试
|
||||
# =============================================================================
|
||||
|
||||
class TestCountTreeEmployees:
|
||||
"""count_tree_employees 递归统计测试。"""
|
||||
|
||||
def test_count_flat_tree(self):
|
||||
"""扁平树统计员工数。"""
|
||||
tree = [
|
||||
{"id": "u1", "label": "张三", "isLeaf": True, "department": ""},
|
||||
{"id": "u2", "label": "李四", "isLeaf": True, "department": ""},
|
||||
]
|
||||
assert count_tree_employees(tree) == 2
|
||||
|
||||
def test_count_nested_tree(self):
|
||||
"""嵌套树统计员工数。"""
|
||||
tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"children": [
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发部",
|
||||
"children": [
|
||||
{"id": "u1", "label": "张三", "isLeaf": True},
|
||||
{"id": "u2", "label": "李四", "isLeaf": True},
|
||||
],
|
||||
},
|
||||
{"id": "u3", "label": "王五", "isLeaf": True},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert count_tree_employees(tree) == 3
|
||||
|
||||
def test_count_empty_tree(self):
|
||||
"""空树员工数为 0。"""
|
||||
assert count_tree_employees([]) == 0
|
||||
|
||||
def test_count_only_dept_nodes(self):
|
||||
"""只有部门节点无员工时为 0。"""
|
||||
tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"children": [
|
||||
{"id": "dept_2", "label": "研发部", "children": []},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert count_tree_employees(tree) == 0
|
||||
|
||||
def test_count_mixed_nodes(self):
|
||||
"""混合节点(部门+员工)正确统计。"""
|
||||
tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"children": [
|
||||
{"id": "u1", "label": "张三", "isLeaf": True},
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发部",
|
||||
"children": [
|
||||
{"id": "u2", "label": "李四", "isLeaf": True},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{"id": "u3", "label": "王五", "isLeaf": True},
|
||||
]
|
||||
assert count_tree_employees(tree) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 五、get_org_tree_cached() 测试(mock 依赖)
|
||||
# =============================================================================
|
||||
|
||||
class TestGetOrgTreeCached:
|
||||
"""get_org_tree_cached 缓存与过滤测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_key_per_endpoint(self):
|
||||
"""agent 和 h5 端点使用不同的缓存 key。"""
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
mock_redis = MockRedis()
|
||||
directory = [_emp("u1", "张三", "研发", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
with patch(
|
||||
"app.services.employee_directory.get_org_directory",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(directory, True),
|
||||
):
|
||||
with patch(
|
||||
"app.services.employee_directory.get_cached_dept_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=dept_list,
|
||||
):
|
||||
# 调用 agent 端点
|
||||
await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||||
# 调用 h5 端点
|
||||
await get_org_tree_cached(None, mock_redis, "h5", "nobody")
|
||||
|
||||
# 验证两个不同的缓存 key
|
||||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:agent" in mock_redis._data
|
||||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:h5" in mock_redis._data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_exclusion_from_cached_tree(self):
|
||||
"""缓存包含全量员工,读取后过滤当前用户。"""
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
mock_redis = MockRedis()
|
||||
directory = [
|
||||
_emp("u1", "张三", "研发", [2]),
|
||||
_emp("u2", "李四", "研发", [2]),
|
||||
_emp("current_user", "当前用户", "研发", [2]),
|
||||
]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
with patch(
|
||||
"app.services.employee_directory.get_org_directory",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(directory, True),
|
||||
):
|
||||
with patch(
|
||||
"app.services.employee_directory.get_cached_dept_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=dept_list,
|
||||
):
|
||||
# 第一次调用:构建树并缓存
|
||||
tree1 = await get_org_tree_cached(None, mock_redis, "agent", "current_user")
|
||||
# 当前用户应被排除
|
||||
assert count_tree_employees(tree1) == 2 # 张三 + 李四
|
||||
|
||||
def get_emp_ids(nodes):
|
||||
ids = set()
|
||||
for n in nodes:
|
||||
if n.get("isLeaf"):
|
||||
ids.add(n["id"])
|
||||
elif "children" in n:
|
||||
ids.update(get_emp_ids(n["children"]))
|
||||
return ids
|
||||
|
||||
assert "current_user" not in get_emp_ids(tree1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_skips_rebuild(self):
|
||||
"""缓存命中时不重新构建树。"""
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
mock_redis = MockRedis()
|
||||
# 预填充缓存
|
||||
cached_tree = [
|
||||
{
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{"id": "u1", "label": "张三", "isLeaf": True, "department": ""},
|
||||
{"id": "u2", "label": "李四", "isLeaf": True, "department": ""},
|
||||
],
|
||||
}
|
||||
]
|
||||
await mock_redis.setex(
|
||||
f"{ORG_TREE_CACHE_KEY_PREFIX}:agent",
|
||||
1800,
|
||||
json.dumps(cached_tree, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# mock get_org_directory — 如果被调用说明缓存未命中
|
||||
mock_get_dir = AsyncMock(return_value=([], True))
|
||||
mock_get_dept = AsyncMock(return_value=[])
|
||||
|
||||
with patch("app.services.employee_directory.get_org_directory", mock_get_dir):
|
||||
with patch("app.services.employee_directory.get_cached_dept_list", mock_get_dept):
|
||||
tree = await get_org_tree_cached(None, mock_redis, "agent", "u1")
|
||||
|
||||
# 缓存命中,不应调用 get_org_directory
|
||||
mock_get_dir.assert_not_called()
|
||||
mock_get_dept.assert_not_called()
|
||||
|
||||
# u1 被过滤
|
||||
assert count_tree_employees(tree) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_builds_and_caches(self):
|
||||
"""缓存未命中时构建树并写入缓存。"""
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
mock_redis = MockRedis()
|
||||
directory = [_emp("u1", "张三", "研发", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
with patch(
|
||||
"app.services.employee_directory.get_org_directory",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(directory, True),
|
||||
):
|
||||
with patch(
|
||||
"app.services.employee_directory.get_cached_dept_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=dept_list,
|
||||
):
|
||||
tree = await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||||
|
||||
# 树已构建
|
||||
assert len(tree) == 1
|
||||
# 缓存已写入
|
||||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:agent" in mock_redis._data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_redis_builds_tree_without_cache(self):
|
||||
"""redis=None 时仍能构建树(不读写缓存)。"""
|
||||
directory = [_emp("u1", "张三", "研发", [2])]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
with patch(
|
||||
"app.services.employee_directory.get_org_directory",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(directory, True),
|
||||
):
|
||||
with patch(
|
||||
"app.services.employee_directory.get_cached_dept_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=dept_list,
|
||||
):
|
||||
tree = await get_org_tree_cached(None, None, "agent", "nobody")
|
||||
|
||||
assert len(tree) == 1
|
||||
assert count_tree_employees(tree) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degraded_flat_tree_when_no_dept_list(self):
|
||||
"""dept_list 为空时降级到扁平树。"""
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
mock_redis = MockRedis()
|
||||
directory = [_emp("u1", "张三", "研发部")]
|
||||
|
||||
with patch(
|
||||
"app.services.employee_directory.get_org_directory",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(directory, True),
|
||||
):
|
||||
with patch(
|
||||
"app.services.employee_directory.get_cached_dept_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[], # 空部门列表
|
||||
):
|
||||
tree = await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||||
|
||||
# 降级路径:dept_name_ 前缀
|
||||
assert len(tree) == 1
|
||||
assert tree[0]["id"] == "dept_name_研发部"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 六、兼容性测试:resolve_target 与搜索接口不受 dept_ids 影响
|
||||
# =============================================================================
|
||||
|
||||
class TestCompatibility:
|
||||
"""验证新增 dept_ids 字段不影响现有功能。"""
|
||||
|
||||
def test_directory_entry_has_dept_ids_field(self):
|
||||
"""验证 directory 条目结构包含 dept_ids 字段(企微路径)。
|
||||
|
||||
这不是直接测试 get_org_directory(需要 mock 企微 API),
|
||||
而是验证 build_org_tree 对 dept_ids 字段的消费方式正确。
|
||||
"""
|
||||
# 模拟 get_org_directory 返回的数据结构
|
||||
directory = [
|
||||
{
|
||||
"employee_id": "u1",
|
||||
"name": "张三",
|
||||
"department": "研发部",
|
||||
"dept_ids": [2],
|
||||
}
|
||||
]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 员工正确出现在部门下
|
||||
assert count_tree_employees(tree) == 1
|
||||
|
||||
def test_directory_entry_without_dept_ids(self):
|
||||
"""dept_ids 字段缺失时(旧缓存兼容),build_org_tree 不报错。"""
|
||||
directory = [
|
||||
{
|
||||
"employee_id": "u1",
|
||||
"name": "张三",
|
||||
"department": "研发部",
|
||||
# dept_ids 缺失
|
||||
}
|
||||
]
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# dept_ids 缺失 → emp.get("dept_ids") or [] → [] → 归入未分配部门
|
||||
top_ids = {n["id"] for n in tree}
|
||||
assert "dept_unassigned" in top_ids
|
||||
|
||||
def test_employee_with_unknown_dept_id(self):
|
||||
"""员工 dept_id 不在 dept_list 中时的行为验证。
|
||||
|
||||
已知边界情况:员工 dept_id 不在 dept_map 中时,
|
||||
员工会被分配到 dept_employees[unknown_id] 但不会被任何 build_dept_node 处理,
|
||||
导致该员工从树中"消失"。
|
||||
|
||||
本测试验证此行为并记录为已知边界情况。
|
||||
"""
|
||||
directory = [_emp("u1", "张三", "某部门", [99])] # dept 99 不在 dept_list
|
||||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||||
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 已知边界情况:员工 u1 不在树中(dept 99 不在 dept_map)
|
||||
# 理想行为应该是归入"未分配部门",但当前实现会丢失该员工
|
||||
assert count_tree_employees(tree) == 0
|
||||
# 记录此行为 — 见测试报告中的 Known Issues
|
||||
@@ -0,0 +1,132 @@
|
||||
# =============================================================================
|
||||
# Token异常检测 - 单元测试
|
||||
# =============================================================================
|
||||
# 测试用例:
|
||||
# T001: record_token_ip 正常记录
|
||||
# T002: get_token_ips 获取IP列表
|
||||
# T003: 多IP记录超过阈值触发告警
|
||||
# T004: IP记录自动过期
|
||||
# T005: 异常处理 - Redis连接失败
|
||||
# T006: 异常处理 - 无效token
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestTokenServiceIPRecord:
|
||||
"""Token IP记录功能测试"""
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_redis(self):
|
||||
"""创建模拟Redis客户端"""
|
||||
mock = AsyncMock()
|
||||
# 模拟setex和get方法
|
||||
mock.setex = AsyncMock(return_value=True)
|
||||
mock.get = AsyncMock(return_value=None)
|
||||
return mock
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_token_ip(self, mock_redis):
|
||||
"""T001: record_token_ip 正常记录"""
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
service = TokenService(mock_redis)
|
||||
|
||||
# 测试记录Token IP
|
||||
await service.record_token_ip("test_token_123", "192.168.1.100")
|
||||
|
||||
# 验证Redis setex被调用
|
||||
mock_redis.setex.assert_called_once()
|
||||
call_args = mock_redis.setex.call_args
|
||||
assert call_args[0][0].startswith("token_ip:")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token_ips(self, mock_redis):
|
||||
"""T002: get_token_ips 获取IP列表"""
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
# 模拟已有IP记录
|
||||
mock_redis.get = AsyncMock(
|
||||
return_value="192.168.1.100@2026-07-14T10:00:00,192.168.1.101@2026-07-14T10:05:00"
|
||||
)
|
||||
|
||||
service = TokenService(mock_redis)
|
||||
result = await service.get_token_ips("test_token_123")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["ip"] == "192.168.1.100"
|
||||
assert result[1]["ip"] == "192.168.1.101"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token_ips_empty(self, mock_redis):
|
||||
"""T002-2: get_token_ips 无记录返回空列表"""
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
|
||||
service = TokenService(mock_redis)
|
||||
result = await service.get_token_ips("nonexistent_token")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestTokenAnomalyDetection:
|
||||
"""Token异常检测测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anomaly_detection_threshold(self):
|
||||
"""T003: 多IP记录超过阈值"""
|
||||
# 模拟3个不同IP的记录
|
||||
test_ips = ["192.168.1.100", "192.168.1.101", "192.168.1.102"]
|
||||
|
||||
# 验证阈值判断
|
||||
ip_count = len(set(test_ips))
|
||||
assert ip_count >= 3 # 触发阈值
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_usage_no_anomaly(self):
|
||||
"""T003-2: 正常使用不触发告警"""
|
||||
# 单个IP正常使用
|
||||
test_ips = ["192.168.1.100"]
|
||||
|
||||
ip_count = len(set(test_ips))
|
||||
assert ip_count < 3 # 不触发
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_ips_no_anomaly(self):
|
||||
"""T003-3: 两个IP不触发告警"""
|
||||
test_ips = ["192.168.1.100", "192.168.1.101"]
|
||||
|
||||
ip_count = len(set(test_ips))
|
||||
assert ip_count < 3 # 不触发
|
||||
|
||||
|
||||
class TestTokenAnomalyDetectionTask:
|
||||
"""Token异常检测定时任务测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_anomaly_logic(self):
|
||||
"""T004: 异常检测逻辑验证"""
|
||||
from app.tasks.token_anomaly_detection import TOKEN_ANOMALY_IP_THRESHOLD
|
||||
|
||||
# 测试阈值配置
|
||||
assert TOKEN_ANOMALY_IP_THRESHOLD == 3
|
||||
|
||||
# 验证异常检测逻辑
|
||||
test_cases = [
|
||||
(["10.0.0.1", "10.0.0.2", "10.0.0.3"], True), # 3个IP,触发
|
||||
(["10.0.0.1", "10.0.0.2"], False), # 2个IP,不触发
|
||||
(["10.0.0.1"], False), # 1个IP,不触发
|
||||
]
|
||||
|
||||
for ips, expected in test_cases:
|
||||
result = len(set(ips)) >= TOKEN_ANOMALY_IP_THRESHOLD
|
||||
assert result == expected
|
||||
|
||||
|
||||
# 运行测试
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,472 @@
|
||||
# =============================================================================
|
||||
# 百度 ASR cuid 修复 — 防御性检查逻辑验证测试
|
||||
# =============================================================================
|
||||
# 验证内容:
|
||||
# 1. get_baidu_token() — api_key/secret_key 空值检查
|
||||
# 2. transcribe_audio() — app_id 空值检查 + 配置诊断日志
|
||||
# 3. 正常流程 — cuid 参数正确映射为 app_id
|
||||
# 4. token 获取异常传播 — HTTPException 正确向上传播
|
||||
#
|
||||
# 关联修复:百度 ASR "url param cuid error" — cuid 为空因为
|
||||
# BAIDU_ASR_APP_ID 环境变量未传入容器
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.voice_asr import (
|
||||
BAIDU_ASR_API_URL,
|
||||
BAIDU_ASR_DEV_PID,
|
||||
get_baidu_token,
|
||||
transcribe_audio,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 辅助类与函数
|
||||
# =============================================================================
|
||||
|
||||
class _MockBaiduHttpResponse:
|
||||
"""模拟 httpx.Response — 提供同步 .json() 方法。"""
|
||||
|
||||
def __init__(self, json_data: dict):
|
||||
self._json_data = json_data
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_data
|
||||
|
||||
|
||||
class _MockAsyncHTTPClient:
|
||||
"""模拟 httpx.AsyncClient — 正确实现 async context manager 协议。
|
||||
|
||||
用真实类替代 AsyncMock,避免 AsyncMock 子属性不可 await 的问题。
|
||||
"""
|
||||
|
||||
# 类级配置,测试前设置
|
||||
_post_return_value = None
|
||||
_post_side_effect = None
|
||||
|
||||
# 实例级记录
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.post_calls = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
async def post(self, *args, **kwargs):
|
||||
self.post_calls.append({"args": args, "kwargs": kwargs})
|
||||
if _MockAsyncHTTPClient._post_side_effect:
|
||||
raise _MockAsyncHTTPClient._post_side_effect
|
||||
return _MockAsyncHTTPClient._post_return_value
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._post_return_value = None
|
||||
cls._post_side_effect = None
|
||||
|
||||
|
||||
def _make_mock_audio(data: bytes = b"fake_pcm_audio_data", filename: str = "test.pcm"):
|
||||
"""创建模拟的 UploadFile 对象。
|
||||
|
||||
Args:
|
||||
data: 模拟的 PCM 音频数据
|
||||
filename: 模拟的文件名
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 UploadFile,支持 await audio.read()
|
||||
"""
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.read = AsyncMock(return_value=data)
|
||||
mock_audio.filename = filename
|
||||
return mock_audio
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试组 1:get_baidu_token() 防御性检查
|
||||
# =============================================================================
|
||||
class TestGetBaiduTokenDefensiveChecks:
|
||||
"""验证 get_baidu_token() 在凭证缺失时抛出 HTTPException。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_api_key_empty(self, monkeypatch):
|
||||
"""BAIDU_ASR_API_KEY 为空时,应抛出 HTTPException(500)。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "valid_secret_key")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_baidu_token()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "BAIDU_ASR_API_KEY" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_secret_key_empty(self, monkeypatch):
|
||||
"""BAIDU_ASR_SECRET_KEY 为空时,应抛出 HTTPException(500)。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "valid_api_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_baidu_token()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "SECRET_KEY" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_both_empty(self, monkeypatch):
|
||||
"""API_KEY 和 SECRET_KEY 均为空时,应抛出 HTTPException(500)。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_baidu_token()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "BAIDU_ASR_API_KEY" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_message_contains_diagnostic_info(self, monkeypatch):
|
||||
"""错误消息应包含可读的诊断信息,帮助运维定位问题。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_baidu_token()
|
||||
|
||||
detail = exc_info.value.detail
|
||||
assert "百度ASR配置缺失" in detail
|
||||
assert "BAIDU_ASR_API_KEY" in detail or "SECRET_KEY" in detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试组 2:transcribe_audio() app_id 空值检查
|
||||
# =============================================================================
|
||||
class TestTranscribeAudioAppIdCheck:
|
||||
"""验证 transcribe_audio() 在 app_id 为空时返回明确错误。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_app_id_empty(self, monkeypatch):
|
||||
"""BAIDU_ASR_APP_ID 为空时,应返回 error_response(code=3001)。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["code"] == 3001
|
||||
assert "BAIDU_ASR_APP_ID" in result["message"]
|
||||
assert result["data"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_exact_error_message(self, monkeypatch):
|
||||
"""验证错误消息的精确文本。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["message"] == "百度ASR配置缺失:BAIDU_ASR_APP_ID未设置"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_id_check_does_not_call_get_baidu_token(self, monkeypatch):
|
||||
"""app_id 为空时,不应调用 get_baidu_token()(快速失败)。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
"app.api.voice_asr.get_baidu_token",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_token:
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
mock_token.assert_not_called()
|
||||
assert result["code"] == 3001
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_audio_raises_400_before_app_id_check(self, monkeypatch):
|
||||
"""空音频数据应在 app_id 检查之前抛出 HTTPException(400)。"""
|
||||
# Arrange — 即使 app_id 有效,空音频也应先报错
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "valid_app_id")
|
||||
mock_audio = _make_mock_audio(data=b"")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await transcribe_audio(mock_audio)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "未收到音频数据" in exc_info.value.detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试组 3:token 获取异常传播
|
||||
# =============================================================================
|
||||
class TestTokenExceptionPropagation:
|
||||
"""验证 get_baidu_token() 的 HTTPException 在 transcribe_audio 中正确传播。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_exception_from_get_token_propagates(self, monkeypatch):
|
||||
"""app_id 有效但 api_key 为空时,get_baidu_token() 抛出的
|
||||
HTTPException 应被 transcribe_audio 重新抛出。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "valid_app_id")
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act & Assert — get_baidu_token 抛 HTTPException(500),
|
||||
# transcribe_audio 中 except HTTPException: raise 应将其重新抛出
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await transcribe_audio(mock_audio)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "BAIDU_ASR_API_KEY" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_exception_returns_error_response(self, monkeypatch):
|
||||
"""get_baidu_token() 抛出非 HTTPException 异常时,
|
||||
transcribe_audio 应捕获并返回 error_response。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "valid_app_id")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
"app.api.voice_asr.get_baidu_token",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ConnectionError("Redis 连接失败"),
|
||||
):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["code"] == 3001
|
||||
assert "token" in result["message"].lower() or "获取" in result["message"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试组 4:正常流程 — cuid 参数映射验证(核心修复点)
|
||||
# =============================================================================
|
||||
class TestTranscribeAudioNormalFlow:
|
||||
"""验证正常流程中 cuid 参数正确映射为 app_id(修复的核心)。"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试前重置 mock HTTP 客户端状态。"""
|
||||
_MockAsyncHTTPClient.reset()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cuid_set_to_app_id_in_api_call(self, monkeypatch):
|
||||
"""正常流程中,百度 ASR API 请求的 cuid 参数应等于 settings.baidu_asr_app_id。
|
||||
|
||||
这是本次修复的核心:之前 cuid 为空导致百度返回 "url param cuid error"。
|
||||
"""
|
||||
# Arrange
|
||||
test_app_id = "12345678"
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", test_app_id)
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "test_api_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "test_secret_key")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# 配置 mock HTTP 客户端
|
||||
_MockAsyncHTTPClient._post_return_value = _MockBaiduHttpResponse({
|
||||
"err_no": 0,
|
||||
"result": ["你好世界"],
|
||||
})
|
||||
|
||||
# Act
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="fake_access_token")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _MockAsyncHTTPClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert — 响应正确
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["text"] == "你好世界"
|
||||
assert result["message"] == "success"
|
||||
|
||||
# Assert — cuid 参数正确映射为 app_id(核心验证点)
|
||||
# _MockAsyncHTTPClient 被实例化后,post_calls 记录在实例上
|
||||
# 通过检查 httpx.AsyncClient 的调用来验证参数
|
||||
import app.api.voice_asr as voice_asr_module
|
||||
# patch 已退出,恢复真实 httpx.AsyncClient,但实例化的 _MockAsyncHTTPClient
|
||||
# 实例的 post_calls 已记录。由于类被替换,每次 AsyncClient(timeout=30)
|
||||
# 创建一个新实例。我们无法直接拿到实例,但可以通过类变量检查。
|
||||
# 改用另一种方式:在 patch 期间捕获实例。
|
||||
# 重新设计:用 side_effect 捕获实例。
|
||||
_MockAsyncHTTPClient.reset()
|
||||
_MockAsyncHTTPClient._post_return_value = _MockBaiduHttpResponse({
|
||||
"err_no": 0,
|
||||
"result": ["你好世界"],
|
||||
})
|
||||
|
||||
captured_instances = []
|
||||
|
||||
class _CapturingClient(_MockAsyncHTTPClient):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
captured_instances.append(self)
|
||||
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="fake_access_token")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _CapturingClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
assert result["code"] == 0
|
||||
assert len(captured_instances) == 1
|
||||
client_instance = captured_instances[0]
|
||||
|
||||
# 验证 post 调用参数
|
||||
assert len(client_instance.post_calls) == 1
|
||||
call = client_instance.post_calls[0]
|
||||
called_url = call["args"][0] if call["args"] else call["kwargs"].get("url")
|
||||
assert called_url == BAIDU_ASR_API_URL
|
||||
|
||||
called_params = call["kwargs"].get("params", {})
|
||||
assert called_params["cuid"] == test_app_id
|
||||
assert called_params["token"] == "fake_access_token"
|
||||
assert called_params["dev_pid"] == BAIDU_ASR_DEV_PID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_recognition_returns_text(self, monkeypatch):
|
||||
"""正常识别成功时,应返回识别文字。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "test_app_id")
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "test_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "test_secret")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
_MockAsyncHTTPClient.reset()
|
||||
_MockAsyncHTTPClient._post_return_value = _MockBaiduHttpResponse({
|
||||
"err_no": 0,
|
||||
"result": ["测试识别结果文字"],
|
||||
})
|
||||
|
||||
# Act
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="token123")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _MockAsyncHTTPClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["text"] == "测试识别结果文字"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_baidu_api_error_returns_error_response(self, monkeypatch):
|
||||
"""百度 ASR 返回错误时(err_no != 0),应返回 error_response。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "test_app_id")
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "test_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "test_secret")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
_MockAsyncHTTPClient.reset()
|
||||
_MockAsyncHTTPClient._post_return_value = _MockBaiduHttpResponse({
|
||||
"err_no": 3301,
|
||||
"err_msg": "audio quality too low",
|
||||
"result": [],
|
||||
})
|
||||
|
||||
# Act
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="token123")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _MockAsyncHTTPClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["code"] == 3001
|
||||
assert "audio quality too low" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_error_response(self, monkeypatch):
|
||||
"""百度 ASR API 超时时,应返回超时错误响应。"""
|
||||
# Arrange
|
||||
import httpx as _httpx
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "test_app_id")
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "test_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "test_secret")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
_MockAsyncHTTPClient.reset()
|
||||
_MockAsyncHTTPClient._post_side_effect = _httpx.TimeoutException(
|
||||
"Connection timed out"
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="token123")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _MockAsyncHTTPClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert result["code"] == 3001
|
||||
assert "超时" in result["message"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试组 5:响应格式契约验证
|
||||
# =============================================================================
|
||||
class TestResponseFormatContract:
|
||||
"""验证错误响应和成功响应符合统一信封格式。"""
|
||||
|
||||
def setup_method(self):
|
||||
_MockAsyncHTTPClient.reset()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_response_has_three_keys(self, monkeypatch):
|
||||
"""错误响应应包含 code、data、message 三个字段。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
# Act
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert set(result.keys()) == {"code", "data", "message"}
|
||||
assert result["code"] == 3001
|
||||
assert result["data"] is None
|
||||
assert isinstance(result["message"], str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_response_has_three_keys(self, monkeypatch):
|
||||
"""成功响应应包含 code、data、message 三个字段。"""
|
||||
# Arrange
|
||||
monkeypatch.setattr(settings, "baidu_asr_app_id", "test_app_id")
|
||||
monkeypatch.setattr(settings, "baidu_asr_api_key", "test_key")
|
||||
monkeypatch.setattr(settings, "baidu_asr_secret_key", "test_secret")
|
||||
mock_audio = _make_mock_audio()
|
||||
|
||||
_MockAsyncHTTPClient._post_return_value = _MockBaiduHttpResponse({
|
||||
"err_no": 0,
|
||||
"result": ["成功识别"],
|
||||
})
|
||||
|
||||
# Act
|
||||
with patch("app.api.voice_asr.get_baidu_token", AsyncMock(return_value="token123")):
|
||||
with patch("app.api.voice_asr.httpx.AsyncClient", _MockAsyncHTTPClient):
|
||||
result = await transcribe_audio(mock_audio)
|
||||
|
||||
# Assert
|
||||
assert set(result.keys()) == {"code", "data", "message"}
|
||||
assert result["code"] == 0
|
||||
assert "text" in result["data"]
|
||||
assert result["message"] == "success"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# 构建 P2/P3 前端
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# 设置 npm 镜像
|
||||
$env:NPM_CONFIG_REGISTRY = "https://registry.npmmirror.com"
|
||||
|
||||
# 构建 frontend-agent
|
||||
Write-Host "Building frontend-agent..."
|
||||
Set-Location "D:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent"
|
||||
& "C:\Users\simon\.workbuddy\binaries\node\versions\22.22.2\node.exe" "C:\Users\simon\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js" install
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
|
||||
|
||||
& "C:\Users\simon\.workbuddy\binaries\node\versions\22.22.2\node.exe" "C:\Users\simon\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js" run build
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm run build failed" }
|
||||
|
||||
Write-Host "frontend-agent built successfully!"
|
||||
|
||||
# 构建 frontend-h5
|
||||
Write-Host "Building frontend-h5..."
|
||||
Set-Location "D:\资料\03-项目开发\wecom_it_smart_desk\frontend-h5"
|
||||
& "C:\Users\simon\.workbuddy\binaries\node\versions\22.22.2\node.exe" "C:\Users\simon\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js" install
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
|
||||
|
||||
& "C:\Users\simon\.workbuddy\binaries\node\versions\22.22.2\node.exe" "C:\Users\simon\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js" run build
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm run build failed" }
|
||||
|
||||
Write-Host "frontend-h5 built successfully!"
|
||||
|
||||
Write-Host "All done!"
|
||||
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
async def test():
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
keyword = "打印机驱动安装"
|
||||
|
||||
# 直接测试 Cypher 查询
|
||||
try:
|
||||
data = await neo4j_client.execute_read_query(
|
||||
"MATCH (i:Issue) WHERE i.name CONTAINS $keyword RETURN i.uuid AS uuid, i.name AS name, i.category AS category LIMIT 5",
|
||||
{"keyword": keyword}
|
||||
)
|
||||
print(f"Data type: {type(data)}")
|
||||
print(f"Data: {data}")
|
||||
if data:
|
||||
print(f"First record uuid: {data[0].get('uuid')}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
asyncio.run(test())
|
||||
@@ -0,0 +1,27 @@
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
async def test():
|
||||
from app.services.graph_query_service import get_graph_query_service
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
graph_service = await get_graph_query_service(neo4j_client)
|
||||
|
||||
# 测试关键词提取
|
||||
question = "打印机驱动安装"
|
||||
keywords = graph_service._extract_keywords(question)
|
||||
print(f"Question: {question}")
|
||||
print(f"Keywords: {keywords}")
|
||||
|
||||
# 测试每个关键词的查询
|
||||
for kw in keywords:
|
||||
print(f"\n=== Querying keyword: {kw} ===")
|
||||
try:
|
||||
result = await graph_service._query_by_keyword(kw)
|
||||
print(f"Result: {result}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
asyncio.run(test())
|
||||
@@ -0,0 +1,27 @@
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
async def test():
|
||||
from app.services.graph_query_service import get_graph_query_service
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
graph_service = await get_graph_query_service(neo4j_client)
|
||||
|
||||
keyword = "打印机驱动安装"
|
||||
print(f"Querying keyword: {keyword}")
|
||||
|
||||
# 直接调用 Neo4j 查询
|
||||
try:
|
||||
data = await neo4j_client.execute_read_query(
|
||||
"""MATCH (i:Issue) WHERE i.name CONTAINS $keyword RETURN i.uuid AS uuid, i.name AS name, i.category AS category LIMIT 5""",
|
||||
{"keyword": keyword}
|
||||
)
|
||||
print(f"Raw data: {data}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
asyncio.run(test())
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""打包 P2/P3 部署文件"""
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
# 待上传的后端文件
|
||||
backend_files = [
|
||||
'backend/app/utils/token_counter.py',
|
||||
'backend/app/services/automation/context_compressor.py',
|
||||
'backend/app/services/automation/snapshot_service.py',
|
||||
'backend/app/services/automation/correction_service.py',
|
||||
'backend/alembic/versions/049_add_p2_p3_tables.py',
|
||||
'backend/tests/test_p2_p3.py',
|
||||
# 修改的文件
|
||||
'backend/app/models/automation.py',
|
||||
'backend/app/constants.py',
|
||||
'backend/app/config.py',
|
||||
'backend/app/schemas/automation.py',
|
||||
'backend/app/api/automation.py',
|
||||
]
|
||||
|
||||
# 创建临时打包
|
||||
with tarfile.open('backend-p2-p3-deploy.tar.gz', 'w:gz') as tar:
|
||||
for f in backend_files:
|
||||
if os.path.exists(f):
|
||||
tar.add(f, arcname=f)
|
||||
print(f'Added: {f}')
|
||||
else:
|
||||
print(f'NOT FOUND: {f}')
|
||||
|
||||
print('Done!')
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重新打包前端部署文件 - 使用正确目录结构"""
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
os.chdir('D:/资料/03-项目开发/wecom_it_smart_desk')
|
||||
|
||||
# 重新打包 frontend-agent
|
||||
print("Repacking frontend-agent...")
|
||||
if os.path.exists('frontend-agent/dist'):
|
||||
with tarfile.open('frontend-agent-dist-v2.tar.gz', 'w:gz') as tar:
|
||||
tar.add('frontend-agent/dist', arcname='dist')
|
||||
print('Created: frontend-agent-dist-v2.tar.gz')
|
||||
|
||||
# 重新打包 frontend-h5
|
||||
print("Repacking frontend-h5...")
|
||||
if os.path.exists('frontend-h5/dist'):
|
||||
with tarfile.open('frontend-h5-dist-v2.tar.gz', 'w:gz') as tar:
|
||||
tar.add('frontend-h5/dist', arcname='dist')
|
||||
print('Created: frontend-h5-dist-v2.tar.gz')
|
||||
|
||||
print('Done!')
|
||||
@@ -0,0 +1,312 @@
|
||||
# =============================================================================
|
||||
# Sigma Detection Rules - IT智能服务台 (修订版)
|
||||
# =============================================================================
|
||||
# 适用于无密码登录系统(仅企微OAuth/扫码)
|
||||
# 编译目标: PostgreSQL (审计日志查询)
|
||||
# =============================================================================
|
||||
|
||||
---
|
||||
# 规则1: Token异常使用 - 多IP使用
|
||||
# 适用ATT&CK: T1078 (有效账户), T1552 (非安全凭据)
|
||||
---
|
||||
- id: itdesk-T1078-001
|
||||
title: 凭证异常 - Token多IP使用
|
||||
status: draft
|
||||
level: high
|
||||
description: |
|
||||
检测同一Token在短时间内被多个不同IP地址使用,可能是Token泄露或中间人攻击。
|
||||
当同一token_hash在1小时内出现3个及以上不同IP时触发告警。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1078/
|
||||
- https://attack.mitre.org/techniques/T1552/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.initial_access
|
||||
- attack.t1078
|
||||
- attack.credential_access
|
||||
- attack.t1552
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: token
|
||||
detection:
|
||||
selection:
|
||||
event: 'token_used'
|
||||
timeframe: 1h
|
||||
condition: |
|
||||
DISTINCT ip_address >= 3
|
||||
GROUP BY token_hash
|
||||
falsepositives:
|
||||
- 用户使用VPN导致IP跳变
|
||||
- 移动设备切换网络 (4G/WiFi)
|
||||
fields:
|
||||
- token_hash
|
||||
- employee_id
|
||||
- ip_address
|
||||
- created_at
|
||||
actions:
|
||||
- invalidate_token: true
|
||||
- notify_security: webhook
|
||||
- suspend_user: pending_review
|
||||
|
||||
---
|
||||
# 规则2: 越权访问检测
|
||||
# 适用ATT&CK: T1068 (权限提升)
|
||||
---
|
||||
- id: itdesk-T1068-001
|
||||
title: 权限提升 - 越权访问
|
||||
status: draft
|
||||
level: critical
|
||||
description: |
|
||||
检测用户尝试访问其角色无权访问的资源,可能是权限提升攻击。
|
||||
当同一用户在10分钟内触发3次及以上RBAC拒绝时触发告警。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1068/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.privilege_escalation
|
||||
- attack.t1068
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: rbac
|
||||
detection:
|
||||
selection:
|
||||
event: 'access_denied'
|
||||
reason: 'insufficient_permissions'
|
||||
timeframe: 10m
|
||||
condition: |
|
||||
COUNT(*) >= 3
|
||||
GROUP BY employee_id
|
||||
falsepositives:
|
||||
- 前端缓存导致的重复请求
|
||||
- 正常的权限申请流程
|
||||
fields:
|
||||
- employee_id
|
||||
- requested_resource
|
||||
- user_role
|
||||
- required_role
|
||||
- created_at
|
||||
actions:
|
||||
- notify_security: webhook
|
||||
- suspend_user: pending_review
|
||||
|
||||
---
|
||||
# 规则3: 数据外传 - 批量导出检测
|
||||
# 适用ATT&CK: T1041 (通过Web服务外传数据)
|
||||
---
|
||||
- id: itdesk-T1041-001
|
||||
title: 数据泄露 - 批量导出
|
||||
status: draft
|
||||
level: high
|
||||
description: |
|
||||
检测用户执行大量数据导出操作,可能是数据外传的前兆。
|
||||
当导出记录数超过1000条或导出文件大小超过100MB时触发告警。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1041/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.exfiltration
|
||||
- attack.t1041
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: audit
|
||||
detection:
|
||||
selection:
|
||||
action: 'data_export'
|
||||
condition: |
|
||||
record_count > 1000 OR export_size_mb > 100
|
||||
falsepositives:
|
||||
- 正常的报表导出任务
|
||||
- 数据备份脚本
|
||||
fields:
|
||||
- employee_id
|
||||
- export_type
|
||||
- record_count
|
||||
- export_size_mb
|
||||
- ip_address
|
||||
- created_at
|
||||
actions:
|
||||
- require_mfa_verification: true
|
||||
- notify_security: webhook
|
||||
|
||||
---
|
||||
# 规则4: 内部威胁 - 管理员非工作时间操作
|
||||
# 适用ATT&CK: T1056 (权限滥用)
|
||||
---
|
||||
- id: itdesk-T1056-001
|
||||
title: 内部威胁 - 管理员非工作时间操作
|
||||
status: draft
|
||||
level: high
|
||||
description: |
|
||||
检测管理员在非工作时间(22:00-06:00)执行敏感操作。
|
||||
敏感操作包括: 角色变更、配置修改、账号禁用等。
|
||||
这是内部威胁的重要指标。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1056/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.privilege_escalation
|
||||
- attack.t1056
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: audit
|
||||
detection:
|
||||
selection:
|
||||
action|IN:
|
||||
- role_change
|
||||
- config_change
|
||||
- account_disable
|
||||
- account_create_reset
|
||||
- data_export
|
||||
condition: |
|
||||
EXTRACT(HOUR FROM created_at) NOT BETWEEN 8 AND 22
|
||||
AND employee_id IN (
|
||||
SELECT employee_id FROM user_roles WHERE role = 'admin'
|
||||
)
|
||||
falsepositives:
|
||||
- 紧急故障处理
|
||||
- 定时任务执行
|
||||
fields:
|
||||
- employee_id
|
||||
- action
|
||||
- ip_address
|
||||
- created_at
|
||||
actions:
|
||||
- notify_security: webhook
|
||||
- require_mfa_verification: true
|
||||
|
||||
---
|
||||
# 规则5: 外部服务异常 - Dify API高错误率
|
||||
# 适用ATT&CK: T1071 (应用层协议)
|
||||
---
|
||||
- id: itdesk-T1071-001
|
||||
title: 外部服务异常 - Dify API高错误率
|
||||
status: draft
|
||||
level: medium
|
||||
description: |
|
||||
检测Dify AI服务的异常调用模式。
|
||||
当API错误率超过30%或响应时间超过30秒时触发告警。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1071/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.command_and_control
|
||||
- attack.t1071
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: dify
|
||||
detection:
|
||||
selection:
|
||||
service: 'dify'
|
||||
timeframe: 5m
|
||||
condition: |
|
||||
error_rate > 0.3 OR response_time_ms > 30000
|
||||
falsepositives:
|
||||
- Dify服务临时不可用
|
||||
- 网络抖动导致超时
|
||||
fields:
|
||||
- endpoint
|
||||
- error_count
|
||||
- total_count
|
||||
- avg_response_time
|
||||
- created_at
|
||||
actions:
|
||||
- switch_backup_channel: true
|
||||
- notify_security: webhook
|
||||
|
||||
---
|
||||
# 规则6: 异常会话 - 异地登录
|
||||
# 适用ATT&CK: T1078 (有效账户)
|
||||
---
|
||||
- id: itdesk-T1078-002
|
||||
title: 异常会话 - 异地登录
|
||||
status: draft
|
||||
level: medium
|
||||
description: |
|
||||
检测同一用户在短时间内从距离超过500km的不同位置登录。
|
||||
需要结合IP地理信息库进行判断。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1078/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.initial_access
|
||||
- attack.t1078
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: login
|
||||
detection:
|
||||
selection:
|
||||
result: 'success'
|
||||
timeframe: 30m
|
||||
condition: |
|
||||
DISTINCT ip_address >= 2
|
||||
AND ip_geolocation_distance > 500km
|
||||
GROUP BY employee_id
|
||||
falsepositives:
|
||||
- 用户使用VPN导致IP跳变
|
||||
- 移动设备切换网络
|
||||
fields:
|
||||
- employee_id
|
||||
- ip_address
|
||||
- login_source
|
||||
- created_at
|
||||
actions:
|
||||
- mark_suspicious: true
|
||||
- notify_security: webhook
|
||||
|
||||
---
|
||||
# 规则7: API滥用 - 高频调用
|
||||
# 适用ATT&CK: T1105 (入口工具传输)
|
||||
---
|
||||
- id: itdesk-T1105-001
|
||||
title: API滥用 - 高频调用
|
||||
status: draft
|
||||
level: high
|
||||
description: |
|
||||
检测特定API端点在短时间内被高频调用,可能是滥用或DDoS攻击。
|
||||
监控的端点包括管理后台敏感操作和数据查询接口。
|
||||
references:
|
||||
- https://attack.mitre.org/techniques/T1105/
|
||||
author: Threat Detection Engineer
|
||||
date: 2026/07/14
|
||||
tags:
|
||||
- attack.resource_development
|
||||
- attack.t1105
|
||||
logsource:
|
||||
category: application
|
||||
product: itdesk
|
||||
service: api
|
||||
detection:
|
||||
selection:
|
||||
path|IN:
|
||||
- /api/admin/roles/*
|
||||
- /api/admin/configs/*
|
||||
- /api/admin/agents/*
|
||||
- /api/admin/export/*
|
||||
- /api/employees/*
|
||||
timeframe: 1m
|
||||
condition: |
|
||||
COUNT(*) > 20
|
||||
GROUP BY employee_id, path
|
||||
falsepositives:
|
||||
- 批量数据导入任务
|
||||
- 正常的管理脚本
|
||||
fields:
|
||||
- employee_id
|
||||
- path
|
||||
- ip_address
|
||||
- request_count
|
||||
actions:
|
||||
- rate_limit: reduce
|
||||
- notify_security: webhook
|
||||
@@ -1,4 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
backend:
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||
@@ -0,0 +1,17 @@
|
||||
// 清除旧数据
|
||||
MATCH (n) DETACH DELETE n
|
||||
|
||||
// 重新创建测试数据(带 uuid)
|
||||
CREATE (i1:Issue {name: "打印机驱动安装", category: "硬件问题", uuid: "issue-001"})
|
||||
CREATE (a1:Action {name: "打印机驱动安装步骤", description: "1. 打开控制面板 2. 添加打印机 3. 选择手动添加"})
|
||||
CREATE (i1)-[:HAS_ACTION]->(a1)
|
||||
|
||||
CREATE (i2:Issue {name: "网络连不上", category: "网络问题", uuid: "issue-002"})
|
||||
CREATE (a2:Action {name: "网络诊断步骤", description: "1. 检查网线 2. 重启路由器 3. 检查IP配置"})
|
||||
CREATE (i2)-[:HAS_ACTION]->(a2)
|
||||
|
||||
CREATE (i3:Issue {name: "邮箱无法收发", category: "软件问题", uuid: "issue-003"})
|
||||
CREATE (a3:Action {name: "邮箱故障排除", description: "1. 检查网络 2. 清除缓存 3. 重新登录"})
|
||||
CREATE (i3)-[:HAS_ACTION]->(a3)
|
||||
|
||||
RETURN "测试数据添加完成"
|
||||
@@ -0,0 +1 @@
|
||||
.sidebar[data-v-c780a27f]{width:220px;background:var(--bg-secondary);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}.sidebar-logo[data-v-c780a27f]{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}.sidebar-logo-icon[data-v-c780a27f]{width:32px;height:32px;background:linear-gradient(135deg,#3b82f6,#8b5cf6);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff}.sidebar-logo-text[data-v-c780a27f]{font-size:15px;font-weight:600;color:var(--text-primary)}.sidebar-logo-sub[data-v-c780a27f]{font-size:10px;color:var(--text-muted)}.sidebar-menu[data-v-c780a27f]{flex:1;overflow-y:auto;border-right:none}.menu-section-title[data-v-c780a27f]{padding:12px 20px 6px;font-size:11px;color:var(--text-muted);letter-spacing:.5px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.sidebar-menu .el-menu-item[data-v-c780a27f]{height:40px;line-height:40px;font-size:13px;border-left:3px solid transparent;margin:0}.sidebar-menu .el-menu-item.is-active[data-v-c780a27f]{background-color:var(--accent-light);border-left-color:var(--accent)}.locked-menu-item[data-v-c780a27f]{opacity:.5;pointer-events:auto!important}.lock-icon[data-v-c780a27f]{margin-left:auto;font-size:12px;color:var(--text-muted)}.search-box-wrapper[data-v-a29232a7]{position:relative}.search-box[data-v-a29232a7]{display:flex;align-items:center;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:0 10px;gap:6px}.search-box[data-v-a29232a7]:focus-within{border-color:var(--accent)}.search-input[data-v-a29232a7]{background:transparent;border:none;color:var(--text-primary);padding:6px 0;font-size:13px;outline:none;width:180px}.search-input[data-v-a29232a7]::-moz-placeholder{color:var(--text-muted)}.search-input[data-v-a29232a7]::placeholder{color:var(--text-muted)}.search-results[data-v-a29232a7]{max-height:300px;overflow-y:auto}.search-loading[data-v-a29232a7],.search-empty[data-v-a29232a7]{padding:16px;text-align:center;color:var(--text-muted);font-size:13px}.search-result-count[data-v-a29232a7]{padding:8px 12px;font-size:11px;color:var(--text-muted);border-bottom:1px solid var(--border)}.search-result-item[data-v-a29232a7]{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;transition:background .2s;border-bottom:1px solid var(--border)}.search-result-item[data-v-a29232a7]:last-child{border-bottom:none}.search-result-item[data-v-a29232a7]:hover{background:var(--bg-tertiary)}.result-name[data-v-a29232a7]{font-size:13px;color:var(--text-primary)}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.agent-performance[data-v-efd02f09]{padding:0}.filter-bar[data-v-efd02f09]{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:center}.skill-tag[data-v-efd02f09]{margin-right:4px;margin-bottom:2px}.summary-bar[data-v-efd02f09]{margin-top:20px}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as E,o as T,b as u,c as g,e as p,f as t,w as o,j as I,aa as L,X as R,n as k,r as b,E as S,i,a4 as U,g as r,t as n,V as j,W as F,h as O,a2 as w,U as x}from"./index-CXRNRwY6.js";import{O as W}from"./admin-WeuzXR_q.js";import{_ as X}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const q={class:"agent-performance"},G={class:"filter-bar"},H={key:0,style:{color:"#909399"}},J={class:"summary-bar"},K=E({__name:"AgentPerformance",setup(Q){const m=b(!1),c=b([]),d=b(null),C=x(()=>c.value.reduce((a,l)=>a+(l.total_conversations||0),0)),V=x(()=>c.value.reduce((a,l)=>a+(l.resolved_conversations||0),0));async function f(){m.value=!0;try{const a={};d.value&&(a.date_from=d.value[0],a.date_to=d.value[1]);const l=await W(a);c.value=l.items||[]}catch{S.error("加载绩效数据失败")}finally{m.value=!1}}function z(a){return{online:"success",busy:"warning",offline:"info"}[a]||"info"}function D(a){return{online:"在线",busy:"忙碌",offline:"离线"}[a]||a}function B(a){if(a==="—")return"#909399";const l=parseInt(a);return l>=80?"#67c23a":l>=50?"#e6a23c":"#f56c6c"}return T(()=>f()),(a,l)=>{const M=i("el-date-picker"),N=i("el-button"),s=i("el-table-column"),v=i("el-tag"),P=i("el-table"),_=i("el-descriptions-item"),Y=i("el-descriptions"),A=U("loading");return u(),g("div",q,[p("div",G,[t(M,{modelValue:d.value,"onUpdate:modelValue":l[0]||(l[0]=e=>d.value=e),type:"daterange","range-separator":"至","start-placeholder":"开始日期","end-placeholder":"结束日期","value-format":"YYYY-MM-DD",style:{width:"280px"},onChange:f},null,8,["modelValue"]),t(N,{type:"primary",icon:I(L),onClick:f},{default:o(()=>[...l[1]||(l[1]=[r("刷新",-1)])]),_:1},8,["icon"])]),R((u(),k(P,{data:c.value,stripe:"",style:{width:"100%"}},{default:o(()=>[t(s,{prop:"name",label:"坐席姓名",width:"120"}),t(s,{prop:"status",label:"状态",width:"90"},{default:o(({row:e})=>[t(v,{type:z(e.status),size:"small"},{default:o(()=>[r(n(D(e.status)),1)]),_:2},1032,["type"])]),_:1}),t(s,{prop:"role",label:"角色",width:"80"},{default:o(({row:e})=>[t(v,{type:e.role==="admin"?"danger":"info",size:"small"},{default:o(()=>[r(n(e.role==="admin"?"组长":"坐席"),1)]),_:2},1032,["type"])]),_:1}),t(s,{prop:"skill_tags",label:"技能标签","min-width":"150"},{default:o(({row:e})=>{var y;return[(u(!0),g(j,null,F(e.skill_tags||[],h=>(u(),k(v,{key:h,size:"small",class:"skill-tag"},{default:o(()=>[r(n(h),1)]),_:2},1024))),128)),(y=e.skill_tags)!=null&&y.length?O("",!0):(u(),g("span",H,"—"))]}),_:1}),t(s,{prop:"current_load",label:"当前负载",width:"90",align:"center"},{default:o(({row:e})=>[p("span",{style:w({color:e.current_load>=e.max_load?"#f56c6c":"#303133"})},n(e.current_load)+"/"+n(e.max_load),5)]),_:1}),t(s,{prop:"total_conversations",label:"总会话数",width:"100",align:"center",sortable:""}),t(s,{prop:"resolved_conversations",label:"已结单",width:"90",align:"center",sortable:""}),t(s,{prop:"resolution_rate",label:"结单率",width:"90",align:"center"},{default:o(({row:e})=>[p("span",{style:w({color:B(e.resolution_rate)})},n(e.resolution_rate),5)]),_:1}),t(s,{prop:"today_conversations",label:"今日会话",width:"100",align:"center",sortable:""})]),_:1},8,["data"])),[[A,m.value]]),p("div",J,[t(Y,{column:4,border:"",size:"small"},{default:o(()=>[t(_,{label:"坐席总数"},{default:o(()=>[r(n(c.value.length),1)]),_:1}),t(_,{label:"在线坐席"},{default:o(()=>[r(n(c.value.filter(e=>e.status==="online").length),1)]),_:1}),t(_,{label:"总会话数"},{default:o(()=>[r(n(C.value),1)]),_:1}),t(_,{label:"总结单数"},{default:o(()=>[r(n(V.value),1)]),_:1})]),_:1})])])}}}),ae=X(K,[["__scopeId","data-v-efd02f09"]]);export{ae as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.agent-info[data-v-a8c5783f]{display:flex;align-items:center;gap:10px}.agent-avatar[data-v-a8c5783f]{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:12px;font-weight:600;flex-shrink:0}.agent-name[data-v-a8c5783f]{font-weight:500;color:var(--text-primary)}.agent-id[data-v-a8c5783f]{font-size:11px;color:var(--text-muted)}.text-muted[data-v-a8c5783f]{color:var(--text-muted);font-size:12px}.agent-table-row:hover td{background-color:var(--bg-tertiary)!important}.agents-toolbar[data-v-0e8504fe]{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.category-tabs[data-v-0e8504fe]{display:flex;gap:8px}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as C,o as x,b as o,c as l,e as a,V as A,W as B,r as k,a5 as p,g as c,t as i,n as f,w as d,h as g,f as v,E as r,i as u}from"./index-CXRNRwY6.js";import{J as V,K as z}from"./admin-WeuzXR_q.js";import{_ as L}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const N={class:"assignment-mode-page"},$={class:"mode-list"},q=["onClick"],E={class:"mode-info"},D={class:"mode-title"},F={class:"mode-desc"},I={key:0,class:"mode-lock-text"},J=C({__name:"AssignmentMode",setup(K){const _=k([{id:"manual",name:"手动接单",enabled:!0,locked:!1},{id:"round_robin",name:"轮询分配",enabled:!1,locked:!0,unlock_at:"阶段二"},{id:"least_active",name:"最少活跃优先",enabled:!1,locked:!0,unlock_at:"阶段二"},{id:"weighted",name:"加权比例分配",enabled:!1,locked:!0,unlock_at:"阶段三"},{id:"skill_match",name:"技能匹配分配",enabled:!1,locked:!0,unlock_at:"阶段三"},{id:"priority_queue",name:"优先队列",enabled:!1,locked:!0,unlock_at:"阶段三"}]),n=k("manual");x(async()=>{try{const s=await V();n.value=s.current_mode,s.modes&&s.modes.length>0&&(_.value=s.modes)}catch{}});async function y(e){if(e.locked){r.warning(`${e.name}暂未开放,${e.unlock_at||"后续版本"}解锁`);return}if(e.id!==n.value)try{await z(e.id),n.value=e.id,r.success(`已切换为「${e.name}」`)}catch{r.error("切换分配模式失败")}}function b(e){return{manual:"坐席在待办列表中自行选择接单。AB 角色冗余设计,1 人即可承担。",round_robin:"按坐席列表顺序依次分配,循环往复。",least_active:"自动分配给当前活跃会话数最少的坐席。",weighted:"按坐席权重分配(如高级权重2、初级权重1)。",skill_match:"根据问题类别匹配坐席技能标签。",priority_queue:"紧急/阻断性问题优先路由到高级坐席/组长。"}[e]||""}function h(e){return{round_robin:"坐席人数不足3人,暂不需要",least_active:"坐席人数不足3人,暂不需要",weighted:"坐席人数不足5人,暂不需要",skill_match:"需坐席≥5人 + 技能标签体系成熟",priority_queue:"需坐席≥5人 + 紧急度评分上线"}[e]||"后续阶段解锁"}return(e,s)=>{const m=u("el-tag"),w=u("Lock"),M=u("el-icon");return o(),l("div",N,[s[1]||(s[1]=a("div",{class:"page-title"},"消息分配模式",-1)),s[2]||(s[2]=a("div",{class:"page-desc"},"当前坐席1人足以承担,手动接单完全满足。后续模式按坐席规模自动解锁。",-1)),a("div",$,[(o(!0),l(A,null,B(_.value,t=>(o(),l("div",{key:t.id,class:p(["mode-card",{"mode-selected":t.id===n.value,"mode-locked":t.locked}]),onClick:R=>y(t)},[a("div",{class:p(["mode-radio",{selected:t.id===n.value}])},null,2),a("div",E,[a("div",D,[c(i(t.name)+" ",1),t.id===n.value?(o(),f(m,{key:0,type:"success",size:"small",class:"mode-badge"},{default:d(()=>[...s[0]||(s[0]=[c(" 当前启用 ",-1)])]),_:1})):t.locked?(o(),f(m,{key:1,type:t.unlock_at==="阶段三"?"info":"warning",size:"small",class:"mode-badge"},{default:d(()=>[c(i(t.unlock_at||"后续"),1)]),_:2},1032,["type"])):g("",!0)]),a("div",F,i(b(t.id)),1),t.locked?(o(),l("div",I,[v(M,{size:12},{default:d(()=>[v(w)]),_:1}),c(" "+i(h(t.id)),1)])):g("",!0)])],10,q))),128))])])}}}),G=L(J,[["__scopeId","data-v-781a7eba"]]);export{G as default};
|
||||
@@ -0,0 +1 @@
|
||||
.mode-list[data-v-781a7eba]{display:flex;flex-direction:column;gap:12px}.mode-card[data-v-781a7eba]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;align-items:flex-start;gap:16px;cursor:pointer;transition:border-color .2s}.mode-card[data-v-781a7eba]:hover{border-color:var(--border-hover)}.mode-card.mode-selected[data-v-781a7eba]{border-color:var(--accent)}.mode-card.mode-locked[data-v-781a7eba]{opacity:.5;cursor:not-allowed}.mode-card.mode-locked[data-v-781a7eba]:hover{border-color:var(--border)}.mode-radio[data-v-781a7eba]{width:20px;height:20px;border-radius:50%;border:2px solid var(--text-muted);display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px}.mode-radio.selected[data-v-781a7eba]{border-color:var(--accent)}.mode-radio.selected[data-v-781a7eba]:after{content:"";width:10px;height:10px;border-radius:50%;background:var(--accent)}.mode-info[data-v-781a7eba]{flex:1}.mode-title[data-v-781a7eba]{font-size:14px;font-weight:600;margin-bottom:2px;display:flex;align-items:center;gap:8px;color:var(--text-primary)}.mode-desc[data-v-781a7eba]{font-size:12px;color:var(--text-secondary);margin-bottom:4px}.mode-badge[data-v-781a7eba]{flex-shrink:0}.mode-lock-text[data-v-781a7eba]{font-size:11px;color:var(--text-muted);font-style:italic;display:flex;align-items:center;gap:4px;margin-top:4px}
|
||||
@@ -0,0 +1 @@
|
||||
.audit-logs[data-v-3c9b75a0]{padding:0}.filter-bar[data-v-3c9b75a0]{margin-bottom:16px;border:none;background:var(--bg-secondary)}.pagination-bar[data-v-3c9b75a0]{display:flex;justify-content:flex-end;margin-top:16px}.detail-block[data-v-3c9b75a0]{padding:8px 16px;background:var(--bg-secondary, #f5f7fa);border-radius:4px}.detail-title[data-v-3c9b75a0]{font-size:12px;color:#909399;margin-bottom:6px}.detail-json[data-v-3c9b75a0]{margin:0;white-space:pre-wrap;word-break:break-all;font-family:Menlo,Consolas,monospace;font-size:12px;line-height:1.5;max-height:280px;overflow:auto}.detail-meta[data-v-3c9b75a0]{display:flex;gap:24px;margin-top:8px;font-size:12px;color:#909399}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as E,o as F,b as g,c as f,f as o,w as n,X as P,n as j,e as d,r as w,v as z,E as K,i,a4 as O,p as $,Y as J,V as Q,W,g as c,t as p,h as X}from"./index-CXRNRwY6.js";import{Q as q}from"./admin-WeuzXR_q.js";import{_ as G}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const H={class:"audit-logs"},Z={class:"detail-block"},ee={class:"detail-json"},te={key:0,class:"detail-meta"},ae={class:"pagination-bar"},oe=E({__name:"AuditLogs",setup(le){const m=w(!1),v=w([]),l=z({employeeId:"",action:"",dateRange:null}),s=z({page:1,pageSize:50,total:0}),V=["config_change","login","logout","permission_change","role_assign","role_revoke","agent_create","agent_update","agent_delete","integration_update"],S=[{text:"最近24小时",value:()=>{const a=new Date,e=new Date;return e.setTime(e.getTime()-3600*1e3*24),[e,a]}},{text:"最近7天",value:()=>{const a=new Date,e=new Date;return e.setTime(e.getTime()-3600*1e3*24*7),[e,a]}}];function D(){const a={page:s.page,page_size:s.pageSize};return l.employeeId&&(a.employee_id=l.employeeId),l.action&&(a.action=l.action),l.dateRange&&l.dateRange.length===2&&(a.from=`${l.dateRange[0]}T00:00:00`,a.to=`${l.dateRange[1]}T23:59:59`),a}async function u(){m.value=!0;try{const a=await q(D());v.value=a.items||[],s.total=a.total||0}catch{K.error("加载安全审计日志失败")}finally{m.value=!1}}function y(){s.page=1,u()}function k(){l.employeeId="",l.action="",l.dateRange=null,s.page=1,u()}function x(){s.page=1,u()}function C(a){if(!a)return"—";const e=new Date(a);return isNaN(e.getTime())?a:e.toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}function I(a){if(a==null)return"—";try{return JSON.stringify(a,null,2)}catch{return String(a)}}return F(()=>u()),(a,e)=>{const T=i("el-input"),_=i("el-form-item"),N=i("el-option"),R=i("el-select"),L=i("el-date-picker"),b=i("el-button"),U=i("el-form"),M=i("el-card"),r=i("el-table-column"),h=i("el-tag"),Y=i("el-table"),A=i("el-pagination"),B=O("loading");return g(),f("div",H,[o(M,{class:"filter-bar",shadow:"never"},{default:n(()=>[o(U,{inline:!0,onSubmit:e[3]||(e[3]=$(()=>{},["prevent"]))},{default:n(()=>[o(_,{label:"操作人"},{default:n(()=>[o(T,{modelValue:l.employeeId,"onUpdate:modelValue":e[0]||(e[0]=t=>l.employeeId=t),placeholder:"employee_id",clearable:"",style:{width:"200px"},onKeyup:J(y,["enter"])},null,8,["modelValue"])]),_:1}),o(_,{label:"操作类型"},{default:n(()=>[o(R,{modelValue:l.action,"onUpdate:modelValue":e[1]||(e[1]=t=>l.action=t),placeholder:"全部",clearable:"",filterable:"","allow-create":"","default-first-option":"",style:{width:"200px"}},{default:n(()=>[(g(),f(Q,null,W(V,t=>o(N,{key:t,label:t,value:t},null,8,["label","value"])),64))]),_:1},8,["modelValue"])]),_:1}),o(_,{label:"时间范围"},{default:n(()=>[o(L,{modelValue:l.dateRange,"onUpdate:modelValue":e[2]||(e[2]=t=>l.dateRange=t),type:"daterange","range-separator":"至","start-placeholder":"开始日期","end-placeholder":"结束日期","value-format":"YYYY-MM-DD",shortcuts:S},null,8,["modelValue"])]),_:1}),o(_,null,{default:n(()=>[o(b,{type:"primary",onClick:y},{default:n(()=>[...e[6]||(e[6]=[c("查询",-1)])]),_:1}),o(b,{onClick:k},{default:n(()=>[...e[7]||(e[7]=[c("重置",-1)])]),_:1})]),_:1})]),_:1})]),_:1}),P((g(),j(Y,{data:v.value,stripe:"",style:{width:"100%"}},{default:n(()=>[o(r,{type:"expand"},{default:n(({row:t})=>[d("div",Z,[e[8]||(e[8]=d("div",{class:"detail-title"},"详情 (details)",-1)),d("pre",ee,p(I(t.details)),1),t.resource_id?(g(),f("div",te,[d("span",null,"资源ID:"+p(t.resource_id),1),d("span",null,"UA:"+p(t.user_agent||"—"),1)])):X("",!0)])]),_:1}),o(r,{prop:"created_at",label:"时间",width:"170"},{default:n(({row:t})=>[c(p(C(t.created_at)),1)]),_:1}),o(r,{prop:"employee_id",label:"操作人",width:"130","show-overflow-tooltip":""}),o(r,{prop:"action",label:"操作类型",width:"150"},{default:n(({row:t})=>[o(h,{size:"small",effect:"plain"},{default:n(()=>[c(p(t.action||"—"),1)]),_:2},1024)]),_:1}),o(r,{prop:"resource",label:"资源",width:"150","show-overflow-tooltip":""}),o(r,{prop:"result",label:"结果",width:"90"},{default:n(({row:t})=>[o(h,{size:"small",type:t.result==="success"?"success":"danger"},{default:n(()=>[c(p(t.result||"—"),1)]),_:2},1032,["type"])]),_:1}),o(r,{prop:"ip_address",label:"IP",width:"140","show-overflow-tooltip":""})]),_:1},8,["data"])),[[B,m.value]]),d("div",ae,[o(A,{"current-page":s.page,"onUpdate:currentPage":e[4]||(e[4]=t=>s.page=t),"page-size":s.pageSize,"onUpdate:pageSize":e[5]||(e[5]=t=>s.pageSize=t),total:s.total,"page-sizes":[20,50,100],layout:"total, sizes, prev, pager, next, jumper",onCurrentChange:u,onSizeChange:x},null,8,["current-page","page-size","total"])])])}}}),de=G(oe,[["__scopeId","data-v-3c9b75a0"]]);export{de as default};
|
||||
@@ -0,0 +1 @@
|
||||
.auto-metrics[data-v-f6392139]{padding:4px}.page-title[data-v-f6392139]{font-size:20px;font-weight:600;color:var(--text-primary)}.page-desc[data-v-f6392139]{color:var(--text-secondary);font-size:13px;margin:6px 0 16px}.stat-grid[data-v-f6392139]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;margin-bottom:16px}.breakdown[data-v-f6392139]{margin-top:8px}
|
||||
@@ -0,0 +1 @@
|
||||
import{S as o}from"./StatCard-CvXkeHsY.js";import{u as g}from"./automation-CSa-HzII.js";import{d as w,o as k,b as c,c as i,e as v,j as x,f as a,V as M,w as d,U as _,i as l,g as h}from"./index-CXRNRwY6.js";import{_ as y}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const C={class:"auto-metrics"},V={key:0,class:"loading-state"},A={class:"stat-grid"},B=w({__name:"AutoMetrics",setup(N){const n=g(),e=_(()=>n.metrics),m=_(()=>!e.value||e.value.total_sessions===0?"解决率 -":`解决率 ${Math.round(e.value.resolved_sessions/e.value.total_sessions*100)}%`),p=_(()=>{var s;const r=((s=e.value)==null?void 0:s.by_scenario)||{};return Object.entries(r).map(([u,t])=>({scenario:u,count:t}))});return k(()=>{n.loadMetrics()}),(r,s)=>{const u=l("el-skeleton"),t=l("el-table-column"),b=l("el-table"),f=l("el-card");return c(),i("div",C,[s[1]||(s[1]=v("div",{class:"page-title"},"自动化运营指标",-1)),s[2]||(s[2]=v("div",{class:"page-desc"},"阶段5 自动化闭环核心指标看板,数据实时反映自动化处置成效。",-1)),x(n).metrics?(c(),i(M,{key:1},[v("div",A,[a(o,{label:"自动化会话总数",value:e.value.total_sessions,subtitle:m.value,"value-color":"var(--accent)"},null,8,["value","subtitle"]),a(o,{label:"已解决",value:e.value.resolved_sessions,"value-color":"var(--success)"},null,8,["value"]),a(o,{label:"转人工",value:e.value.handoff_sessions,"value-color":"var(--warning)"},null,8,["value"]),a(o,{label:"异常会话",value:e.value.error_sessions,"value-color":"var(--danger)"},null,8,["value"]),a(o,{label:"自动执行动作",value:e.value.auto_executed_actions},null,8,["value"]),a(o,{label:"需审批动作",value:e.value.approval_required_actions,"value-color":"var(--warning)"},null,8,["value"])]),a(f,{class:"breakdown",shadow:"never"},{header:d(()=>[...s[0]||(s[0]=[h("按场景分布(会话数)",-1)])]),default:d(()=>[a(b,{data:p.value,stripe:""},{default:d(()=>[a(t,{prop:"scenario",label:"场景"}),a(t,{prop:"count",label:"会话数",width:"140"})]),_:1},8,["data"])]),_:1})],64)):(c(),i("div",V,[a(u,{rows:6,animated:""})]))])}}}),F=y(B,[["__scopeId","data-v-f6392139"]]);export{F as default};
|
||||
@@ -0,0 +1 @@
|
||||
.bind-page[data-v-78b70d78]{min-height:100vh;display:flex;align-items:center;justify-content:center;position:relative;background:var(--bg-primary)}.bind-bg[data-v-78b70d78]{position:absolute;top:0;right:0;bottom:0;left:0;overflow:hidden}.bind-bg-gradient[data-v-78b70d78]{position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at center,rgba(59,130,246,.08) 0%,transparent 60%);animation:rotate-78b70d78 30s linear infinite}@keyframes rotate-78b70d78{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.bind-card[data-v-78b70d78]{position:relative;z-index:1;width:400px;padding:40px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 4px 24px #0000004d}.bind-header[data-v-78b70d78]{text-align:center;margin-bottom:32px}.bind-logo-icon[data-v-78b70d78]{width:56px;height:56px;background:linear-gradient(135deg,#3b82f6,#8b5cf6);border-radius:14px;display:flex;align-items:center;justify-content:center;color:#fff;margin:0 auto 16px}.bind-title[data-v-78b70d78]{font-size:22px;font-weight:700;color:var(--text-primary);margin:0 0 4px}.bind-subtitle[data-v-78b70d78]{font-size:13px;color:var(--text-muted);margin:0}.wecom-user-info[data-v-78b70d78]{margin-bottom:24px}.bind-options[data-v-78b70d78]{display:flex;flex-direction:column;gap:16px}.bind-options[data-v-78b70d78] .el-radio-group{display:flex;flex-direction:column;gap:12px}.bind-options[data-v-78b70d78] .el-radio{padding:12px;background:var(--bg-tertiary);border-radius:8px}.next-btn[data-v-78b70d78]{width:100%;margin-top:24px}.bind-form[data-v-78b70d78]{margin-top:16px}.form-actions[data-v-78b70d78]{display:flex;gap:12px;margin-top:24px}.form-actions[data-v-78b70d78] .el-button{flex:1}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.config-group-card[data-v-fdfcb9b4]{background-color:var(--bg-secondary);border-color:var(--border)}.config-group-header[data-v-fdfcb9b4]{display:flex;align-items:center;justify-content:space-between}.config-group-title[data-v-fdfcb9b4]{display:flex;align-items:center;font-size:14px;font-weight:600;color:var(--text-primary)}.config-group-desc[data-v-fdfcb9b4]{font-size:12px;color:var(--text-secondary);margin-top:6px}.config-items[data-v-fdfcb9b4]{display:flex;flex-direction:column}.config-item[data-v-fdfcb9b4]{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid var(--border)}.config-item[data-v-fdfcb9b4]:first-child{border-top:none;padding-top:0}.config-item-info[data-v-fdfcb9b4]{display:flex;flex-direction:column;gap:2px}.config-item-name[data-v-fdfcb9b4]{font-size:13px;color:var(--text-primary)}.config-item-stage[data-v-fdfcb9b4]{font-size:11px;color:var(--text-muted)}.config-item-control[data-v-fdfcb9b4]{display:flex;align-items:center;gap:8px;flex-shrink:0}.config-json-preview[data-v-fdfcb9b4]{font-size:11px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.loading-state[data-v-f5be3c67]{padding:24px 0}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.health-list[data-v-5d2e4267]{padding:16px}.health-item[data-v-5d2e4267]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.health-item[data-v-5d2e4267]:last-child{margin-bottom:0}.health-name[data-v-5d2e4267]{font-size:13px;color:var(--text-primary)}.dashboard-table-row:hover td{background-color:var(--bg-tertiary)!important}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.evaluation-stats[data-v-e0edafa6]{padding:0}.stats-cards[data-v-e0edafa6]{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-bottom:20px}.stat-card[data-v-e0edafa6]{background:var(--bg-card);border-radius:8px;padding:20px;display:flex;align-items:center;gap:16px;border:1px solid var(--border)}.stat-card-icon[data-v-e0edafa6]{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:24px;color:#fff}.total-icon[data-v-e0edafa6]{background:linear-gradient(135deg,#409eff,#67b3ff)}.star-icon[data-v-e0edafa6]{background:linear-gradient(135deg,#e6a23c,#f0c78a)}.satisfied-icon[data-v-e0edafa6]{background:linear-gradient(135deg,#67c23a,#85ce61)}.neutral-icon[data-v-e0edafa6]{background:linear-gradient(135deg,#e6a23c,#ebb563)}.dissatisfied-icon[data-v-e0edafa6]{background:linear-gradient(135deg,#f56c6c,#f78989)}.stat-card-content[data-v-e0edafa6]{flex:1}.stat-card-value[data-v-e0edafa6]{font-size:28px;font-weight:600;color:var(--text-primary);line-height:1.2}.stat-card-label[data-v-e0edafa6]{font-size:13px;color:var(--text-secondary);margin-top:4px}.detail-section[data-v-e0edafa6]{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}.distribution-card[data-v-e0edafa6]{border-radius:8px}.card-header[data-v-e0edafa6]{font-size:15px;font-weight:500;color:var(--text-primary)}.star-bars[data-v-e0edafa6],.emoji-bars[data-v-e0edafa6]{display:flex;flex-direction:column;gap:12px}.star-bar-item[data-v-e0edafa6],.emoji-bar-item[data-v-e0edafa6]{display:grid;grid-template-columns:50px 1fr 120px;align-items:center;gap:12px}.star-label[data-v-e0edafa6],.emoji-label[data-v-e0edafa6]{font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:4px}.star-count[data-v-e0edafa6],.emoji-count[data-v-e0edafa6]{font-size:13px;color:var(--text-secondary);text-align:right}.table-card[data-v-e0edafa6]{border-radius:8px}.pagination-container[data-v-e0edafa6]{margin-top:20px;display:flex;justify-content:flex-end}@media (max-width: 1200px){.stats-cards[data-v-e0edafa6]{grid-template-columns:repeat(3,1fr)}}@media (max-width: 768px){.stats-cards[data-v-e0edafa6]{grid-template-columns:repeat(2,1fr)}.detail-section[data-v-e0edafa6]{grid-template-columns:1fr}.star-bar-item[data-v-e0edafa6],.emoji-bar-item[data-v-e0edafa6]{grid-template-columns:40px 1fr}.star-count[data-v-e0edafa6],.emoji-count[data-v-e0edafa6]{display:none}}
|
||||
@@ -0,0 +1 @@
|
||||
.exclusion-form[data-v-0dc6dc0c]{max-height:60vh;overflow-y:auto;padding-right:8px}.form-row[data-v-0dc6dc0c]{display:flex;gap:16px}.form-col[data-v-0dc6dc0c]{flex:1}.form-hint[data-v-0dc6dc0c]{font-size:12px;color:var(--text-muted, #909399);line-height:1.5;margin-top:4px}.test-dialog-content[data-v-48feb84a]{min-height:200px}.rule-info[data-v-48feb84a]{display:flex;align-items:center;gap:8px;margin-bottom:16px;padding:8px 12px;background:var(--bg-secondary, #f5f7fa);border-radius:6px}.rule-name[data-v-48feb84a]{font-size:13px;color:var(--text-primary, #303133);font-weight:500}.test-action[data-v-48feb84a]{display:flex;justify-content:center;margin-top:16px}.test-result[data-v-48feb84a]{margin-top:8px}.result-box[data-v-48feb84a]{border-radius:8px;padding:16px 20px}.result-hit[data-v-48feb84a]{background:#fef0f0;border:1px solid #fde2e2}.result-miss[data-v-48feb84a]{background:#f0f9eb;border:1px solid #e1f3d8}.result-header[data-v-48feb84a]{display:flex;align-items:center;gap:8px;margin-bottom:12px}.result-icon[data-v-48feb84a]{font-size:20px}.result-hit .result-icon[data-v-48feb84a]{color:#f56c6c}.result-miss .result-icon[data-v-48feb84a]{color:#67c23a}.result-title[data-v-48feb84a]{font-size:15px;font-weight:600}.result-hit .result-title[data-v-48feb84a]{color:#f56c6c}.result-miss .result-title[data-v-48feb84a]{color:#67c23a}.result-details[data-v-48feb84a]{display:flex;flex-direction:column;gap:8px}.result-row[data-v-48feb84a]{display:flex;align-items:center;gap:12px}.result-label[data-v-48feb84a]{font-size:13px;color:var(--text-muted, #909399);min-width:70px;flex-shrink:0}.result-value[data-v-48feb84a]{font-size:13px;color:var(--text-primary, #303133)}.result-desc[data-v-48feb84a]{font-size:13px;color:var(--text-secondary, #606266);line-height:1.6}.stats-cards[data-v-a9d8a868]{display:flex;gap:16px;margin-bottom:20px}.stat-card[data-v-a9d8a868]{flex:1;background:var(--bg-primary, #fff);border:1px solid var(--border, #e4e7ed);border-radius:8px;padding:16px 20px;text-align:center}.stat-value[data-v-a9d8a868]{font-size:28px;font-weight:700;margin-bottom:4px}.stat-enabled[data-v-a9d8a868]{color:#67c23a}.stat-disabled[data-v-a9d8a868]{color:#909399}.stat-hits[data-v-a9d8a868]{color:#409eff}.stat-transfers[data-v-a9d8a868]{color:#e6a23c}.stat-label[data-v-a9d8a868]{font-size:13px;color:var(--text-secondary, #909399)}.filter-bar[data-v-a9d8a868]{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}.filter-spacer[data-v-a9d8a868]{flex:1}.pagination-bar[data-v-a9d8a868]{display:flex;justify-content:flex-end;margin-top:16px}
|
||||
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
@@ -0,0 +1 @@
|
||||
.integration-card[data-v-468027ce]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;text-align:center;transition:border-color .2s}.integration-card[data-v-468027ce]:hover{border-color:var(--border-hover)}.integration-card.is-configurable[data-v-468027ce]{border-color:var(--accent);border-opacity:.3}.integration-icon[data-v-468027ce]{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;margin:0 auto 12px}.integration-name[data-v-468027ce]{font-size:14px;font-weight:600;margin-bottom:4px;color:var(--text-primary)}.integration-status[data-v-468027ce]{margin-bottom:12px}.integration-detail[data-v-468027ce]{font-size:11px;color:var(--text-muted);margin-bottom:12px;min-height:32px}.config-info[data-v-468027ce]{margin-bottom:2px}.integration-actions[data-v-468027ce]{display:flex;justify-content:center;gap:8px}.loading-state[data-v-2e402f21]{padding:24px 0}.form-hint[data-v-2e402f21]{font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4}.test-result[data-v-2e402f21]{text-align:center}.test-detail[data-v-2e402f21]{margin-top:8px;font-size:14px;color:var(--text-secondary)}.test-debug-hint[data-v-2e402f21]{display:flex;align-items:flex-start;gap:6px;margin-top:12px;padding:10px 12px;background:#f59e0b14;border:1px solid rgba(245,158,11,.2);border-radius:6px;font-size:12px;color:#d97706;text-align:left;line-height:1.5}.test-debug-info[data-v-2e402f21]{margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;font-size:11px;color:var(--text-muted);text-align:left;font-family:monospace;line-height:1.6}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.knowledge-toolbar[data-v-8f2a47d4]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;gap:16px;flex-wrap:wrap}.category-tabs[data-v-8f2a47d4]{display:flex;gap:8px;flex-wrap:wrap;flex:1}.knowledge-list[data-v-8f2a47d4]{display:flex;flex-direction:column;gap:12px}.knowledge-card[data-v-8f2a47d4]{margin-bottom:0}.card-header[data-v-8f2a47d4]{display:flex;justify-content:space-between;align-items:center}.card-title[data-v-8f2a47d4]{display:flex;align-items:center;gap:8px}.title-text[data-v-8f2a47d4]{font-weight:500}.card-actions[data-v-8f2a47d4]{display:flex;gap:8px}.card-content[data-v-8f2a47d4]{color:#666;font-size:14px;line-height:1.6}.card-content p[data-v-8f2a47d4]{margin:0;white-space:pre-wrap}.card-footer[data-v-8f2a47d4]{display:flex;justify-content:space-between;align-items:center}.tag-item[data-v-8f2a47d4]{margin-right:4px}.stats[data-v-8f2a47d4]{display:flex;align-items:center;gap:4px;color:#999;font-size:12px}.loading-state[data-v-8f2a47d4]{padding:24px 0}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.knowledge-iteration-page[data-v-e4dda180]{padding:20px}.knowledge-iteration-page__header[data-v-e4dda180]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.knowledge-iteration-page__header h2[data-v-e4dda180]{margin:0;font-size:20px;font-weight:600}.knowledge-iteration-page__header-actions[data-v-e4dda180]{display:flex;gap:8px}.knowledge-iteration-page__stats-bar[data-v-e4dda180]{display:flex;gap:16px;margin-bottom:16px}.knowledge-iteration-page__stat-item[data-v-e4dda180]{display:flex;flex-direction:column;align-items:center;padding:10px 20px;background:#f5f7fa;border-radius:8px;min-width:80px}.knowledge-iteration-page__stat-value[data-v-e4dda180]{font-size:24px;font-weight:700;color:#303133}.knowledge-iteration-page__stat-label[data-v-e4dda180]{font-size:12px;color:#909399;margin-top:2px}.knowledge-iteration-page__filters[data-v-e4dda180]{display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap}.knowledge-iteration-page__table[data-v-e4dda180]{background:#fff;border-radius:8px;overflow:hidden}.knowledge-iteration-page__pagination[data-v-e4dda180]{display:flex;justify-content:center;margin-top:20px}.knowledge-iteration-page__graph-filters[data-v-e4dda180]{display:flex;gap:10px;align-items:center;margin-bottom:16px}.knowledge-iteration-page__graph-info[data-v-e4dda180]{font-size:13px;color:#909399}.knowledge-iteration-page__graph-container[data-v-e4dda180]{width:100%;height:550px;background:#fff;border-radius:8px;border:1px solid #e4e7ed}
|
||||
@@ -0,0 +1 @@
|
||||
.knowledge-suggestions-page[data-v-b4848931]{padding:20px}.page-title[data-v-b4848931]{font-size:20px;font-weight:600;margin-bottom:8px}.page-desc[data-v-b4848931]{color:var(--text-secondary, #909399);margin-bottom:20px}.stats-cards[data-v-b4848931]{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:20px}.stat-card[data-v-b4848931]{text-align:center}.stat-card-content[data-v-b4848931]{padding:10px 0}.stat-value[data-v-b4848931]{font-size:28px;font-weight:600;color:var(--el-color-primary)}.stat-card-warning .stat-value[data-v-b4848931]{color:var(--el-color-warning)}.stat-card-success .stat-value[data-v-b4848931]{color:var(--el-color-success)}.stat-card-danger .stat-value[data-v-b4848931]{color:var(--el-color-danger)}.stat-label[data-v-b4848931]{font-size:14px;color:var(--text-secondary, #909399);margin-top:4px}.toolbar[data-v-b4848931]{display:flex;justify-content:space-between;margin-bottom:16px}.pagination-wrapper[data-v-b4848931]{display:flex;justify-content:flex-end;margin-top:16px}.content-box[data-v-b4848931]{max-height:200px;overflow-y:auto;padding:8px;background:var(--el-fill-color-light);border-radius:4px;white-space:pre-wrap}.mr-2[data-v-b4848931]{margin-right:8px}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.login-page[data-v-79a59725]{min-height:100vh;display:flex;align-items:center;justify-content:center;position:relative;background:var(--bg-primary)}.login-bg[data-v-79a59725]{position:absolute;top:0;right:0;bottom:0;left:0;overflow:hidden}.login-bg-gradient[data-v-79a59725]{position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at center,rgba(59,130,246,.08) 0%,transparent 60%);animation:rotate-79a59725 30s linear infinite}@keyframes rotate-79a59725{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.login-card[data-v-79a59725]{position:relative;z-index:1;width:400px;padding:40px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 4px 24px #0000004d}.login-logo[data-v-79a59725]{text-align:center;margin-bottom:32px}.login-logo-icon[data-v-79a59725]{width:56px;height:56px;background:linear-gradient(135deg,#3b82f6,#8b5cf6);border-radius:14px;display:flex;align-items:center;justify-content:center;color:#fff;margin:0 auto 16px}.login-title[data-v-79a59725]{font-size:22px;font-weight:700;color:var(--text-primary);margin:0 0 4px}.login-subtitle[data-v-79a59725]{font-size:13px;color:var(--text-muted);margin:0}.qr-login[data-v-79a59725]{text-align:center}.qr-container[data-v-79a59725]{width:200px;height:200px;margin:0 auto 16px;background:var(--bg-tertiary);border-radius:12px;display:flex;align-items:center;justify-content:center}.qr-loading[data-v-79a59725]{display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--text-muted)}.qr-code img[data-v-79a59725]{width:180px;height:180px}.qr-error[data-v-79a59725]{color:var(--text-muted);text-align:center}.qr-hint[data-v-79a59725]{font-size:14px;color:var(--text-muted);margin:0}.scan-tip[data-v-79a59725]{display:flex;align-items:center;justify-content:center;gap:4px;color:var(--success-color, #07c160);font-size:14px;margin-top:12px}.loading-container[data-v-79a59725]{display:flex;flex-direction:column;align-items:center;gap:16px;padding:40px 0;color:var(--text-secondary)}
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/auth-CFWKygdy.js","assets/index-BYeSIZFz.js","assets/index-CXRNRwY6.js","assets/index-BqADv29R.css"])))=>i.map(i=>d[i]);
|
||||
import{d as L,o as R,a as V,b as r,c as i,e as a,f as n,w as m,g as C,h as A,t as B,r as c,E as l,_ as I,i as b,u as N,j as w,k as D,l as O,m as M}from"./index-CXRNRwY6.js";import{getQrcode as Q,handleOAuthCallback as W,getScanStatus as j}from"./auth-CFWKygdy.js";import{u as $}from"./admin-CjbgpjK_.js";import{_ as F}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const G={class:"login-page"},H={class:"login-card"},J={class:"login-logo"},K={class:"login-logo-icon"},X={key:0,class:"qr-login"},Y={class:"qr-container"},Z={key:0,class:"qr-loading"},ee={key:1,class:"qr-code"},te=["src"],se={key:2,class:"qr-error"},ae={key:0,class:"scan-tip"},ne={key:1,class:"loading-container"},oe=L({__name:"Login",setup(re){const g=N(),u=$(),y=c(!0),k=c(""),S=c(!1),v=c(""),o=c(!1),p=c("请稍候...");let h=null,q="";function U(){return typeof navigator>"u"?!1:/wxwork/i.test(navigator.userAgent)}async function d(){S.value=!0,v.value="waiting";try{const t=await Q();k.value=`data:image/png;base64,${t.qrcode_png_base64}`,q=t.ticket,E()}catch(t){console.error("获取二维码失败:",t),l.error("获取二维码失败,请重试")}finally{S.value=!1}}async function x(){if(q)try{const t=await j(q);t.status==="scanned"?v.value="scanned":t.status==="confirmed"&&t.token?(_(),await P(t.token,t.user_info)):t.status==="expired"?(_(),l.warning("二维码已过期,请重新扫码"),d()):t.status==="cancelled"&&(_(),l.info("扫码已取消"),v.value="waiting",d())}catch(t){console.warn("轮询扫码状态失败:",t)}}function E(){_(),h=setInterval(x,2e3)}function _(){h&&(clearInterval(h),h=null)}async function P(t,e){o.value=!0,p.value="登录中...";try{if(e!=null&&e.is_new_user){g.push({name:"Bind",query:{userid:e.userid,name:e.name}});return}localStorage.setItem("admin_token",t),u.token=t,e&&(localStorage.setItem("admin_user_id",e.userid),u.adminUserId=e.userid,u.adminInfo={user_id:e.userid,name:e.name,status:"online"}),l.success("登录成功"),g.push("/")}catch(s){console.error("登录失败:",s),l.error("登录失败,请重试")}finally{o.value=!1}}async function T(t,e){o.value=!0,p.value="授权中...";try{const s=await W(t,e);if(s.is_new_user){g.push({name:"Bind",query:{userid:s.user_info.userid,name:s.user_info.name}});return}localStorage.setItem("admin_token",s.token),u.token=s.token,localStorage.setItem("admin_user_id",s.user_info.userid),u.adminUserId=s.user_info.userid,u.adminInfo={user_id:s.user_info.userid,name:s.user_info.name,status:"online"},l.success("登录成功"),g.push("/")}catch(s){console.error("OAuth回调处理失败:",s),l.error("授权失败,请重试")}finally{o.value=!1}}async function z(){const t=window.location.origin+"/itadmin/";try{const{getOAuthAuthorizeUrl:e}=await I(async()=>{const{getOAuthAuthorizeUrl:f}=await import("./auth-CFWKygdy.js");return{getOAuthAuthorizeUrl:f}},__vite__mapDeps([0,1,2,3])),s=await e(t);if(s.authorize_url){window.location.href=s.authorize_url;return}}catch(e){console.warn("从后端获取授权URL失败,尝试本地构造:",e)}{console.warn("未配置 VITE_WECOM_CORP_ID,无法跳转授权");return}}return R(async()=>{const t=new URLSearchParams(window.location.search),e=t.get("code"),s=t.get("state");if(e){await T(e,s||void 0);return}if(U()){o.value=!0,p.value="正在跳转企微授权...";try{await z()}catch(f){console.error("跳转授权失败:",f),y.value=!0,d()}finally{o.value=!1}}else y.value=!0,d()}),V(()=>{_()}),(t,e)=>{const s=b("el-icon"),f=b("el-button");return r(),i("div",G,[e[7]||(e[7]=a("div",{class:"login-bg"},[a("div",{class:"login-bg-gradient"})],-1)),a("div",H,[a("div",J,[a("div",K,[n(s,{size:28},{default:m(()=>[n(w(D))]),_:1})]),e[0]||(e[0]=a("h1",{class:"login-title"},"智能IT服务平台",-1)),e[1]||(e[1]=a("p",{class:"login-subtitle"},"管理后台",-1))]),y.value?(r(),i("div",X,[a("div",Y,[S.value?(r(),i("div",Z,[n(s,{class:"is-loading"},{default:m(()=>[n(w(O))]),_:1}),e[2]||(e[2]=a("p",null,"加载中...",-1))])):k.value?(r(),i("div",ee,[a("img",{src:k.value,alt:"企微扫码登录"},null,8,te)])):(r(),i("div",se,[e[4]||(e[4]=a("p",null,"获取二维码失败",-1)),n(f,{size:"small",onClick:d},{default:m(()=>[...e[3]||(e[3]=[C("重试",-1)])]),_:1})]))]),e[6]||(e[6]=a("p",{class:"qr-hint"},"请使用企业微信扫码登录",-1)),v.value==="scanned"?(r(),i("p",ae,[n(s,null,{default:m(()=>[n(w(M))]),_:1}),e[5]||(e[5]=C(" 已扫码,请在手机确认 ",-1))])):A("",!0)])):A("",!0),o.value?(r(),i("div",ne,[n(s,{class:"is-loading",size:"32"},{default:m(()=>[n(w(O))]),_:1}),a("p",null,B(p.value),1)])):A("",!0)])])}}}),_e=F(oe,[["__scopeId","data-v-79a59725"]]);export{_e as default};
|
||||
@@ -0,0 +1 @@
|
||||
.page-header[data-v-28b977a1]{margin-bottom:20px}.page-title[data-v-28b977a1]{font-size:18px;font-weight:600;color:var(--text-primary);margin-bottom:4px}.page-desc[data-v-28b977a1]{font-size:13px;color:var(--text-muted)}.filter-bar[data-v-28b977a1]{display:flex;gap:12px;align-items:center;margin-bottom:16px}.stat-cards[data-v-28b977a1]{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}.stat-card[data-v-28b977a1]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px 20px;transition:border-color .2s}.stat-card[data-v-28b977a1]:hover{border-color:var(--border-hover)}.stat-card--success[data-v-28b977a1]{border-left:3px solid var(--success)}.stat-card--info[data-v-28b977a1]{border-left:3px solid var(--text-muted)}.stat-label[data-v-28b977a1]{font-size:12px;color:var(--text-muted);margin-bottom:6px}.stat-value[data-v-28b977a1]{font-size:22px;font-weight:600;color:var(--text-primary);line-height:1.2}.data-table-wrapper[data-v-28b977a1]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:16px}.role-tag[data-v-28b977a1]{margin-right:4px}.text-muted[data-v-28b977a1]{color:var(--text-muted);font-size:12px}.pagination-bar[data-v-28b977a1]{display:flex;justify-content:flex-end}
|
||||
@@ -0,0 +1,4 @@
|
||||
import{d as L,o as W,b as r,c,e as n,f as o,Y as X,j as w,ad as P,w as l,aa as B,t as p,X as q,n as z,r as T,v as O,U,E as M,i as m,a4 as A,g as y,V as G,W as J,h as Q,$ as Z}from"./index-CXRNRwY6.js";import{a as N}from"./index-BYeSIZFz.js";import{_ as ee}from"./_plugin-vue_export-helper-DlAUqK2U.js";async function te(k={}){return await N.get("/auth/otp-admin-users",{params:k})}async function ae(k){return await N.post(`/auth/otp-admin-reset/${encodeURIComponent(k)}`)}const se={class:"mfa-manage-page"},ne={class:"filter-bar"},oe={class:"stat-cards"},le={class:"stat-card"},ie={class:"stat-value"},de={class:"stat-card stat-card--success"},re={class:"stat-value"},ue={class:"stat-card stat-card--info"},ce={class:"stat-value"},pe={class:"data-table-wrapper"},_e={key:0},me={key:1,class:"text-muted"},fe={key:0,class:"text-muted"},ge={key:0},ve={key:1,class:"text-muted"},ye={key:0},be={key:1,class:"text-muted"},he={key:1,class:"text-muted"},ke={class:"pagination-bar"},xe=L({__name:"MfaManage",setup(k){const b=T(!1),h=T([]),x=T(""),u=O({keyword:"",bound:""}),i=O({page:1,pageSize:20,total:0}),D=U(()=>h.value.filter(s=>s.mfa_enabled).length),E=U(()=>h.value.filter(s=>!s.mfa_enabled).length);async function f(){var s,e;b.value=!0;try{const a={page:i.page,page_size:i.pageSize};u.keyword.trim()&&(a.keyword=u.keyword.trim()),u.bound&&(a.bound=u.bound);const d=await te(a);h.value=d.items||[],i.total=d.total||0}catch(a){h.value=[],i.total=0;const d=((e=(s=a==null?void 0:a.response)==null?void 0:s.data)==null?void 0:e.message)||(a==null?void 0:a.message)||"加载用户 OTP 列表失败";M.error(d)}finally{b.value=!1}}function S(){i.page=1,f()}function R(){u.keyword="",u.bound="",i.page=1,f()}function I(){i.page=1,f()}async function j(s){var a,d;const e=s.name||s.employee_id;try{await Z.confirm(`确认清除 OTP 绑定?
|
||||
|
||||
坐席「${e} (${s.employee_id})」的 OTP 二次验证将被清除。
|
||||
该坐席下次登录时需重新绑定。`,"清除 OTP 绑定确认",{type:"warning",confirmButtonText:"确认清除",cancelButtonText:"取消",confirmButtonClass:"el-button--danger"})}catch{return}x.value=s.employee_id;try{await ae(s.employee_id),M.success(`已清除 ${e} 的 OTP 绑定`),await f()}catch(_){const v=((d=(a=_==null?void 0:_.response)==null?void 0:a.data)==null?void 0:d.message)||(_==null?void 0:_.message)||"清除失败";M.error(v)}finally{x.value=""}}function V(s){if(!s)return"—";const e=new Date(s);if(isNaN(e.getTime()))return s;const a=d=>String(d).padStart(2,"0");return`${e.getFullYear()}-${a(e.getMonth()+1)}-${a(e.getDate())} ${a(e.getHours())}:${a(e.getMinutes())}`}function F(s){return{user:"info",agent:"success",admin:"danger"}[s]||"info"}return W(()=>{f()}),(s,e)=>{const a=m("el-input"),d=m("el-option"),_=m("el-select"),v=m("el-button"),g=m("el-table-column"),$=m("el-tag"),K=m("el-table"),Y=m("el-pagination"),H=A("loading");return r(),c("div",se,[e[10]||(e[10]=n("div",{class:"page-header"},[n("div",null,[n("div",{class:"page-title"},"OTP 管理"),n("div",{class:"page-desc"},"管理所有用户的动态令牌(OTP)绑定状态,丢手机兜底清除")])],-1)),n("div",ne,[o(a,{modelValue:u.keyword,"onUpdate:modelValue":e[0]||(e[0]=t=>u.keyword=t),placeholder:"搜索员工姓名 / 员工账号","prefix-icon":w(P),clearable:"",style:{width:"260px"},onKeyup:X(S,["enter"])},null,8,["modelValue","prefix-icon"]),o(_,{modelValue:u.bound,"onUpdate:modelValue":e[1]||(e[1]=t=>u.bound=t),placeholder:"绑定状态",clearable:"",style:{width:"140px"}},{default:l(()=>[o(d,{label:"全部",value:""}),o(d,{label:"已绑定",value:"true"}),o(d,{label:"未绑定",value:"false"})]),_:1},8,["modelValue"]),o(v,{type:"primary",icon:w(P),onClick:S},{default:l(()=>[...e[4]||(e[4]=[y("搜索",-1)])]),_:1},8,["icon"]),o(v,{icon:w(B),onClick:R},{default:l(()=>[...e[5]||(e[5]=[y("重置",-1)])]),_:1},8,["icon"]),o(v,{icon:w(B),onClick:f},{default:l(()=>[...e[6]||(e[6]=[y("刷新",-1)])]),_:1},8,["icon"])]),n("div",oe,[n("div",le,[e[7]||(e[7]=n("div",{class:"stat-label"},"总用户数",-1)),n("div",ie,p(i.total),1)]),n("div",de,[e[8]||(e[8]=n("div",{class:"stat-label"},"已绑定",-1)),n("div",re,p(D.value),1)]),n("div",ue,[e[9]||(e[9]=n("div",{class:"stat-label"},"未绑定",-1)),n("div",ce,p(E.value),1)])]),n("div",pe,[q((r(),z(K,{data:h.value,stripe:"",size:"small","empty-text":"暂无用户记录"},{default:l(()=>[o(g,{prop:"employee_id",label:"员工账号","min-width":"140"}),o(g,{prop:"name",label:"姓名","min-width":"100"},{default:l(({row:t})=>[t.name?(r(),c("span",_e,p(t.name),1)):(r(),c("span",me,"—"))]),_:1}),o(g,{prop:"roles",label:"角色","min-width":"120"},{default:l(({row:t})=>[(r(!0),c(G,null,J(t.roles||[],C=>(r(),z($,{key:C,size:"small",type:F(C),class:"role-tag"},{default:l(()=>[y(p(C),1)]),_:2},1032,["type"]))),128)),!t.roles||t.roles.length===0?(r(),c("span",fe,"—")):Q("",!0)]),_:1}),o(g,{label:"OTP 状态","min-width":"100"},{default:l(({row:t})=>[o($,{type:t.mfa_enabled?"success":"info",size:"small"},{default:l(()=>[y(p(t.mfa_enabled?"已绑定":"未绑定"),1)]),_:2},1032,["type"])]),_:1}),o(g,{prop:"mfa_bound_at",label:"首次绑定时间","min-width":"160"},{default:l(({row:t})=>[t.mfa_bound_at?(r(),c("span",ge,p(V(t.mfa_bound_at)),1)):(r(),c("span",ve,"—"))]),_:1}),o(g,{prop:"last_verified_at",label:"最近验证时间","min-width":"160"},{default:l(({row:t})=>[t.last_verified_at?(r(),c("span",ye,p(V(t.last_verified_at)),1)):(r(),c("span",be,"—"))]),_:1}),o(g,{label:"操作",width:"140",fixed:"right"},{default:l(({row:t})=>[t.mfa_enabled?(r(),z(v,{key:0,type:"danger",size:"small",link:"",disabled:x.value===t.employee_id,onClick:C=>j(t)},{default:l(()=>[y(p(x.value===t.employee_id?"清除中...":"清除绑定"),1)]),_:2},1032,["disabled","onClick"])):(r(),c("span",he,"无需操作"))]),_:1})]),_:1},8,["data"])),[[H,b.value]])]),n("div",ke,[o(Y,{"current-page":i.page,"onUpdate:currentPage":e[2]||(e[2]=t=>i.page=t),"page-size":i.pageSize,"onUpdate:pageSize":e[3]||(e[3]=t=>i.pageSize=t),total:i.total,"page-sizes":[10,20,50,100],layout:"total, sizes, prev, pager, next, jumper",onCurrentChange:f,onSizeChange:I},null,8,["current-page","page-size","total"])])])}}}),Te=ee(xe,[["__scopeId","data-v-28b977a1"]]);export{Te as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{S as c}from"./StatCard-CvXkeHsY.js";import{L as S}from"./admin-WeuzXR_q.js";import{d as h,o as D,b as p,c as M,e as s,f as t,w as r,X as x,n as I,r as f,v as k,i as v,a4 as q,g as y,t as i}from"./index-CXRNRwY6.js";import{_ as E}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const O={class:"monitor-page"},P={class:"stats-grid"},z={class:"table-wrapper"},B={class:"table-header-row"},N={class:"session-id"},T=h({__name:"Monitor",setup(V){const u=f(!1),n=k({in_progress:0,queued:0,resolved_today:0,alerts:0}),d=f([]);D(async()=>{u.value=!0;try{const e=await S();Object.assign(n,e.stats),d.value=e.items}catch{n.in_progress=5,n.queued=0,n.resolved_today=42,n.alerts=1,d.value=[{id:"#C042",employee_name:"李明 (EMP101)",status:"serving",assigned_agent_name:"宋献",urgency_score:8,created_at:new Date(Date.now()-5*6e4).toISOString(),last_message_summary:"电脑无法开机"},{id:"#C041",employee_name:"赵刚 (EMP205)",status:"serving",assigned_agent_name:"宋献",urgency_score:14,created_at:new Date(Date.now()-12*6e4).toISOString(),last_message_summary:"VPN连接失败"},{id:"#C040",employee_name:"孙芳 (EMP312)",status:"timeout",assigned_agent_name:"王丽",urgency_score:6,created_at:new Date(Date.now()-8*6e4).toISOString(),last_message_summary:"打印机脱机"},{id:"#C039",employee_name:"周磊 (EMP088)",status:"serving",assigned_agent_name:"宋献",urgency_score:4,created_at:new Date(Date.now()-3*6e4).toISOString(),last_message_summary:"邮箱登录失败"},{id:"#C038",employee_name:"吴婷 (EMP156)",status:"ai_chatting",assigned_agent_name:"AI 助手",urgency_score:2,created_at:new Date(Date.now()-1*6e4).toISOString(),last_message_summary:"密码重置请求"}]}finally{u.value=!1}});function w(l){try{const e=new Date(l.created_at).getTime(),o=Date.now()-e,m=Math.floor(o/6e4),g=Math.floor(o%6e4/1e3);return`${m}分${g}秒`}catch{return"-"}}function b(l){switch(l){case"serving":return"success";case"timeout":return"warning";case"ai_chatting":return"primary";case"queued":return"info";default:return"info"}}function C(l){switch(l){case"serving":return"进行中";case"timeout":return"超时未响应";case"ai_chatting":return"AI 对话中";case"queued":return"等待中";default:return l}}return(l,e)=>{const _=v("el-tag"),o=v("el-table-column"),m=v("el-table"),g=q("loading");return p(),M("div",O,[e[2]||(e[2]=s("div",{class:"page-title"},"会话监控",-1)),e[3]||(e[3]=s("div",{class:"page-desc"}," 实时查看坐席工作状态、会话队列、异常告警。阶段二功能,当前为 Demo 预览。 ",-1)),s("div",P,[t(c,{label:"进行中会话",value:n.in_progress,valueColor:"var(--accent)",icon:"ChatLineSquare",iconColor:"var(--accent)"},null,8,["value"]),t(c,{label:"等待中",value:n.queued,valueColor:"var(--warning)",icon:"Clock",iconColor:"var(--warning)"},null,8,["value"]),t(c,{label:"今日已结单",value:n.resolved_today,valueColor:"var(--success)",icon:"CircleCheckFilled",iconColor:"var(--success)"},null,8,["value"]),t(c,{label:"异常告警",value:n.alerts,valueColor:"var(--danger)",icon:"WarningFilled",iconColor:"var(--danger)"},null,8,["value"])]),s("div",z,[s("div",B,[e[1]||(e[1]=s("div",{class:"table-title"},"实时会话",-1)),t(_,{type:"primary",size:"small"},{default:r(()=>[...e[0]||(e[0]=[y("Demo 预览",-1)])]),_:1})]),x((p(),I(m,{data:d.value,style:{width:"100%"},"header-cell-style":{background:"var(--bg-tertiary)",color:"var(--text-secondary)",fontSize:"12px"},"cell-style":{color:"var(--text-primary)",fontSize:"13px"},"row-class-name":"monitor-table-row","element-loading-text":"正在加载会话数据..."},{default:r(()=>[t(o,{label:"会话ID",width:"80"},{default:r(({row:a})=>[s("span",N,i(a.id),1)]),_:1}),t(o,{label:"员工","min-width":"160"},{default:r(({row:a})=>[s("span",null,i(a.employee_name),1)]),_:1}),t(o,{label:"坐席",width:"100"},{default:r(({row:a})=>[s("span",null,i(a.assigned_agent_name),1)]),_:1}),t(o,{label:"持续时长",width:"100"},{default:r(({row:a})=>[s("span",null,i(w(a)),1)]),_:1}),t(o,{label:"紧急度",width:"80",align:"center"},{default:r(({row:a})=>[s("span",null,i(a.urgency_score||"-"),1)]),_:1}),t(o,{label:"状态",width:"120"},{default:r(({row:a})=>[t(_,{type:b(a.status),size:"small"},{default:r(()=>[y(i(C(a.status)),1)]),_:2},1032,["type"])]),_:1})]),_:1},8,["data"])),[[g,u.value]])])])}}}),W=E(T,[["__scopeId","data-v-56d82a2c"]]);export{W as default};
|
||||
@@ -0,0 +1 @@
|
||||
.session-id[data-v-56d82a2c]{font-family:SF Mono,Fira Code,monospace;color:var(--accent)}.monitor-table-row:hover td{background-color:var(--bg-tertiary)!important}
|
||||
@@ -0,0 +1 @@
|
||||
.matrix-page[data-v-6385fdb6]{padding:20px}.page-header[data-v-6385fdb6]{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:20px;gap:16px;flex-wrap:wrap}.page-title[data-v-6385fdb6]{font-size:22px;font-weight:600;color:#f1f5f9}.page-desc[data-v-6385fdb6]{font-size:13px;color:#94a3b8;margin-top:4px}.header-actions[data-v-6385fdb6]{display:flex;gap:8px;flex-wrap:wrap}.legend[data-v-6385fdb6]{display:flex;gap:12px;align-items:center;margin-bottom:16px;padding:12px 16px;background:#1e293b80;border-radius:8px;font-size:13px}.legend-title[data-v-6385fdb6]{color:#94a3b8;font-weight:500;margin-right:4px}.legend-item[data-v-6385fdb6]{padding:2px 8px;border-radius:4px;font-size:12px;font-weight:500}.legend-own[data-v-6385fdb6]{background:#dbeafe;color:#1e40af}.legend-dept[data-v-6385fdb6]{background:#93c5fd;color:#1e3a8a}.legend-all[data-v-6385fdb6]{background:#1e40af;color:#fff}.legend-none[data-v-6385fdb6]{background:#e2e8f0;color:#64748b}.matrix-table[data-v-6385fdb6]{margin-bottom:24px}.resource-label[data-v-6385fdb6]{font-family:JetBrains Mono,monospace;font-size:12px;color:#1e293b}.role-header[data-v-6385fdb6]{text-align:center}.role-name[data-v-6385fdb6]{font-weight:600;color:#1e293b}.role-id[data-v-6385fdb6]{font-size:11px;color:#94a3b8;font-family:monospace;margin-top:2px}.perm-cell[data-v-6385fdb6]{display:flex;flex-direction:column;gap:4px;align-items:center}.scope-tag[data-v-6385fdb6]{font-size:10px;font-family:monospace}.scope-own[data-v-6385fdb6]{background:#dbeafe;color:#1e40af;border-color:#93c5fd}.scope-department[data-v-6385fdb6]{background:#93c5fd;color:#1e3a8a;border-color:#60a5fa}.scope-all[data-v-6385fdb6]{background:#1e40af;color:#fff;border-color:#1e3a8a}.no-perm[data-v-6385fdb6]{color:#cbd5e1;font-size:14px}.role-detail[data-v-6385fdb6]{margin-top:24px}.section-title[data-v-6385fdb6]{font-size:16px;font-weight:600;color:#1e293b;margin-bottom:12px}.role-meta[data-v-6385fdb6]{display:flex;flex-direction:column;gap:4px}.role-desc[data-v-6385fdb6]{font-size:13px;color:#475569;line-height:1.4}.role-perm-count[data-v-6385fdb6]{font-size:12px;color:#94a3b8;margin-top:4px}
|
||||
@@ -0,0 +1,2 @@
|
||||
import{d as W,o as q,b as a,c as p,e as l,f as o,w as n,n as m,V as g,a8 as G,r as R,U as z,E as D,i as r,W as k,j as E,a9 as H,g as y,aa as J,t as v,a5 as K,h as O}from"./index-CXRNRwY6.js";import{a as Q}from"./index-BYeSIZFz.js";import{_ as X}from"./_plugin-vue_export-helper-DlAUqK2U.js";const Y={class:"matrix-page"},Z={class:"page-header"},ee={class:"header-actions"},te={class:"resource-label"},se={class:"role-header"},ae={class:"role-name"},le={class:"role-id"},ne={class:"perm-cell"},oe={key:0,class:"no-perm"},re={class:"role-detail"},ie={class:"role-meta"},ce={class:"role-desc"},de={class:"role-perm-count"},ue=W({__name:"PermissionsMatrix",setup(pe){const x=R(!1),i=R({roles:[],resources:[],actions:[],scopes:[],matrix:{}}),w=R(""),V=R(""),U=z(()=>i.value.roles.length>0),C=z(()=>w.value?i.value.roles.filter(s=>s.name===w.value):i.value.roles),$=z(()=>{const s=[];for(const t of i.value.resources)for(const c of i.value.actions)s.push({resource:t,action:c,key:`${t}:${c}`});return s}),B=z(()=>V.value?$.value.filter(s=>s.resource===V.value):$.value);async function L(){x.value=!0;try{const s=await Q.get("/admin/roles/permissions/matrix");i.value=s}catch(s){console.error("loadMatrix 失败:",s),D.error("加载失败: "+((s==null?void 0:s.message)||"未知错误"))}finally{x.value=!1}}function j(s,t){var _;const c=[];for(const d of i.value.scopes){const u=`${s.resource}:${s.action}:${d}`;(_=i.value.matrix[t])!=null&&_[u]&&c.push(d)}return c}function T(s){return{read:"info",create:"success",update:"warning",delete:"danger"}[s]||"info"}function A(){const t=[["resource","action",...C.value.map(f=>f.name)].join(",")];for(const f of B.value){const b=[f.resource,f.action];for(const h of C.value){const M=j(f,h.name);b.push(M.join("|")||"")}t.push(b.join(","))}const c=t.join(`
|
||||
`),_=new Blob([c],{type:"text/csv;charset=utf-8"}),d=URL.createObjectURL(_),u=document.createElement("a");u.href=d,u.download=`rbac-matrix-${new Date().toISOString().slice(0,10)}.csv`,u.click(),URL.revokeObjectURL(d),D.success("CSV 已导出")}return q(()=>{L()}),(s,t)=>{const c=r("el-option"),_=r("el-select"),d=r("el-icon"),u=r("el-button"),f=r("el-skeleton"),b=r("el-table-column"),h=r("el-tag"),M=r("el-table"),F=r("el-descriptions-item"),I=r("el-descriptions"),P=r("el-empty");return a(),p("div",Y,[l("div",Z,[t[4]||(t[4]=l("div",null,[l("div",{class:"page-title"},"RBAC 权限矩阵"),l("div",{class:"page-desc"}," 5 角色 × 4 资源 × 4 操作 × 3 数据范围,共 240 个权限点 (5 × 16 × 3) ")],-1)),l("div",ee,[o(_,{modelValue:w.value,"onUpdate:modelValue":t[0]||(t[0]=e=>w.value=e),placeholder:"筛选角色",size:"default",clearable:"",style:{width:"180px"}},{default:n(()=>[(a(!0),p(g,null,k(i.value.roles,e=>(a(),m(c,{key:e.name,label:e.display_name+" ("+e.name+")",value:e.name},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),o(_,{modelValue:V.value,"onUpdate:modelValue":t[1]||(t[1]=e=>V.value=e),placeholder:"筛选资源",size:"default",clearable:"",style:{width:"180px"}},{default:n(()=>[(a(!0),p(g,null,k(i.value.resources,e=>(a(),m(c,{key:e,label:e,value:e},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),o(u,{onClick:A},{default:n(()=>[o(d,null,{default:n(()=>[o(E(H))]),_:1}),t[2]||(t[2]=y(" 导出 CSV ",-1))]),_:1}),o(u,{onClick:L,loading:x.value},{default:n(()=>[o(d,null,{default:n(()=>[o(E(J))]),_:1}),t[3]||(t[3]=y(" 刷新 ",-1))]),_:1},8,["loading"])])]),x.value&&!U.value?(a(),m(f,{key:0,rows:10,animated:""})):U.value?(a(),p(g,{key:1},[t[8]||(t[8]=G('<div class="legend" data-v-6385fdb6><span class="legend-title" data-v-6385fdb6>数据范围:</span><span class="legend-item legend-own" data-v-6385fdb6>own — 自己的</span><span class="legend-item legend-dept" data-v-6385fdb6>department — 部门的</span><span class="legend-item legend-all" data-v-6385fdb6>all — 全部</span><span class="legend-item legend-none" data-v-6385fdb6>无权限</span></div>',1)),o(M,{data:B.value,stripe:"",size:"small","empty-text":x.value?"加载中...":"无数据",class:"matrix-table"},{default:n(()=>[o(b,{label:"资源",prop:"resource",width:"140",fixed:""},{default:n(({row:e})=>[l("span",te,v(e.resource),1)]),_:1}),o(b,{label:"操作",prop:"action",width:"100",fixed:""},{default:n(({row:e})=>[o(h,{size:"small",type:T(e.action)},{default:n(()=>[y(v(e.action),1)]),_:2},1032,["type"])]),_:1}),(a(!0),p(g,null,k(C.value,e=>(a(),m(b,{key:e.name,label:e.display_name,"min-width":"140",align:"center"},{header:n(()=>[l("div",se,[l("div",ae,v(e.display_name),1),l("div",le,v(e.name),1)])]),default:n(({row:N})=>[l("div",ne,[(a(!0),p(g,null,k(j(N,e.name),S=>(a(),m(h,{key:S,size:"small",class:K(["scope-tag",`scope-${S}`])},{default:n(()=>[y(v(S),1)]),_:2},1032,["class"]))),128)),j(N,e.name).length===0?(a(),p("span",oe,"—")):O("",!0)])]),_:2},1032,["label"]))),128))]),_:1},8,["data","empty-text"]),l("div",re,[t[7]||(t[7]=l("div",{class:"section-title"},"角色详情",-1)),o(I,{column:2,border:"",size:"small"},{default:n(()=>[(a(!0),p(g,null,k(C.value,e=>(a(),m(F,{key:e.name,label:e.display_name+" ("+e.name+")"},{default:n(()=>[l("div",ie,[l("div",ce,v(e.description||"—"),1),l("div",de,[t[6]||(t[6]=y(" 权限数: ",-1)),l("b",null,v(e.permission_count),1),e.is_default?(a(),m(h,{key:0,type:"info",size:"small",style:{"margin-left":"8px"}},{default:n(()=>[...t[5]||(t[5]=[y("默认",-1)])]),_:1})):O("",!0)])])]),_:2},1032,["label"]))),128))]),_:1})])],64)):(a(),m(P,{key:2,description:"加载失败, 请检查网络或权限"}))])}}}),ve=X(ue,[["__scopeId","data-v-6385fdb6"]]);export{ve as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{d as v,b,c as g,e as s,f as e,w as n,t as r,U as i,i as o,g as x,u as C,q as T}from"./index-CXRNRwY6.js";const k={class:"placeholder-center"},y={class:"placeholder-icon"},B={class:"placeholder-title"},H={class:"placeholder-desc"},w=v({__name:"Placeholder",setup(N){const d=C(),t=T(),u=i(()=>{const a=t.meta.title;return t.path==="/:pathMatch(.*)*"?"页面未找到":a||"功能开发中"}),_=i(()=>t.path==="/:pathMatch(.*)*"?"您访问的页面不存在":t.meta.comingSoon?"该功能正在开发中,敬请期待":"此功能将在后续版本中开放,请关注版本更新");function p(){d.push("/dashboard")}return(a,c)=>{const m=o("Tools"),l=o("el-icon"),h=o("HomeFilled"),f=o("el-button");return b(),g("div",k,[s("div",y,[e(l,{size:64},{default:n(()=>[e(m)]),_:1})]),s("div",B,r(u.value),1),s("div",H,r(_.value),1),e(f,{type:"primary",onClick:p},{default:n(()=>[e(l,null,{default:n(()=>[e(h)]),_:1}),c[0]||(c[0]=x(" 返回运营总览 ",-1))]),_:1})])}}});export{w as default};
|
||||
@@ -0,0 +1 @@
|
||||
.reply-card[data-v-eab703b7]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;margin-bottom:10px;transition:border-color .2s}.reply-card[data-v-eab703b7]:hover{border-color:var(--border-hover)}.reply-card.is-pending[data-v-eab703b7]{border-color:var(--warning)}.reply-card-header[data-v-eab703b7]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.reply-card-title[data-v-eab703b7]{font-size:13px;font-weight:600;color:var(--text-primary);display:flex;align-items:center}.reply-card-content[data-v-eab703b7]{font-size:12px;color:var(--text-secondary);line-height:1.6;background:var(--bg-primary);padding:10px;border-radius:6px;margin-bottom:8px}.reply-card-footer[data-v-eab703b7]{font-size:11px;color:var(--text-muted);display:flex;align-items:center;flex-wrap:wrap;gap:2px}.footer-label[data-v-eab703b7]{color:var(--text-muted)}.footer-value[data-v-eab703b7]{color:var(--text-secondary)}.footer-sep[data-v-eab703b7]{margin:0 4px;color:var(--text-muted)}.replies-toolbar[data-v-75778112]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.category-tabs[data-v-75778112]{display:flex;gap:8px;flex-wrap:wrap}.loading-state[data-v-75778112]{padding:24px 0}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.ragflow-ingestion-page[data-v-9f315502]{padding:20px;max-width:800px}.ragflow-ingestion-page__header h2[data-v-9f315502]{margin:0 0 16px;font-size:20px;font-weight:600}.ragflow-ingestion-page__alert[data-v-9f315502]{margin-bottom:16px}.ragflow-ingestion-page__upload[data-v-9f315502]{border:2px dashed #dcdfe6;border-radius:12px;padding:40px;text-align:center;background:#fafafa;transition:all .3s;margin-bottom:20px}.ragflow-ingestion-page__upload--dragover[data-v-9f315502]{border-color:#409eff;background:#ecf5ff}.ragflow-ingestion-page__upload-content[data-v-9f315502]{display:flex;flex-direction:column;align-items:center;gap:12px}.ragflow-ingestion-page__upload-text[data-v-9f315502]{font-size:14px;color:#606266;margin:0}.ragflow-ingestion-page__upload-hint[data-v-9f315502]{font-size:12px;color:#c0c4cc;margin:0}.ragflow-ingestion-page__category-select[data-v-9f315502]{display:flex;align-items:center;gap:8px;font-size:13px;color:#606266}.ragflow-ingestion-page__upload-btn[data-v-9f315502]{margin-top:8px}.ragflow-ingestion-page__file-input[data-v-9f315502]{display:none}.ragflow-ingestion-page__result[data-v-9f315502]{margin-bottom:20px}.ragflow-ingestion-page__result-info[data-v-9f315502]{display:flex;gap:16px;align-items:center;margin-bottom:12px;font-size:13px}.ragflow-ingestion-page__suggestions[data-v-9f315502]{margin-bottom:12px}.ragflow-ingestion-page__suggestion-item[data-v-9f315502]{padding:8px 12px;background:#f5f7fa;border-radius:6px;margin-bottom:6px}.ragflow-ingestion-page__suggestion-header[data-v-9f315502]{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;font-size:13px;font-weight:500}.ragflow-ingestion-page__suggestion-category[data-v-9f315502]{font-size:12px;color:#909399}.ragflow-ingestion-page__result-note[data-v-9f315502]{margin-top:12px;padding:8px 12px;background:#f0f9eb;border-radius:4px;font-size:13px;color:#67c23a}.ragflow-ingestion-page__result-note a[data-v-9f315502]{color:#409eff;text-decoration:underline}.ragflow-ingestion-page__logs[data-v-9f315502]{margin-bottom:20px}.ragflow-ingestion-page__log-item[data-v-9f315502]{padding:4px 0;font-size:12px;border-bottom:1px solid #f5f5f5}.ragflow-ingestion-page__log-time[data-v-9f315502]{color:#c0c4cc;margin-right:8px}.ragflow-ingestion-page__log-item--info[data-v-9f315502]{color:#909399}.ragflow-ingestion-page__log-item--success[data-v-9f315502]{color:#67c23a}.ragflow-ingestion-page__log-item--error[data-v-9f315502]{color:#f56c6c}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.emp-option[data-v-dad2ae6f]{display:flex;align-items:center;gap:8px;width:100%}.emp-name[data-v-dad2ae6f]{font-weight:600;color:var(--text-primary)}.emp-id[data-v-dad2ae6f]{color:var(--text-muted);font-size:12px}.emp-dept[data-v-dad2ae6f]{color:var(--el-color-primary);font-size:12px;margin-left:auto}.form-hint[data-v-dad2ae6f]{font-size:12px;color:var(--text-muted);line-height:1.5;margin-top:4px}.page-header[data-v-dad2ae6f]{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px}.page-title[data-v-dad2ae6f]{font-size:18px;font-weight:600;color:var(--text-primary);margin-bottom:4px}.page-desc[data-v-dad2ae6f]{font-size:13px;color:var(--text-muted)}.loading-state[data-v-dad2ae6f]{padding:24px 0}.section-title[data-v-dad2ae6f]{font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:12px}.section-header[data-v-dad2ae6f]{display:flex;justify-content:space-between;align-items:center;margin:28px 0 12px}.role-cards[data-v-dad2ae6f]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:8px}.role-card[data-v-dad2ae6f]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;transition:border-color .2s}.role-card[data-v-dad2ae6f]:hover{border-color:var(--border-hover)}.role-card--default[data-v-dad2ae6f]{border-style:dashed}.role-card-header[data-v-dad2ae6f]{display:flex;align-items:center;gap:12px;margin-bottom:16px}.role-card-icon[data-v-dad2ae6f]{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.role-card-info[data-v-dad2ae6f]{flex:1;min-width:0}.role-card-name[data-v-dad2ae6f]{font-size:15px;font-weight:600;color:var(--text-primary)}.role-card-id[data-v-dad2ae6f]{font-size:12px;color:var(--text-muted)}.role-card-stats[data-v-dad2ae6f]{display:flex;gap:24px;margin-bottom:12px}.stat-item[data-v-dad2ae6f]{display:flex;flex-direction:column}.stat-value[data-v-dad2ae6f]{font-size:20px;font-weight:600;color:var(--text-primary)}.stat-label[data-v-dad2ae6f]{font-size:12px;color:var(--text-muted)}.role-card-desc[data-v-dad2ae6f]{font-size:12px;color:var(--text-secondary);margin-bottom:12px;line-height:1.5}.role-card-perms[data-v-dad2ae6f]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-dad2ae6f]{font-size:11px;font-family:monospace}.data-table-wrapper[data-v-dad2ae6f]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}.user-roles-table[data-v-dad2ae6f] .el-table__header th{background-color:var(--bg-tertiary)!important;color:var(--text-primary);font-weight:600}.user-roles-table[data-v-dad2ae6f] .el-table__body td{background-color:#fafafa;color:var(--text-primary)}.user-roles-table[data-v-dad2ae6f] .el-table__body tr:hover>td{background-color:#f0f7ff!important}.user-roles-table[data-v-dad2ae6f] .el-table__row--striped td{background-color:#f8fafc}.employee-name[data-v-dad2ae6f]{color:var(--text-primary);font-weight:500}.source-badge[data-v-dad2ae6f]{display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;background:var(--bg-tertiary);color:var(--text-secondary)}.source-manual[data-v-dad2ae6f]{background:#3b82f61a;color:#3b82f6}.source-auto[data-v-dad2ae6f]{background:#6b72801a;color:#6b7280}.source-tag[data-v-dad2ae6f]{background:#10b9811a;color:#10b981}.source-ehr[data-v-dad2ae6f]{background:#8b5cf61a;color:#8b5cf6}.text-muted[data-v-dad2ae6f]{color:var(--text-muted);font-size:12px}.revoke-info p[data-v-dad2ae6f]{margin:0 0 12px;font-size:14px;color:var(--text-primary)}.revoke-detail[data-v-dad2ae6f]{background:var(--bg-tertiary);border-radius:8px;padding:12px 16px;font-size:13px;line-height:1.8;color:var(--text-secondary)}.revoke-detail strong[data-v-dad2ae6f]{color:var(--text-primary)}.form-hint[data-v-dad2ae6f]{font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4}
|
||||
@@ -0,0 +1 @@
|
||||
.rule-version[data-v-bcc0e9e8]{padding:4px}.page-title[data-v-bcc0e9e8]{font-size:20px;font-weight:600;color:var(--text-primary)}.page-desc[data-v-bcc0e9e8]{color:var(--text-secondary);font-size:13px;margin:6px 0 16px}.toolbar[data-v-bcc0e9e8]{display:flex;gap:12px;align-items:center;margin-bottom:16px}.ver-table[data-v-bcc0e9e8]{margin-top:8px}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as L,o as M,b as _,c as k,e as w,f as t,w as o,j as x,n as h,q as j,r as d,i as n,V as A,W as F,g as c,t as S,E as C}from"./index-CXRNRwY6.js";import{u as I}from"./automation-CSa-HzII.js";import{_ as Q}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const W={class:"rule-version"},$={class:"toolbar"},G={key:0,class:"loading-state"},H=L({__name:"RuleVersion",setup(X){const s=I(),P=j(),r=d(""),p=d(!1),y=d(!1),f=d(10),m=d(""),v=d("");M(async()=>{var u;await s.loadScenarios();const a=P.query.scenario||"",e=((u=s.scenarios[0])==null?void 0:u.scenario_key)||"";r.value=a&&s.scenarios.some(b=>b.scenario_key===a)?a:e,r.value&&await s.loadVersions(r.value)});async function U(a){a&&await s.loadVersions(a)}function B(a){switch(a){case"active":case"full":return"success";case"canary":return"warning";case"rollback":return"danger";default:return"info"}}function T(){r.value&&(f.value=10,m.value="",v.value="",p.value=!0)}async function E(){if(!r.value)return;const a=s.scenarios.find(u=>u.scenario_key===r.value);if(!a)return;let e;if(m.value.trim())try{e=JSON.parse(m.value)}catch{C.error("规则内容 JSON 格式有误");return}y.value=!0;try{await s.publishVersion(a.id,{content:e,canary_percent:f.value,remark:v.value}),C.success("新版本已发布"),p.value=!1}catch{C.error("发布失败")}finally{y.value=!1}}function J(a){if(!a)return"-";try{return new Date(a).toLocaleString("zh-CN",{hour12:!1})}catch{return a}}return(a,e)=>{const u=n("el-option"),b=n("el-select"),V=n("el-button"),O=n("el-skeleton"),i=n("el-table-column"),R=n("el-tag"),q=n("el-table"),z=n("el-slider"),g=n("el-form-item"),N=n("el-input"),D=n("el-form"),K=n("el-dialog");return _(),k("div",W,[e[9]||(e[9]=w("div",{class:"page-title"},"规则版本管理 / 灰度",-1)),e[10]||(e[10]=w("div",{class:"page-desc"},"管理各场景自动化规则的版本与灰度放量,新版本先小流量验证再全量。",-1)),w("div",$,[t(b,{modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=l=>r.value=l),placeholder:"选择场景",filterable:"",style:{width:"260px"},onChange:U},{default:o(()=>[(_(!0),k(A,null,F(x(s).scenarios,l=>(_(),h(u,{key:l.scenario_key,label:l.name,value:l.scenario_key},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),t(V,{type:"primary",disabled:!r.value,onClick:T},{default:o(()=>[...e[6]||(e[6]=[c(" 发布新版本 ",-1)])]),_:1},8,["disabled"])]),x(s).loading?(_(),k("div",G,[t(O,{rows:6,animated:""})])):(_(),h(q,{key:1,data:x(s).versions,class:"ver-table",stripe:""},{default:o(()=>[t(i,{prop:"version",label:"版本",width:"90"}),t(i,{label:"状态",width:"110"},{default:o(({row:l})=>[t(R,{type:B(l.status),size:"small"},{default:o(()=>[c(S(l.status),1)]),_:2},1032,["type"])]),_:1}),t(i,{label:"灰度比例",width:"120"},{default:o(({row:l})=>[c(S(l.canary_percent)+"%",1)]),_:1}),t(i,{prop:"remark",label:"备注"}),t(i,{prop:"created_by",label:"发布人",width:"120"}),t(i,{label:"发布时间",width:"180"},{default:o(({row:l})=>[c(S(J(l.created_at)),1)]),_:1})]),_:1},8,["data"])),t(K,{modelValue:p.value,"onUpdate:modelValue":e[5]||(e[5]=l=>p.value=l),title:"发布新版本(灰度)",width:"520px","destroy-on-close":""},{footer:o(()=>[t(V,{onClick:e[4]||(e[4]=l=>p.value=!1)},{default:o(()=>[...e[7]||(e[7]=[c("取消",-1)])]),_:1}),t(V,{type:"primary",loading:y.value,onClick:E},{default:o(()=>[...e[8]||(e[8]=[c("发布",-1)])]),_:1},8,["loading"])]),default:o(()=>[t(D,{"label-position":"top"},{default:o(()=>[t(g,{label:"灰度放量比例:{{ canaryPercent }}%"},{default:o(()=>[t(z,{modelValue:f.value,"onUpdate:modelValue":e[1]||(e[1]=l=>f.value=l),min:0,max:100,step:5,"show-input":""},null,8,["modelValue"])]),_:1}),t(g,{label:"规则内容(JSON)"},{default:o(()=>[t(N,{modelValue:m.value,"onUpdate:modelValue":e[2]||(e[2]=l=>m.value=l),type:"textarea",rows:6,placeholder:"可选,留空沿用上一版本"},null,8,["modelValue"])]),_:1}),t(g,{label:"备注"},{default:o(()=>[t(N,{modelValue:v.value,"onUpdate:modelValue":e[3]||(e[3]=l=>v.value=l),placeholder:"如:修复 VPN 重置流程"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])])}}}),te=Q(H,[["__scopeId","data-v-bcc0e9e8"]]);export{te as default};
|
||||
@@ -0,0 +1 @@
|
||||
.runtime-logs[data-v-0c37fc4c]{padding:0}.filter-bar[data-v-0c37fc4c]{margin-bottom:16px;border:none;background:var(--bg-secondary)}.pagination-bar[data-v-0c37fc4c]{display:flex;justify-content:flex-end;margin-top:16px}.detail-block[data-v-0c37fc4c]{padding:8px 16px;background:var(--bg-secondary, #f5f7fa);border-radius:4px}.detail-meta[data-v-0c37fc4c]{display:flex;gap:24px;margin-bottom:8px;font-size:12px;color:#909399}.detail-message[data-v-0c37fc4c]{margin:0;white-space:pre-wrap;word-break:break-all;font-family:Menlo,Consolas,monospace;font-size:12px;line-height:1.5;max-height:280px;overflow:auto}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.automation-config[data-v-553156a6]{padding:4px}.page-title[data-v-553156a6]{font-size:20px;font-weight:600;color:var(--text-primary)}.page-desc[data-v-553156a6]{color:var(--text-secondary);font-size:13px;margin:6px 0 16px}.feature-grid[data-v-553156a6]{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}.scenario-card[data-v-553156a6]{display:flex;flex-direction:column}.sc-header[data-v-553156a6]{display:flex;align-items:flex-start;justify-content:space-between}.sc-name[data-v-553156a6]{font-weight:600;font-size:15px}.sc-key[data-v-553156a6]{font-size:12px;color:var(--text-muted);margin-top:2px}.sc-desc[data-v-553156a6]{font-size:13px;color:var(--text-secondary);margin:10px 0;min-height:36px}.sc-meta[data-v-553156a6]{margin-bottom:10px}.sc-actions[data-v-553156a6]{display:flex;gap:8px;justify-content:flex-end}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.session-audit[data-v-cf01563f]{padding:0}.filter-bar[data-v-cf01563f]{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:center}.pagination-bar[data-v-cf01563f]{display:flex;justify-content:flex-end;margin-top:16px}.clickable-row[data-v-cf01563f]{cursor:pointer}.detail-content[data-v-cf01563f]{padding:0 4px}.detail-section[data-v-cf01563f]{margin-bottom:24px}.detail-section h4[data-v-cf01563f]{margin:0 0 12px;font-size:15px;color:#303133;border-bottom:1px solid #ebeef5;padding-bottom:8px}.message-list[data-v-cf01563f]{display:flex;flex-direction:column;gap:12px;max-height:400px;overflow-y:auto}.message-item[data-v-cf01563f]{padding:10px 12px;border-radius:8px;background:#f5f7fa}.message-item.sender-employee[data-v-cf01563f]{background:#ecf5ff;border-left:3px solid #409eff}.message-item.sender-agent[data-v-cf01563f]{background:#f0f9eb;border-left:3px solid #67c23a}.message-item.sender-ai[data-v-cf01563f]{background:#fdf6ec;border-left:3px solid #e6a23c}.message-item.sender-system[data-v-cf01563f]{background:#f4f4f5;border-left:3px solid #909399}.message-header[data-v-cf01563f]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}.sender-name[data-v-cf01563f]{color:#606266;font-weight:500}.message-time[data-v-cf01563f]{color:#909399;margin-left:auto}.message-content[data-v-cf01563f]{font-size:14px;color:#303133;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
@@ -0,0 +1 @@
|
||||
.stat-card[data-v-4d635d1e]{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;transition:border-color .2s}.stat-card[data-v-4d635d1e]:hover{border-color:var(--border-hover)}.stat-card-header[data-v-4d635d1e]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.stat-label[data-v-4d635d1e]{font-size:12px;color:var(--text-secondary)}.stat-value[data-v-4d635d1e]{font-size:28px;font-weight:700;margin-bottom:4px}.stat-footer[data-v-4d635d1e]{font-size:11px}.trend-up[data-v-4d635d1e]{color:var(--success)}.trend-down[data-v-4d635d1e]{color:var(--danger)}.stat-subtitle[data-v-4d635d1e]{color:var(--text-muted)}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as y,b as t,c as a,e as r,t as s,n as c,w as f,a2 as i,h as o,ab as d,a5 as b,U as u,i as h,a3 as k,g as S}from"./index-CXRNRwY6.js";import{_ as x}from"./_plugin-vue_export-helper-DlAUqK2U.js";const D={class:"stat-card"},w={class:"stat-card-header"},B={class:"stat-label"},V={key:0},z={key:1,class:"stat-subtitle"},N=y({__name:"StatCard",props:{label:{},value:{},valueColor:{},icon:{},iconColor:{},trend:{},trendDirection:{},subtitle:{}},setup(e){const n=e,m=u(()=>n.value??"-"),v=u(()=>n.trendDirection==="up"?"trend-up":n.trendDirection==="down"?"trend-down":"");return(l,g)=>{const C=h("el-icon");return t(),a("div",D,[r("div",w,[r("span",B,s(e.label),1),e.icon?(t(),c(C,{key:0,size:20,style:i({color:e.iconColor||"var(--text-secondary)"})},{default:f(()=>[(t(),c(k(e.icon)))]),_:1},8,["style"])):o("",!0)]),r("div",{class:"stat-value",style:i({color:e.valueColor||"var(--text-primary)"})},[d(l.$slots,"value",{},()=>[S(s(m.value),1)],!0)],4),e.trend||e.subtitle?(t(),a("div",{key:0,class:b(["stat-footer",v.value])},[d(l.$slots,"footer",{},()=>[e.trend?(t(),a("span",V,s(e.trend),1)):o("",!0),e.subtitle?(t(),a("span",z,s(e.subtitle),1)):o("",!0)],!0)],2)):o("",!0)])}}}),E=x(N,[["__scopeId","data-v-4d635d1e"]]);export{E as S};
|
||||
@@ -0,0 +1 @@
|
||||
.system-logs[data-v-d15d7753]{padding:0}.filter-bar[data-v-d15d7753]{margin-bottom:16px;border:none;background:var(--bg-secondary)}.pagination-bar[data-v-d15d7753]{display:flex;justify-content:flex-end;margin-top:16px}.old-value[data-v-d15d7753]{color:#f56c6c;text-decoration:line-through}.new-value[data-v-d15d7753]{color:#67c23a;font-weight:500}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as L,o as M,b as v,c as U,f as o,w as l,X as Y,n as E,e as _,r as w,v as b,E as P,i as s,a4 as F,p as $,Y as S,g as u,t as g}from"./index-CXRNRwY6.js";import{P as j}from"./admin-WeuzXR_q.js";import{_ as I}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./index-BYeSIZFz.js";const X={class:"system-logs"},q={class:"old-value"},A={class:"new-value"},G={class:"pagination-bar"},H=L({__name:"SystemLogs",setup(J){const c=w(!1),f=w([]),a=b({configKey:"",changedBy:"",dateRange:null}),i=b({page:1,pageSize:50,total:0}),z=[{text:"最近24小时",value:()=>{const t=new Date,e=new Date;return e.setTime(e.getTime()-3600*1e3*24),[e,t]}},{text:"最近7天",value:()=>{const t=new Date,e=new Date;return e.setTime(e.getTime()-3600*1e3*24*7),[e,t]}}];function V(){const t={page:i.page,page_size:i.pageSize};return a.configKey&&(t.config_key=a.configKey),a.changedBy&&(t.changed_by=a.changedBy),a.dateRange&&a.dateRange.length===2&&(t.from=`${a.dateRange[0]}T00:00:00`,t.to=`${a.dateRange[1]}T23:59:59`),t}async function r(){c.value=!0;try{const t=await j(V());f.value=t.items||[],i.total=t.total||0}catch{P.error("加载配置变更历史失败")}finally{c.value=!1}}function m(){i.page=1,r()}function x(){a.configKey="",a.changedBy="",a.dateRange=null,i.page=1,r()}function B(){i.page=1,r()}function C(t){if(!t)return"—";const e=new Date(t);return isNaN(e.getTime())?t:e.toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}return M(()=>r()),(t,e)=>{const y=s("el-input"),p=s("el-form-item"),D=s("el-date-picker"),h=s("el-button"),k=s("el-form"),K=s("el-card"),d=s("el-table-column"),T=s("el-table"),R=s("el-pagination"),N=F("loading");return v(),U("div",X,[o(K,{class:"filter-bar",shadow:"never"},{default:l(()=>[o(k,{inline:!0,onSubmit:e[3]||(e[3]=$(()=>{},["prevent"]))},{default:l(()=>[o(p,{label:"配置键"},{default:l(()=>[o(y,{modelValue:a.configKey,"onUpdate:modelValue":e[0]||(e[0]=n=>a.configKey=n),placeholder:"如 emergency_mode",clearable:"",style:{width:"220px"},onKeyup:S(m,["enter"])},null,8,["modelValue"])]),_:1}),o(p,{label:"操作人"},{default:l(()=>[o(y,{modelValue:a.changedBy,"onUpdate:modelValue":e[1]||(e[1]=n=>a.changedBy=n),placeholder:"agent_id",clearable:"",style:{width:"200px"},onKeyup:S(m,["enter"])},null,8,["modelValue"])]),_:1}),o(p,{label:"时间范围"},{default:l(()=>[o(D,{modelValue:a.dateRange,"onUpdate:modelValue":e[2]||(e[2]=n=>a.dateRange=n),type:"daterange","range-separator":"至","start-placeholder":"开始日期","end-placeholder":"结束日期","value-format":"YYYY-MM-DD",shortcuts:z},null,8,["modelValue"])]),_:1}),o(p,null,{default:l(()=>[o(h,{type:"primary",onClick:m},{default:l(()=>[...e[6]||(e[6]=[u("查询",-1)])]),_:1}),o(h,{onClick:x},{default:l(()=>[...e[7]||(e[7]=[u("重置",-1)])]),_:1})]),_:1})]),_:1})]),_:1}),Y((v(),E(T,{data:f.value,stripe:"",style:{width:"100%"}},{default:l(()=>[o(d,{prop:"changed_at",label:"时间",width:"170"},{default:l(({row:n})=>[u(g(C(n.changed_at)),1)]),_:1}),o(d,{prop:"config_key",label:"配置键",width:"250","show-overflow-tooltip":""}),o(d,{prop:"changed_by_name",label:"操作人",width:"100"},{default:l(({row:n})=>[u(g(n.changed_by_name||"—"),1)]),_:1}),o(d,{prop:"old_value",label:"变更前","min-width":"200","show-overflow-tooltip":""},{default:l(({row:n})=>[_("span",q,g(n.old_value||"—"),1)]),_:1}),o(d,{prop:"new_value",label:"变更后","min-width":"200","show-overflow-tooltip":""},{default:l(({row:n})=>[_("span",A,g(n.new_value||"—"),1)]),_:1})]),_:1},8,["data"])),[[N,c.value]]),_("div",G,[o(R,{"current-page":i.page,"onUpdate:currentPage":e[4]||(e[4]=n=>i.page=n),"page-size":i.pageSize,"onUpdate:pageSize":e[5]||(e[5]=n=>i.pageSize=n),total:i.total,"page-sizes":[20,50,100],layout:"total, sizes, prev, pager, next, jumper",onCurrentChange:r,onSizeChange:B},null,8,["current-page","page-size","total"])])])}}}),ee=I(H,[["__scopeId","data-v-d15d7753"]]);export{ee as default};
|
||||
@@ -0,0 +1 @@
|
||||
.connection-warning[data-v-085081c4]{display:flex;align-items:center;gap:8px;padding:10px 16px;background:#eab3081a;border:1px solid rgba(234,179,8,.3);border-radius:var(--radius);margin-bottom:16px;color:var(--text-secondary);font-size:13px}.tab-group[data-v-085081c4]{display:flex;gap:8px}.search-bar[data-v-085081c4]{display:flex;gap:8px;align-items:center}.pagination-wrapper[data-v-085081c4]{display:flex;justify-content:flex-end;margin-top:16px}.terminal-name[data-v-085081c4]{color:var(--accent);font-weight:500;cursor:pointer}.terminal-name[data-v-085081c4]:hover{text-decoration:underline}.os-text[data-v-085081c4]{font-size:12px;color:var(--text-secondary)}.time-text[data-v-085081c4]{font-size:12px;color:var(--text-muted)}.text-danger[data-v-085081c4]{color:var(--danger);font-weight:600}.text-muted[data-v-085081c4]{color:var(--text-muted);font-size:12px}.detail-content[data-v-085081c4]{padding:0 4px}.detail-section[data-v-085081c4]{margin-bottom:20px}.detail-section-title[data-v-085081c4]{font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)}.detail-grid[data-v-085081c4]{display:grid;grid-template-columns:1fr 1fr;gap:10px}.detail-item[data-v-085081c4]{display:flex;flex-direction:column;gap:2px}.detail-label[data-v-085081c4]{font-size:11px;color:var(--text-muted)}.detail-value[data-v-085081c4]{font-size:13px;color:var(--text-primary)}.mono[data-v-085081c4]{font-family:SF Mono,Fira Code,monospace;font-size:12px}.security-status-grid[data-v-085081c4]{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}.security-item[data-v-085081c4]{text-align:center;padding:12px 8px;border-radius:var(--radius);border:1px solid var(--border)}.security-item.safe[data-v-085081c4]{background:#22c55e14;border-color:#22c55e33}.security-item.danger[data-v-085081c4]{background:#ef444414;border-color:#ef444433}.security-item.warning[data-v-085081c4]{background:#eab30814;border-color:#eab30833}.security-value[data-v-085081c4]{font-size:20px;font-weight:700;color:var(--text-primary)}.security-item.danger .security-value[data-v-085081c4]{color:var(--danger)}.security-item.warning .security-value[data-v-085081c4]{color:var(--warning)}.security-label[data-v-085081c4]{font-size:11px;color:var(--text-muted);margin-top:4px}.action-buttons[data-v-085081c4]{display:flex;gap:8px;flex-wrap:wrap}.action-hint[data-v-085081c4]{margin-top:8px;font-size:11px;color:var(--text-muted);font-style:italic}.terminal-table-row:hover td{background-color:var(--bg-tertiary)!important;cursor:pointer}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.graph-canvas[data-v-c818f9fe]{width:100%;height:100%;min-height:400px;position:relative;background:#fff;border-radius:8px;border:1px solid #e4e7ed}.graph-canvas__chart[data-v-c818f9fe]{width:100%;height:100%;min-height:400px}.graph-toolbar[data-v-2ed6d8aa]{display:flex;align-items:center;gap:4px;padding:12px 16px;background:#fff;border-radius:8px;border:1px solid #e4e7ed;flex-wrap:wrap}.graph-toolbar__group[data-v-2ed6d8aa]{display:flex;align-items:center;gap:8px}.graph-toolbar__label[data-v-2ed6d8aa]{font-size:13px;color:#909399;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;user-select:none}.graph-toolbar__dot[data-v-2ed6d8aa]{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:middle}.graph-toolbar[data-v-2ed6d8aa] .el-checkbox__label{display:inline-flex;align-items:center}.node-detail-panel[data-v-4fea2bd1]{width:100%;height:100%;background:#fff;border-radius:8px;border:1px solid #e4e7ed;display:flex;flex-direction:column;overflow:hidden}.node-detail-panel__empty[data-v-4fea2bd1]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#c0c4cc;gap:12px}.node-detail-panel__empty p[data-v-4fea2bd1]{font-size:13px;margin:0}.node-detail-panel__header[data-v-4fea2bd1]{padding:16px;border-bottom:1px solid #f0f0f0;display:flex;align-items:center;gap:10px}.node-detail-panel__type-badge[data-v-4fea2bd1]{font-size:11px;font-weight:600;padding:2px 8px;border-radius:4px;text-transform:uppercase;white-space:nowrap;flex-shrink:0}.node-detail-panel__name[data-v-4fea2bd1]{margin:0;font-size:16px;font-weight:600;color:#303133;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-detail-panel__scroll[data-v-4fea2bd1]{flex:1;overflow-y:auto}.node-detail-panel__section[data-v-4fea2bd1]{padding:12px 16px}.node-detail-panel__section+.node-detail-panel__section[data-v-4fea2bd1]{border-top:1px solid #f0f0f0}.node-detail-panel__section-title[data-v-4fea2bd1]{font-size:13px;font-weight:600;color:#606266;margin-bottom:10px;display:flex;align-items:center;gap:2px}.node-detail-panel__count[data-v-4fea2bd1]{font-size:12px;color:#909399;font-weight:400}.node-detail-panel__uuid[data-v-4fea2bd1]{font-family:Courier New,Courier,monospace;font-size:12px;color:#909399;word-break:break-all}.node-detail-panel__type-tag[data-v-4fea2bd1]{font-size:11px;font-weight:600;padding:2px 8px;border-radius:4px;text-transform:uppercase}.node-detail-panel__no-relations[data-v-4fea2bd1]{font-size:13px;color:#c0c4cc;padding:8px 0}.node-detail-panel__relation-list[data-v-4fea2bd1]{display:flex;flex-direction:column;gap:8px}.node-detail-panel__relation-item[data-v-4fea2bd1]{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#f5f7fa;border-radius:6px;flex-wrap:wrap}.node-detail-panel__relation-direction[data-v-4fea2bd1]{display:flex;align-items:center;gap:4px;font-size:13px;color:#606266;flex:1;min-width:0}.node-detail-panel__relation-node[data-v-4fea2bd1]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.node-detail-panel__relation-node--current[data-v-4fea2bd1]{font-weight:600;color:#303133}.node-detail-panel__relation-arrow[data-v-4fea2bd1]{color:#c0c4cc;flex-shrink:0}.node-detail-panel__relation-weight[data-v-4fea2bd1]{font-size:11px;color:#909399}.topology-preview-page[data-v-4fe1139a]{padding:20px;display:flex;flex-direction:column;height:100%;gap:12px}.topology-preview-page__header[data-v-4fe1139a]{display:flex;align-items:center;justify-content:space-between}.topology-preview-page__header h2[data-v-4fe1139a]{margin:0;font-size:20px;font-weight:600}.topology-preview-page__header-actions[data-v-4fe1139a]{display:flex;gap:8px;align-items:center}.topology-preview-page__stats-bar[data-v-4fe1139a]{display:flex;gap:12px}.topology-preview-page__stat-item[data-v-4fe1139a]{display:flex;align-items:center;gap:6px;padding:10px 20px;background:#f5f7fa;border-radius:8px;min-width:100px}.topology-preview-page__stat-dot[data-v-4fe1139a]{display:inline-block;width:8px;height:8px;border-radius:50%}.topology-preview-page__stat-value[data-v-4fe1139a]{font-size:24px;font-weight:700;color:#303133}.topology-preview-page__stat-label[data-v-4fe1139a]{font-size:12px;color:#909399}.topology-preview-page__main[data-v-4fe1139a]{display:flex;gap:12px;flex:1;min-height:0}.topology-preview-page__canvas-wrapper[data-v-4fe1139a]{flex:1;min-width:0;position:relative;min-height:400px}.topology-preview-page__detail-wrapper[data-v-4fe1139a]{width:360px;flex-shrink:0;min-height:400px}.topology-preview-page__empty[data-v-4fe1139a]{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:#fff;border-radius:8px;border:1px solid #e4e7ed}.topology-preview-page__empty-title[data-v-4fe1139a]{font-size:16px;font-weight:600;color:#606266;margin:8px 0 0}.topology-preview-page__empty-desc[data-v-4fe1139a]{font-size:13px;color:#909399;margin:0 0 12px}
|
||||
@@ -0,0 +1 @@
|
||||
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};
|
||||
@@ -0,0 +1 @@
|
||||
import{a0 as y,r as c,U as f,a1 as p}from"./index-CXRNRwY6.js";import{a as _}from"./index-BYeSIZFz.js";const u="admin_token",m="admin_user_id",A=y("admin",()=>{const a=c(null),n=c(localStorage.getItem(u)),s=c(localStorage.getItem(m)),l=c(!1),I=f(()=>!!n.value&&!!a.value),h=f(()=>{var e;return((e=a.value)==null?void 0:e.name)||""}),E=f(()=>{var e;return((e=a.value)==null?void 0:e.user_id)||s.value||""});async function S(e,o,d){l.value=!0;try{const t=await _.post("/agents/login",{user_id:e,name:e,password:o,otp_code:d||void 0}),i=t.agent_info||t;if("require_otp"in t&&t.require_otp)throw l.value=!1,new Error("require_otp");if(i.role!=="admin")throw new Error("无管理权限:该账号非管理员角色");n.value=t.token||i.token,s.value=e,localStorage.setItem(u,t.token||i.token),localStorage.setItem(m,e),a.value={...i,role:"admin"},p.push("/dashboard")}catch(r){if(console.error("管理员登录失败:",r),r instanceof Error&&r.message==="require_otp")throw r;const t=r instanceof Error?r.message:"登录失败,请重试";throw new Error(t)}finally{l.value=!1}}function g(){n.value=null,s.value=null,a.value=null,localStorage.removeItem(u),localStorage.removeItem(m),p.push("/login")}async function v(){var e;try{if(!n.value)return;const o=await _.get("/agents/me");a.value=o}catch(o){console.error("获取管理员信息失败:",o),o&&typeof o=="object"&&"response"in o&&((e=o.response)==null?void 0:e.status)===401&&g()}}async function w(){const e=localStorage.getItem(u);if(e){n.value=e,s.value=localStorage.getItem(m);try{await v()}catch{g()}}}return{adminInfo:a,token:n,adminUserId:s,logging:l,isLoggedIn:I,adminName:h,userId:E,login:S,logout:g,refreshAdminInfo:v,initAuth:w}});export{A as u};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{a as n}from"./index-BYeSIZFz.js";import"./index-CXRNRwY6.js";async function e(){return n.get("/auth/qrcode")}async function o(t){return n.get("/auth/scan/status",{params:{ticket:t}})}async function c(t,a){return n.get("/auth/oauth2/callback",{params:{code:t,state:a}})}async function s(t){return n.post("/auth/bind",t)}async function i(t){return n.post("/auth/verify",{token:t})}async function h(){return n.post("/auth/logout")}async function f(){return n.get("/auth/me")}async function g(t){return n.get("/auth/oauth2/url",{params:{redirect_uri:t}})}export{s as bindAccount,f as getCurrentUser,g as getOAuthAuthorizeUrl,e as getQrcode,o as getScanStatus,c as handleOAuthCallback,h as logout,i as verifyToken};
|
||||
@@ -0,0 +1 @@
|
||||
import{a0 as w,r as e}from"./index-CXRNRwY6.js";import{a as s}from"./index-BYeSIZFz.js";function C(){return s.get("/itportal/automation/admin/scenarios")}function S(n,o){return s.put(`/itportal/automation/admin/scenarios/${n}`,o)}function A(n,o){return s.post(`/itportal/automation/admin/scenarios/${n}/version`,o)}function V(n){return s.get("/itportal/automation/admin/rule-versions",{params:{scenario_key:n}})}function h(){return s.get("/itportal/automation/admin/metrics")}const M=w("automation",()=>{const n=e([]),o=e([]),u=e(null),r=e(!1),c=e("");async function f(){r.value=!0;try{const t=await C();n.value=t}finally{r.value=!1}}async function m(t,a){const i=await S(t,a),l=n.value.findIndex(y=>y.id===t);return l>=0&&(n.value[l]=i),i}async function d(t){c.value=t;const a=await V(t);o.value=a}async function p(t,a){const i=await A(t,a);return o.value.unshift(i),i}async function v(){const t=await h();u.value=t}return{scenarios:n,versions:o,metrics:u,loading:r,currentScenarioKey:c,loadScenarios:f,updateScenario:m,loadVersions:d,publishVersion:p,loadMetrics:v}});export{M as u};
|
||||
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
@@ -0,0 +1 @@
|
||||
const a=["电脑","软件","外设","网络","安全","资产","其他"],o=["全部","电脑","软件","外设","网络","安全","资产","其他"],s={auto:"系统自动",tag:"企微标签",ehr:"eHR 岗位",manual:"手动分配"},t={wecom_tag:"企微标签",ehr_position:"eHR 岗位"};export{t as M,o as Q,s as R,a as S};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user