diff --git a/KNOWLEDGE.md b/KNOWLEDGE.md new file mode 100644 index 0000000..bacaa5f --- /dev/null +++ b/KNOWLEDGE.md @@ -0,0 +1,1074 @@ +# 企微智能 IT 支持服务台 — 项目全量知识库 + +> **项目代号**:`wecom_it_smart_desk` +> **状态**:v0.5.0-beta 内测(2026-06-15),v0.4.x 仍为生产稳定版 +> **目标用户**:公司 6000 员工(企微 IM) +> **核心痛点**:员工绕过 AI 直接找人工 / AI 转人工需另开窗口 / 无法跨主体共享 +> **核心方案**:自研 IT 服务坐席系统,基于企微自建应用消息 API +> **演进路径**:M1 消息接管 → M2 AI 接入 → M3 知识库闭环 +> **本文档用途**:Claude 全面接管项目后的"项目大脑" — 完整功能/架构/集成/部署/安全/历史决策清单 +> **最后更新**:2026-06-15(项目从 WB 移交到 Claude 接管) + +--- + +## 0. 项目移交状态(2026-06-15) + +| 项目 | 状态 | +|---|---| +| 项目控制方 | **Claude 全面接管**(原由 Workbuddy CN 推进,2026-06-15 起停用) | +| WB 项目目录 | `D:\资料\03-项目开发\wecom_it_smart_desk`(待用户授权后归档封存) | +| Claude 工作目录 | `D:\资料\03-项目开发\wecom_it_smart_desk-claude`(唯一 active 副本) | +| 代码托管 | Gitea(自托管 NAS 8418 端口),推送前需重新获 token | +| 移交决策原因 | WB 1009 上下文冲突 + token 误吊销 + 大量功能信息丢失 | +| 关联 memory | `project-handover-to-claude` / `gitea-push-permission-revoked-2026-06-15` | + +--- + +## 1. 业务背景 + +### 1.1 公司规模与用户 +- 6000 员工,全国分子机构 +- 内部 IM:企业微信(企微) +- 现有痛点: + - 员工绕过 AI 直接找人工,AI 筛选率极低 + - AI 转人工需另开窗口,体验割裂 + - 企微"员工服务"不支持跨企业应用共享 + +### 1.2 核心设计理念 +- **并行协作**(非传统串行排队):AI 全程在线 + 人工随时介入 + 员工同一窗口无缝切换 +- **统一入口**(v0.5):企微工作台 → 唯一 OAuth2 认证点 → 角色检测 → 路由到用户端/坐席端/管理端 +- **AI Wingman**(v0.6+):AI 不仅是服务员工的,更是解放坐席的 + +### 1.3 三步演进路径 + +| 里程碑 | 周期 | 核心交付 | 状态 | +|---|---|---|---| +| **M1** 消息接管 + 极简坐席 | 6-8 周 | 企微 API 链路 · 三栏工作台 · H5 双栏 · 邀请功能 | ✅ 代码完成,部署中 | +| **M2** AI 机器人接入 | M1 后 4-6 周 | 千问/Dify/RAGFlow · AI 前置筛选 · 排队系统 | 📋 计划中 | +| **M3** 知识库闭环 | M2 后 4-6 周 | 坐席标注系统 · 千问自动分析 · 知识库自优化 | 📋 计划中 | + +--- + +## 2. 系统架构 + +### 2.1 部署架构(Docker Compose 单机版) + +``` +浏览器 ──→ https://itsupport.servyou.com.cn:443 + │ + ▼ + ┌─── nginx (Docker 容器) ────────────────┐ + │ /itdesk/ → H5 员工端 SPA │ + │ /itagent/ → 坐席工作台 SPA │ + │ /itadmin/ → 管理后台 SPA(内网/VPN) │ + │ /itportal/ → 统一入口 SPA │ + │ /api/* → backend:8000 (FastAPI) │ + │ /ws/* → backend:8000 (WebSocket) │ + └──────────────┬──────────────────────────┘ + │ Docker 网络 + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ backend │ │ postgres │ │ redis │ + │ :8000 │ │ :5432 │ │ :6379 │ + └──────────┘ └──────────┘ └──────────┘ +``` + +| 对比项 | 预生产(当前) | 正式环境(未来) | +|---|---|---| +| 部署方式 | Docker Compose 单主机 | K8s 集群高可用 | +| 域名 | `itsupport.servyou.com.cn` | 公司高可用架构 | +| 服务器 | **10.90.5.110**(2026-06-15 起替代 10.80.0.136) | 待迁移 | + +### 2.2 技术栈 + +| 层级 | 技术选型 | 说明 | +|---|---|---| +| 反向代理 | **Nginx**(Docker 容器) | HTTPS 终止 + 路径路由 + WS 代理 + 静态文件 | +| 后端框架 | **FastAPI**(Python 3.12) | 异步 + 自动 OpenAPI + 类型安全 | +| 数据库 | **PostgreSQL 16** | 16 张表(2026-06 最新盘点) | +| 缓存 | **Redis 7** | access_token(TTL 7200s) + JWT 会话 + 模板缓存 | +| ORM | **SQLAlchemy 2.0(async)** | 声明式模型 | +| 数据库迁移 | **Alembic** | 10 个迁移版本(002-009 + initial) | +| 加解密 | **cryptography** | AES-CBC-256 企微消息加解密 | +| 坐席前端 | **Vue3 + ElementPlus + Pinia** | 三栏工作台(会话/对话/AI 助手) | +| 员工 H5 | **Vue3 + Vant4 + Pinia** | 双栏 + 摇人按钮 + 呼叫坐席 | +| 统一入口 | **Vue3**(独立 frontend-portal) | 角色路由选择页 | +| 管理后台 | **Vue3 + ElementPlus** | 13+ 视图(仪表盘/坐席/集成等) | +| 容器化 | Docker + Docker Compose | 4-5 容器一键启停 | + +### 2.3 URL 路径规划(已实现) + +| 端 | 路径 | 说明 | +|---|---|---| +| 统一入口 | `/itportal/` | 角色路由选择页(v0.5 新增) | +| 用户端 | `/itdesk/` | 员工 H5(原员工端) | +| 坐席端 | `/itagent/` | IT 坐席工作台 | +| 管理端 | `/itadmin/` | 系统配置 + 数据分析(内网白名单) | +| API | `/api/` | 后端接口(企微回调/会话/消息/坐席/...) | +| WebSocket | `/ws/{agent_id}` | 坐席实时推送 | +| 统一入口跳转 | `/` | 302 → `/itportal/` | + +--- + +## 3. 数据库设计(16 张表) + +### 3.1 核心业务表(9 张) + +| 表名 | 用途 | 关键字段 | +|---|---|---| +| `conversations` | 会话主表 | employee_id, status(queued/serving/resolved), urgency_score(1-5), tags(JSON), is_vip, participants(JSON) | +| `messages` | 消息记录 | sender_type(employee/agent/ai/system), content, msg_type(text/image/file/voice) | +| `agents` | 坐席信息 | user_id, status(online/offline/busy), current_load, max_load, **password_hash**(v0.5 加) | +| `quick_reply_templates` | 快速回复模板 | category, title, content({变量}), variables(JSON) | +| `system_configs` | 系统配置 | config_key, config_value(关键词/阈值/话术) | +| `funny_phrases` | 趣味话术 | scene(6 种), content, tone, is_active | +| `approval_links` | 审批流程链接 | category(IT/HR/行政/财务), title, url | +| `software_downloads` | 软件下载入口 | category, name, version, platform, download_url | +| `agent_notes` | 坐席备注 | conversation_id, agent_id, content | + +### 3.2 阶段一新增表(7 张) + +| 表名 | 用途 | 关键字段 | +|---|---|---| +| `employees` | 员工档案 | user_id, name, department, mobile, email, is_vip | +| `todos` | 待办事项 | agent_id, conversation_id, content, due_at, status | +| `troubleshooting_templates` | 排障模板 | category, title, steps(JSON) | +| `roles` | RBAC 角色 | name(user/agent/admin), display_name, permissions(JSON) | +| `user_roles` | 用户角色关联 | employee_id, role_id, source(auto/tag/ehr/manual) | +| `role_mapping_rules` | 角色映射规则 | role_id, source_type(wecom_tag/ehr_position/manual), source_value, priority | +| `config_change_logs` | 配置变更审计 | config_key, old_value, new_value, changed_by, changed_at | + +> **alembic 迁移链**:6d5520491644(initial) → 002 → 003 → 004 → 005 → 006 → 007 → 008 → 009 +> **v0.5 修过的 007 链路断裂** = `007_role_system.py` 中 docstring + `revision` 都写成 `007_role_sys`,v0.5.1 需统一 filename 与 revision(任务 #41) + +--- + +## 4. 业务功能清单(已实现,分阶段) + +### 4.1 阶段 0(2025-12, v0.1) — 基础框架 +- FastAPI + SQLAlchemy 2.0 + Alembic +- 4 前端工程(坐席/H5/Portal/Admin) +- 企微回调基础 +- Docker Compose 编排 + +### 4.2 阶段 0.2(2026-01, v0.2) — 核心业务 +- 16 张数据表(初始 9 张) +- 40+ API 端点 +- OAuth2 企微登录 +- 消息收发(文本/图片/文件/语音) +- 会话分配 / 抢单 / 转接 +- 协作坐席(摇人按钮) +- 邀请功能(P0-09~11) + +### 4.3 阶段 0.3(2026-03, v0.3) — AI 辅助与标记 +- AI 草稿回复(坐席采纳模式) +- AI 实质性回复计数 +- 紧急度评分(1-5) +- 标签系统(举手/情绪/需介入) +- 影响范围评估 +- 阻断性标记 + +### 4.4 阶段 0.4(2026-04, v0.4) — RBAC + 配置 +- RBAC 角色管理(user/agent/admin) +- 角色自动映射(企微标签 + eHR 字段) +- 配置变更日志(审计) +- 趣味话术(摇人/等待/接入 6 场景) +- 审批流程链接 +- 软件下载入口 +- 紧急度算法优化 + VIP 自动匹配 +- 部门权限粒度 + +### 4.5 阶段 0.5(2026-05~06, v0.5.0-beta) — 阶段一收尾 +**4 前端 + 47 项功能盘点 66% 完成** + +- **H5 员工端**(11 组件): + - 双栏布局(对话/助手 + 摇人按钮) + - 摇人(转人工) + - 呼叫坐席 + - 会话进度查看 + - 审批链接 / 软件下载入口 + - 表情/截图/文件/图片 4 类消息 + - OAuth2 + 降级登录(密码) +- **坐席工作台**(23 组件,三栏): + - 会话列表(紧急度排序 + VIP/举手/需介入/情绪 标记) + - 对话区(WebSocket 实时推送 + 30s 心跳 + 指数退避重连 + 3s 轮询降级) + - AI 助手侧栏(草稿回复/摘要/标签/知识推荐) + - 接单/抢单/转接 + - 多人协作(摇人 + 邀请功能) + - 待办事项 + - 排障模板 + - 快速回复(模板缓存到 Redis) + - 坐席备注 +- **管理后台**(13+ 视图): + - 仪表盘(会话统计/坐席负载/紧急度分布) + - 坐席管理(CRUD + 状态监控) + - 角色管理 + 角色映射规则 + - 系统配置(关键词/阈值/话术 CRUD) + - 配置变更审计 + - 快速回复模板管理 + - 排障模板管理 + - 趣味话术管理 + - 审批链接 / 软件下载管理 + - 终端安全(联软/火绒 集成视图) + - 集成配置(4 外部系统) +- **统一入口 Portal**: + - 唯一 OAuth2 认证点 + - 角色检测 + 卡片选择页 + - 角色切换 API + - 消除公网直访登录页(安全) +- **管理端 API**: + - 仅内网/VPN 访问(IP 白名单 + 路径 `/api/admin/*`) +- **4 个外部系统集成**: + - 火绒企业版(17 API 端点,认证成功) + - 联软 LV7000(68 API 端口,员工映射核心) + - aTrust 零信任(官方文档修正版) + - 北森 eHR(待对接,OAuth2) +- **ExternalSystemAdapter 抽象层**(统一接口规范): + - MockAdapter(开发期) → 真实 API 无缝切换 + - 缓存透明(5 种 TTL)+ 降级安全 + - 上层业务只依赖 ExternalSystemService 统一门面 + +### 4.6 阶段二规划(v0.6+,启动中) +- H5 全流程(邀请功能已闭环) +- WebSocket 推送(H5 WS 端点已上线) +- OAuth2 认证(已上线) +- 排队机制(未开始,P1) +- 满意度评价(未开始,P1) + +### 4.7 阶段三规划 +- AI Wingman(三层渐进式:效率 → 认知 → 情感) + - 效率层:AI 草稿回复 + 自动摘要 + 自动标签(Phase 1) + - 认知层:知识推荐 + SOP 导航 + 相似工单(Phase 2) + - 情感层:情绪识别 + 安抚话术 + 语气润色(Phase 3) +- 排查流程图 +- 标注体系 + +### 4.8 阶段四规划 +- 知识库迭代闭环 +- 数据看板 +- 知识库管理 + +### 4.9 阶段五规划 +- 自动/辅助审核 +- 自动开单/结单 + +--- + +## 5. API 接口分组(20 个 router,7890 行) + +| 路径前缀 | 文件 | 端点数 | 说明 | +|---|---|---|---| +| `/api/wecom/callback` | wecom_callback.py | 2 | GET 验证 URL + POST 接收消息 | +| `/api/conversations` | conversations.py | ~15 | 列表/详情/状态/置顶/接单/邀请/退出/移除参与者 | +| `/api/conversations/{id}/messages` | messages.py | ~10 | 消息列表/发送/撤回 | +| `/api/agents` | agents.py | ~12 | 列表/登录(企微+降级+OAuth)/状态切换/密码/OTP | +| `/api/h5/*` | h5.py | ~25 | H5 完整功能(会话/摇人/审批/软件/OAuth) | +| `/api/portal/*` | portal.py | ~5 | 统一入口(角色列表/切换/入口 URL) | +| `/api/admin/*` | admin.py + admin_roles.py | ~25 | 管理后台(仪表盘/坐席/角色/配置) | +| `/api/quick-replies` | quick_replies.py | ~5 | CRUD | +| `/api/todos` | todo_items.py | ~8 | 待办事项 | +| `/api/troubleshooting-templates` | troubleshooting_templates.py | ~6 | 排障模板 | +| `/api/upload` | upload.py | ~4 | 文件/图片/表情上传 | +| `/api/employees` | employees.py | ~5 | 员工档案 | +| `/api/notes` | agent_notes.py | ~4 | 坐席备注 | +| `/api/approvals` | approval.py | ~4 | 审批链接 | +| `/api/wingman` | wingman.py | ~5 | AI Wingman 草稿/摘要 | +| `/api/system` | system.py | ~3 | 健康检查 + 系统信息 | +| `/api/ws/{agent_id}` | ws.py | 1 | WebSocket 实时推送 | +| router.py | — | — | 路由汇总 | + +**统一响应格式**:`{ "code": 0, "data": {}, "message": "success" }` +**错误码体系**:0=成功,1000+=通用(10/11/12/13/14/15/16),2000+=企微 API 错误,3000+=业务逻辑错误 + +### 5.1 v0.5 安全相关错误码(新增) +- E1010 = OAuth2 token 过期 +- E1011 = 未授权 +- E1012 = **已废弃拆码**(原"首次登录请设置密码"和"改密请输旧密码"语义冲突) + - **E1015** = AUTH_OLD_PASSWORD_REQUIRED(改密时未输旧密码) + - **E1016** = AUTH_OLD_PASSWORD_WRONG(改密时旧密码错) + - E1012 仍保留给"首次登录请设置密码"单一场景 +- E1013 = 密码格式不符 +- E1014 = 旧密码新密码相同 + +--- + +## 6. 后端服务层(15 个 service,7117 行) + +| 服务 | 行数 | 职责 | +|---|---|---| +| `wecom_service.py` | 573 | 企微 API 封装(access_token 缓存 + 发消息 + 通讯录 + 上传素材 + OAuth2) | +| `ai_service.py` | 271 | Dify API 封装(流式 + 非流式) | +| `ai_handler.py` | 289 | AI 路由逻辑(打招呼检测 + 呼叫人工拦截) — **v0.5 替换原 ai_service 直接调用** | +| `message_router.py` | 671 | 消息路由大脑(接收 → 路由 → VIP → 标记 → 评分 → 入库 → AI/坐席) | +| `scoring_service.py` | 406 | 紧急度评分(基础分 + 情绪 + VIP + 重复追问 = 1-5) + 标记检测(举手/需介入/情绪) | +| `session_service.py` | 1234 | 会话全生命周期(创建/分配/接单/转接/邀请/退出/移除) | +| `ws_manager.py` | 327 | WebSocket 连接管理(坐席 + H5 注册/推送/广播/清理) | +| `role_mapping_service.py` | 350 | 角色自动映射(企微标签 + eHR 岗位 + 手动) | +| `wingman_service.py` | 445 | 坐席端 AI 助手(草稿/摘要/标签/知识推荐) | +| `token_service.py` | 263 | JWT + Redis 会话管理 | +| `cache_service.py` | 232 | Redis 缓存装饰器 + 策略 | +| `funny_phrase_service.py` | 157 | 6 场景趣味话术(摇人/等待/接入 等) | +| `admin_service.py` | 1728 | 管理后台综合服务 | +| `security_comparison.py` | 149 | 前后端安全对照 | +| `external/`(空目录) | — | 原计划,实际放到 `integrations/` 下 | + +--- + +## 7. 4 个外部系统集成(2,850 行) + +### 7.1 通用抽象层(`ExternalSystemAdapter`) + +```python +class ExternalSystemAdapter(ABC): + system_name: str + is_available: bool + + async def health_check() -> bool + async def get_terminal_by_user(username) -> Optional[TerminalInfo] # 联软主源 + async def get_terminal_by_computer(name) -> Optional[TerminalInfo] + async def get_terminal_detail(terminal_id) -> Optional[TerminalInfo] + async def get_security_status(terminal_id) -> Optional[SecurityStatus] # 火绒 + async def isolate_terminal(terminal_id, reason) -> bool # 仅火绒 + async def unisolate_terminal(terminal_id) -> bool + async def get_vpn_sessions(username) -> List[VpnSession] # 仅 aTrust + async def get_online_status(username) -> bool +``` + +**统一 DTO**: +- `TerminalInfo`(source_system / computer_name / ip_addresses / mac_addresses / os_version / is_online / logged_in_user / department / hardware_summary / last_seen) +- `SecurityStatus`(source_system / virus_events / vulnerabilities / is_isolated) +- `VpnSession`(username / display_name / remote_ip / vpn_ip / is_trusted) + +**统一门面服务** `ExternalSystemService.find_user_terminal(username)`: +- 优先级:联软(strusername 精确) → aTrust(bindUserList) → eHR(无,返回 None) +- 火绒隔离操作:重试 1 次 → 失败则记录待执行队列 → 告警坐席 + +**缓存策略**: +| 数据 | TTL | 刷新 | +|---|---|---| +| 终端映射 | 30 分钟 | 定时 + 访问检查 | +| 终端详情 | 60 分钟 | 懒加载 | +| 安全状态 | 5 分钟 | 短 TTL + 事件驱动 | +| VPN 在线 | 1 分钟 | 短 TTL | +| eHR 员工 | 24 小时 | 每日凌晨全量 | + +**降级策略**: +- 单系统不可用:跳过该系统尝试下一优先级 +- 全部不可用:返回缓存(标注"可能过时") +- 缓存+系统全挂:返回空 + 告警坐席 +- 火绒隔离失败:重试 + 待执行队列 + 告警 + +### 7.2 火绒企业版集成(`integrations/huorong/`,1184 行) + +| 维度 | 内容 | +|---|---| +| 角色 | **安全源(P0)** — 终端列表/漏洞/病毒/隔离 | +| 认证 | HMAC-SHA1 AccessKey | +| 凭证状态 | **现在可拿** | +| API 端点 | 17 个 | +| 核心方法 | `_list`(终端列表) + `_isolation`(隔离) + `_leak`(漏洞) + `_virus`(病毒) | +| 生产 URL | `http://huorong.oa.servyou-it.com:8080` | +| 关键功能 | 一键隔离/解除 + 漏洞事件 + 病毒事件 | +| 文档 | `docs/火绒终端安全系统集成分析.md`(560 行) | + +### 7.3 联软 LV7000 集成(`integrations/lianruan/`,956 行) + +| 维度 | 内容 | +|---|---| +| 角色 | **主映射源(P0)** — 员工 → 终端精确匹配 | +| 认证 | IP 白名单 + 账号密码 + Token | +| 凭证状态 | 明天可拿(2026-06-11 评估) | +| API 端口 | 68 个 | +| **核心字段** | `strusername`(员工账号,新系统通过此字段关联) | +| 核心方法 | `queryDevByParams(strusername=xxx)` | +| 核心价值 | 员工映射 + 硬件详情 + 在线状态 + VPN IP 关联 | +| 文档 | `docs/联软终端安全系统集成分析.md`(797 行) | + +### 7.4 aTrust 零信任集成(`integrations/atrust/`,无) + +| 维度 | 内容 | +|---|---| +| 角色 | **VPN 源(P1)** | +| 认证 | HMAC-SHA256 签名 | +| 凭证状态 | **约一周可拿** | +| 核心方法 | `queryAll(bindUserList)` 终端绑定用户 | +| 核心价值 | VPN 在线用户 + VPN IP + 踢出用户 | +| 待对接 | 信息安全团队 | +| 文档 | `docs/aTrust零信任系统集成分析.md`(879 行) | + +### 7.5 北森 eHR 集成(规划中) + +| 维度 | 内容 | +|---|---| +| 角色 | **辅助静态数据(P2)** | +| 认证 | OAuth2.0 | +| 凭证状态 | 待对接 HR 数字化团队 | +| 用途 | 员工基础信息 + 任职信息(辅助角色映射 ehr_position 字段) | + +### 7.6 RAGFlow 知识库集成(`integrations/ragflow/`,690 行) + +| 维度 | 内容 | +|---|---| +| 角色 | **AI 知识库(P1)** | +| 用途 | M3 知识库迭代 + M2 语义检索 | +| 凭证 | 现有 10.80.0.85:8080 | +| 文档 | `integrations/ragflow/`, API 模型在 `models.py` | + +--- + +## 8. 消息收发全链路 + +``` +员工发消息(企微) → 企微回调解密 → 消息路由 → 评分标记 → 入库 + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + 坐席 WS 推送 AI 处理(v0.5 路由层已就位) + │ │ + ▼ ▼ + 坐席回复 AI 回复 + │ │ + └──────────────┬────────────────┘ + ▼ + 企微主动推送(message/send) + │ + ▼ + 员工同一窗口收到(无跳转) +``` + +### 8.1 9 步详细链路 +1. **接收**:员工在企微应用内发消息 +2. **回调**:企微 POST 加密 XML 到 `/api/wecom/callback` +3. **解密**:`wecom_crypto.decrypt_message()`(AES-CBC-256) +4. **路由**:`MessageRouter.route_message()` — 创建会话 → VIP 检测 → 标记检测 → 评分 → 入库 +5. **评分**:`ScoringService` — 5 步(基础分 + 情绪 + VIP + 重复追问 + clamp 1-5) +6. **入库**:`conversations` + `messages` 原子写入 +7. **坐席 WS 推送**:`ws_manager.broadcast()`(坐席新消息) +8. **坐席回复**:POST → 后端调企微 `message/send` +9. **员工收到**:企微推送到员工(同一窗口) + +### 8.2 紧急度评分公式 +``` +紧急度 = 基础分(关键词) + 情绪加成 + VIP加成 + 重复追问加成 +范围:1-5(clamp) +映射:1=低, 2=中, 3=高, 4=紧急, 5=最高 +所有关键词和阈值存 system_configs,支持动态修改无需重启 +``` + +### 8.3 会话排序规则 +``` +紧急 → 举手 → 需介入 → 活跃 → AI处理中 → 已结单 +(同级按 last_message_at 倒序) +``` + +### 8.4 标记系统 +| 标记 | 图标 | 触发条件 | +|---|---|---| +| VIP | 红色 | 企微通讯录规则匹配 | +| 举手 | 黄色 | 员工说关键词或点击摇人按钮 | +| 需介入 | 橙红 | 同一问题追问 > 3 轮 | +| 情绪 | 红色 | 关键词匹配(急/崩溃/投诉等) | + +--- + +## 9. 现有系统复用资源(7 + 8 + 10 项) + +### 9.1 🔴 核心复用(P0,直接影响架构) +| # | 资源 | 现有位置 | 新系统对应 | +|---|---|---|---| +| 1 | Dify Workflow | `yw-dify.dc.servyou-it.com/apps` | 坐席助手 AI 面板 + 自动回复(M2) | +| 2 | dify2openai 桥接 | `yw-dify.dc/dify2openai/v1/chat/completions` | 后端调 AI 入口 | +| 3 | RAGFlow 知识库 | `10.80.0.85:8080` | 知识库管理 + 标注闭环(M3) | +| 4 | Qwen3-30B 大模型 | `10.80.0.49:5000/api/llm/servyou/v1` | AI 对话底层 | +| 5 | bge-m3 向量模型 | RAGFlow 内置 | 知识库检索向量化 | +| 6 | Dify DB(只读) | `10.80.128.40:5432` DB=dify User=difyro | 历史数据迁移 | +| 7 | 企微自建应用 | 已创建(CorpID/AgentID/Secret) | 消息收发的企微入口 | + +### 9.2 🟡 业务逻辑复用(需适配) +- 现有 system_users → 新 Agent(改 password 明文 → bcrypt) +- 现有会话定义(15 分钟无交互=一个会话)→ 新状态机(queued/serving/resolved) +- 现有自助解决判定 → 新统计口径 +- 现有知识库命中判定 → 加 AI 置信度 +- 现有 ManualIntervention → 新 tags 体系 +- 现有 ManualEntry → 直接复用 +- 现有 MonthlyReportQueryView → 新运营报表 +- 现有登录认证(Session+Redis+12h) → 新 JWT+Redis + +### 9.3 🟢 基础设施复用(直接复用或微调) +- 服务器 `10.80.0.86`(原 Django+PG+Redis,新 FastAPI 同机部署) +- 域名 `it-dataquery.dc.servyou-it.com`(新系统用子域名) +- SSL 证书(`ssl/` 目录) +- Nginx(`nginx/nginx.conf`,改反代配置) +- Docker Compose 部署模式 +- Redis(可复用,db=0 旧 / db=1 新) +- SearXNG 搜索 `10.90.5.8:8080`(M2 AI 联网) +- LangBot `10.90.5.8:30030`(多模型接入) +- Docker 网络 `dbquery_net`(新 `itdesk_net`) + +### 9.4 关键对接参数 +``` +dify2openai API: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions +RAGFlow: http://10.80.0.85:8080 +Qwen3-30B: http://10.80.0.49:5000/api/llm/servyou/v1/chat/completions +Dify DB(生产只读): 10.80.128.40:5432 DB=dify User=difyro +Dify DB(测试): 10.199.16.9:5432 DB=dify User=dify_ro +Redis: 10.90.5.8:6379 +数据平台: http://it-dataquery.dc.servyou-it.com (10.80.0.86) +B 端智能体: https://agent.dc.servyou-it.com +SearXNG: http://10.90.5.8:8080 +Dify App ID: a57543f3-de66-47cc-ad89-d0540c08159f +``` + +--- + +## 10. WebSocket 实时通信 + +### 10.1 设计要点 +- **路径**:`/ws/{agent_id}` + `/ws/h5/{employee_id}`(H5 端点) +- **认证**:**P0 修复后** token 走 `Sec-WebSocket-Protocol` subprotocol(不是 URL query,不是 header) +- **nginx 配置**: + - `proxy_http_version 1.1` + - `Upgrade` + `Connection "upgrade"` + - `proxy_read_timeout 86400s` + - **`access_log off`**(避免 token 泄露,P0-#4) +- **限流**:无原生限流,依赖 token 鉴权 + +### 10.2 心跳 + 重连 + 降级 +- 心跳:前端 30s ping,后端 pong +- 重连:指数退避(1s → 2s → 4s → ... → 30s 上限) +- 降级:WS 断连 → 自动切 3s 轮询 → 重连后停轮询 + +### 10.3 ws_manager 单例 +- 模块级单例,全局共享 +- broadcast 遇发送失败自动断开该连接(避免僵尸) +- 失败只记 warning 不抛异常(不阻塞调用方) + +--- + +## 11. 前端 4 端总览 + +| 端 | 路径 | 技术栈 | 组件数 | 路由 | +|---|---|---|---|---| +| frontend-h5 | `/itdesk/` | Vue3 + Vant4 + Pinia + TS | 11 | vue-router | +| frontend-agent | `/itagent/` | Vue3 + ElementPlus + Pinia + TS | 23 | vue-router | +| frontend-portal | `/itportal/` | Vue3 + Vant4 + Pinia + TS | 3 | 角色卡片选择 | +| frontend-admin | `/itadmin/` | Vue3 + ElementPlus + Pinia + TS | 13+ | vue-router | + +### 11.1 关键 UI 决策 +- **统一企微浅色扁平风格**(accent=#07C160 微信绿) +- **术语统一**:"举手" → "招手","铃铛" → "传菜铃" +- **响应式**:H5 端适配企微 WebView + +### 11.2 4 前端审计 + 16 项统一优化路线 +详见 `docs/前端审计报告.md` + +--- + +## 12. 部署方案 + +### 12.1 生产服务器(当前 v0.5.0-beta) +| 资源 | 值 | +|---|---| +| 服务器 | **10.90.5.110**(公司内网,2026-06-15 起替代 10.80.0.136) | +| 域名 | `itsupport.servyou.com.cn`(通配符 `*.servyou.com.cn`) | +| HTTPS | 443(SSL 证书已部署,2026-06-15) | +| 80 端口 | 301 跳 443 | +| 数据库 | PostgreSQL 16(Docker) | +| 缓存 | Redis 7(Docker) | +| 部署目录 | `/opt/wecom-it-desk/` | +| 上传临时目录 | `/tmp/`(堡垒机限制) | +| Docker 网络 | bridge(Docker Compose 自定义) | +| 健康检查 | nginx `curl -kf https://localhost:443/itdesk/health` | + +### 12.2 NAS 测试环境 +| 资源 | 值 | +|---|---| +| 服务器 | NAS(Synology 套件) | +| 域名 | `itdesk.amanzac.com` | +| 用途 | Gitea 自托管(8418 端口) + 内部测试 | + +### 12.3 部署包结构(`package.py` 生成) +``` +it-smart-desk-server-deploy.zip +├── docker-compose.yml +├── .env.example +├── deploy.sh +├── README.md +├── nginx/nginx.conf +├── frontend-h5/dist/ # H5 静态文件 +├── frontend-agent/dist/ # 坐席端静态 +├── frontend-portal/dist/ # 统一入口静态 +├── frontend-admin/dist/ # 管理后台静态 +└── backend/ # FastAPI 源码(排除 uploads/ 隐私) +``` + +### 12.4 ⚠️ package.py P0 修复(v0.5) +- 补:INCLUDE_MAP 缺 `frontend-portal/dist` 和 `frontend-admin/dist` +- 补:should_exclude 排除 `uploads/`(隐私不打包) +- 补:build_frontends 加 portal + admin 端构建 + +--- + +## 13. 运维操作 + +### 13.1 一键部署 +```bash +# 打包 +cd 项目根目录 +python deploy-server/package.py +# → it-smart-desk-server-deploy.zip + +# 上传 +scp it-smart-desk-server-deploy.zip user@server:/tmp/ +# (堡垒机禁 ProxyJump,需用其他方式:Web 上传 / 内部文件平台) + +# 部署 +ssh user@server +sudo cp /tmp/it-smart-desk-server-deploy.zip /opt/ +cd /opt +unzip it-smart-desk-server-deploy.zip +mv it-smart-desk-server-deploy wecom-it-desk +cd wecom-it-desk +cp .env.example .env +vi .env # 编辑配置 +chmod +x deploy.sh +./deploy.sh +``` + +### 13.2 日常运维 +```bash +# 查看服务状态 +docker compose ps + +# 查看后端日志 +docker compose logs -f backend + +# 数据库迁移 +docker compose exec backend alembic upgrade head + +# 备份 +./scripts/backup-gitea.sh + +# 健康度仪表盘 +./scripts/dashboard.py +# → docs/dashboard.html(浏览器打开) +``` + +### 13.3 应急响应 +详见 `docs/SOPs/SOP-004-应急响应.md` + +--- + +## 14. 安全设计 + +### 14.1 已修复 P0(2026-06-14~15) +1. **WS token 泄露** — token 改走 `Sec-WebSocket-Protocol` subprotocol(非 URL/header) +2. **坐席登录缺密码** — `agents.py` L222-232 加 bcrypt 验证(3 测试覆盖) +3. **nginx access_log 记录 WS token** — `/ws/` 路径 `access_log off` +4. **5 鉴权漏洞** — 5 端点鉴权已修 +5. **企微凭据硬编码** — 旧 `Bs7ucT*` 已轮换,新值走 `WECOM_CORP_SECRET` env + +### 14.2 待修 P0(2026-06-15 报告) +- 5 鉴权漏洞全部修完(已 100% 闭环,见 [[review-p0-security-2026-06-14]]) +- 浏览器 WS API 不支持 subprotocol → 需找替代方案 +- nginx access_log 全局配置未关(只关了 /ws/) +- 类型 bug / 降级放行 / 缺依赖 5 项遗留(详见评审报告) + +### 14.3 安全 Header(nginx) +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: SAMEORIGIN` +- `X-XSS-Protection: 1; mode=block` +- `Strict-Transport-Security: max-age=31536000; includeSubDomains` +- `Referrer-Policy: strict-origin-when-cross-origin` +- **CSP**:`default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*; font-src 'self' data:;` +- `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()` +- `Cross-Origin-Opener-Policy: same-origin` +- `Cross-Origin-Embedder-Policy: require-corp` +- `Cross-Origin-Resource-Policy: same-origin` +- `server_tokens off` + +### 14.4 IP 白名单(nginx) +- `/itadmin/`:10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 10.212.0.0/16(VPN) +- `/api/admin/`:同 itadmin + +### 14.5 密码策略 +- 坐席密码:**bcrypt**(已迁移明文 → hash) +- DB 用户:PostgreSQL `wecom`(trust on unix socket / MD5 on TCP) +- 旧凭据:已轮换 + +--- + +## 15. 决策锁(已锁定,不可随意变更) + +| 决策 | 内容 | 锁定日期 | 依据 | +|---|---|---|---| +| **域名** | `itsupport.servyou.com.cn`(通配符 `*.servyou.com.cn`) | 2026-06 | 已有 SSL 证书 | +| **UI 风格** | 企微浅色扁平风,accent=#07C160 | 2026-06-13 | 坐席+H5 统一 | +| **术语** | "举手" → "招手","铃铛" → "传菜铃" | 2026-06-13 | 25+ 处代码修改 | +| **双企微应用** | 正式 + 测试(子域名申请困难) | 2026-06-13 | 决策记录在状态报告 | +| **架构** | 单体 Docker Compose → 未来 K8s 集群 | 2026-06-03 | 演进路径 M3 后再迁 | +| **数据库** | PostgreSQL 16(单库,合并原 Dify + intervention) | 2026-06 | 升级策略 | +| **缓存** | Redis 7(单实例,db 0 旧/db 1 新) | 2026-06 | 复用现有 | +| **认证** | JWT + Redis(替换 Django Session) | 2026-06 | 重写认证层 | +| **AI 模型** | Qwen3-30B 主, Dify Workflow 编排 | 2026-06 | 复用现有 | +| **项目名** | `wecom_it_smart_desk`(claude 工作副本 `-claude` 后缀) | 2026-06-15 | 移交后命名 | +| **Git 托管** | Gitea 自托管(NAS 8418) | 2026-06-14 | ADR-001 | +| **IP 白名单(临时)** | set_real_ip_from 用 10/172.16/192.168/10.212 4 段(内网最大化)**临时方案** | 2026-06-15 | 修 403 异常 | +| **IP 白名单(正式)** | **正式发布前必改**:审计公司实际前置代理 IP,精确单 IP 列表(防 X-Forwarded-For 伪造) | v1.0 必做 | 见 [[ip-whitelist-trust-proxies-todo]] | + +### 15.1 配色体系(UI 重要性标识) +- 🔴 紧急/高风险 +- 🟡 重要/需关注 +- 🟢 常规/OK +- ⚪ 信息/记录 + +### 15.2 选择项中英对照(用户偏好) +所有选择项英文内容必须包含中文翻译 + +--- + +## 16. 工具链(8 个脚本 + 5 份配置) + +| # | 路径 | 用途 | +|---|---|---| +| 1 | `scripts/dashboard.py` | 生成健康度 HTML(`docs/dashboard.html`) | +| 2 | `scripts/oneclick-deploy.sh` | 一键部署(灰度) | +| 3 | `scripts/pre-commit-check.sh` | 提交前 4 件套预检(鉴权+依赖+alembic+配置) | +| 4 | `scripts/backup-gitea.sh` | Gitea 备份 + 恢复(cron 3 点) | +| 5 | `scripts/security-audit.sh` | 5 工具集成审计 | +| 6 | `scripts/generate-api-docs.sh` | OpenAPI + Swagger UI + ReDoc | +| 7 | `scripts/build.sh` | 统一构建 | +| 8 | `scripts/deploy.sh` | 通用部署 | + +| # | 路径 | 用途 | +|---|---|---| +| 1 | `.dockerignore` | Docker 优化 | +| 2 | `.gitea/dependabot.yml` | 依赖自动更新 | +| 3 | `.gitea/ISSUE_TEMPLATE/bug.md` | Bug 报告模板 | +| 4 | `.gitea/ISSUE_TEMPLATE/feature.md` | Feature 申请模板 | +| 5 | `.gitea/PULL_REQUEST_TEMPLATE.md` | PR 模板 | + +--- + +## 17. 文档体系(50+ 份) + +### 17.1 设计文档(7) +- `docs/01-项目总览与部署手册.md`(v2.1, 34K) +- `docs/ARCHITECTURE.md`(完整架构,2,880 行,129K) +- `docs/ARCHITECTURE-admin.md`(管理后台,1,274 行) +- `docs/团队沟通文档-架构消息知识库.md`(655 行) +- `docs/统一入口技术设计文档.md`(909 行) +- `docs/Wingman设计.md`(776 行) +- `docs/外部系统集成`:火绒 560 行 + 联软 797 行 + aTrust 879 行 + +### 17.2 PRD(2) +- `docs/PRD.md`(完整需求,2,077 行,127K) +- `docs/PRD-admin.md`(管理后台,27K) +- `docs/PRD-增量-人工按钮与术语统一.md`(14K) + +### 17.3 技术方案(5) +- `docs/ExternalSystemAdapter设计文档.md`(v1.0) +- `docs/消息功能详细方案.md`(534 行) +- `docs/摇人-多坐席协作-技术方案.md`(389 行) +- `docs/邀请功能-技术方案.md`(407 行) +- `docs/OTP二次验证实现.md`(88 行) + +### 17.4 ADR(架构决策记录,4 份) +- `docs/ADRs/ADR-001-Gitea自托管-Funnel暴露.md` +- `docs/ADRs/ADR-002-WS-Token-Subprotocol鉴权.md` +- `docs/ADRs/ADR-003-nginx-access_log关闭.md` +- `docs/ADRs/ADR-004-Token不入文件-走wincred.md` + +### 17.5 SOP(标准操作流程,4 份) +- `docs/SOPs/SOP-001-Gitea部署.md` +- `docs/SOPs/SOP-002-Gitea备份恢复.md` +- `docs/SOPs/SOP-003-推送评审.md` +- `docs/SOPs/SOP-004-应急响应.md` + +### 17.6 审计报告(4) +- `docs/审计报告/CORS-CSP-安全Header全套.md` +- `docs/审计报告/Dockerfile优化与镜像审计.md` +- `docs/审计报告/依赖漏洞扫描与Lockfile审计.md`(识别 5 CVE) +- `docs/审计报告/健康检查+错误码+日志结构化.md`(40+ 错误码 + JSON 日志) + +### 17.7 惊喜报告(2) +- `docs/惊喜报告/🎁惊喜1-项目健康度仪表盘.md` +- `docs/惊喜报告/🎁惊喜2-README徽章+CHANGELOG+模板.md` + +### 17.8 评审报告(6) +- `docs/评审报告/workbuddy-2026-06-14-Gitea重建.md` +- `docs/评审报告/workbuddy-2026-06-14-P0安全.md` +- `docs/评审报告/workbuddy-2026-06-14-消息优化-P1二次评审.md` +- `docs/评审报告/workbuddy-2026-06-14-消息优化.md` +- `docs/评审报告/workbuddy-2026-06-14-预检验证.md` +- `docs/评审报告/workbuddy-2026-06-15-T组A组.md` + +### 17.9 部署/迁移(6) +- `docs/DEPLOY_NAS.md` +- `docs/NAS部署指南.md` +- `docs/Gitea部署指南.md` +- `docs/正式环境独立部署架构方案.md` +- `docs/智能IT支持服务台-项目迁移文档.md` +- `docs/资源申请清单.md` + +### 17.10 状态/报告(5) +- `docs/RELEASE_NOTES_v0.5.0-beta.md` +- `docs/项目任务状态报告_2026-06-13.md`(152 任务 99.3% 完成) +- `docs/IT服务台部署修复记录-2026-06-13.md` +- `docs/调试验证指南_2026-06-13.md` +- `docs/风险跟踪表.md`(907 行) + +### 17.11 数据库(1) +- `docs/数据库ER图与环境变量清点.md`(16 表 ER + 17 env) + +### 17.12 前端审计(1) +- `docs/前端审计报告.md`(10K) + +### 17.13 原型/prototypes(目录) +- `docs/prototypes/` +- `docs/IT智能服务台_项目汇报.html`(34K,可视化) +- `docs/dashboard.html`(健康度仪表盘) + +### 17.14 域名/邮件(1) +- `docs/域名申请邮件-itsupport-servyou-com-cn.md` + +### 17.15 现有系统交接(1) +- `docs/现有系统交接文档内容.txt`(137 行,原 Django 系统) + +### 17.16 紧急预案(1) +- `docs/需求-发布预演页面.md`(应急降级页 v4) + +--- + +## 18. 测试 + +### 18.1 后端测试 +- 116 条 pytest(已写,部分已通过) +- v0.5 新增 3 条(降级登录密码验证) +- 路径:`backend/tests/test_agents.py` + +### 18.2 端到端验证 +- 任务 #149 — In Progress +- 范围:H5 登录(3 种)/ 坐席接单 / 消息收发 / 邀请功能 / 管理后台 +- 环境:`https://itsupport.servyou.com.cn` + `https://itdesk.amanzac.com` + +--- + +## 19. 待办清单(从项目任务状态报告 + 评审报告 + 风险表) + +### 19.1 立即执行(P0) +1. ~~端到端验证 #149~~(等待中) +2. ~~HTTPS 配置(SSL 证书)~~ ✅ 已完成(2026-06-15) +3. 修 006 filename 与 revision 不一致(v0.5.1)— 任务 #41 +4. ~~alembic chain 修复(007 typo)~~ ✅ 已完成 +5. ~~浏览器验证 4 域名~~ ✅ 已完成 + +### 19.2 近期安排(P1) +1. **创建测试企微应用**(双企微方案) +2. **阶段二启动**:排队机制 + 满意度评价 +3. **aTrust 对接**:找信息安全团队 +4. **北森 eHR 对接**:找 HR 数字化团队 +5. **集成验证**:4 外部系统端到端 + +### 19.3 技术债务(P2) +1. Redis 密码加固 +2. PostgreSQL 强密码 +3. CORS 配置收紧 +4. CSP 策略实施(已部分实施) +5. 应急降级页(BC/DR)代码 +6. 演练 SOP-005 + +### 19.4 风险登记 +| 风险 | 级别 | 缓解 | +|---|---|---| +| 企微凭据集中化 | 中 | NAS Vault(待做) | +| 5 外部系统任一不可用 | 中 | ExternalSystemAdapter 自动降级 | +| 现有系统密码明文 | 高 | bcrypt 迁移已完成 | +| Dify DB 只读 | 中 | 新系统自建库,只从 Dify 同步 | +| 5 CVE(依赖漏洞) | 中 | dependabot 自动更新 | +| WB 上下文冲突 | 高 | **已退出,Claude 接管** | + +--- + +## 20. 关键文件路径速查 + +### 20.1 后端核心 +``` +backend/ +├── alembic/versions/ # 10 个迁移 +├── app/ +│ ├── main.py # FastAPI 入口(608 行) +│ ├── config.py # 配置(154 行) +│ ├── database.py # DB 连接(146 行) +│ ├── api/ # 20 个 router +│ ├── services/ # 15 个 service +│ ├── models/ # 16 个 model +│ ├── schemas/ # Pydantic 5 套 +│ ├── integrations/ # 4 个外部系统 +│ └── utils/ # error_codes/token/crypto +├── tests/ # 116 条 pytest +├── Dockerfile +└── requirements.txt +``` + +### 20.2 前端核心 +``` +frontend-h5/ # H5 员工端(11 组件) +frontend-agent/ # 坐席工作台(23 组件) +frontend-portal/ # 统一入口(3 组件) +frontend-admin/ # 管理后台(13+ 视图) +``` + +### 20.3 部署核心 +``` +deploy-server/ +├── package.py # 打包脚本 +├── docker-compose.yml +├── nginx/nginx.conf +├── .env.example +└── deploy.sh +``` + +### 20.4 配置/规范 +``` +.dockerignore +.gitea/ # dependabot + issue_template + pr_template +.pre-commit-config.yaml +docs/ADRs/ # 4 份架构决策 +docs/SOPs/ # 4 份标准操作 +``` + +--- + +## 21. 关联 memory 索引(已沉淀) + +- [[project-handover-to-claude]] — 项目从 WB 全面移交到 Claude(2026-06-15) +- [[locked-decisions]] — 锁定的设计决策 +- [[phase1-progress]] — 痛点清单 + 五阶段演进规划 +- [[tech-architecture]] — 技术架构细节(超时/字段映射/Redis 协议) +- [[wechat-environment]] — 企微环境限制(UA/OAuth2/统一入口) +- [[deployment]] — 部署经验(NAS/Docker/堡垒机) +- [[external-integrations]] — 外部系统集成(火绒/联软/aTrust/eHR) +- [[workbuddy-tool]] — WB 推进工具(已停用) +- [[ip-whitelist-trust-proxies-todo]] — IP 白名单正式发布前必收窄(P0 安全收尾) +- [[code-version-chaos]] — 代码版本管理混乱(治理中) +- [[gitea-deployed-2026-06-14]] — Gitea 部署 +- [[gitea-push-permission-revoked-2026-06-15]] — push 权限吊销 +- [[review-messages-2026-06-14]] — WB 消息评审 +- [[review-p0-security-2026-06-14]] — WB P0 安全评审 +- [[overnight-batch-2026-06-15]] — 满载跑批产出 +- [[server-upload-path]] — 服务器上传路径 +- [[production-architecture-pitfall]] — 生产架构误判教训 +- [[backend-healthcheck-curl-pitfall]] — backend healthcheck curl 坑 +- [[user-language-preference]] — 选择项中英对照 +- [[choice-importance-colors]] — 选项配色体系 +- [[feedback-execute-without-asking]] — 直接执行不请示 + +--- + +## 22. 后续行动(Claude 接管后) + +### 22.1 立即 +- [ ] 修复 006 filename 与 revision 不一致(v0.5.1,任务 #41) +- [ ] commit 当前所有 hotfixes + push 到 Gitea(等用户重授权) +- [ ] 重打部署包并验证 +- [ ] 浏览器验证 /itdesk/ 500 错误根因 + +### 22.2 短期(1 周) +- [ ] 阶段二排队机制设计 +- [ ] 满意度评价 PRD +- [ ] 应急降级页 v4 实现 +- [ ] 演练 SOP-005 +- [ ] 单元测试全量跑通 + +### 22.3 中期(1 月) +- [ ] 阶段二 AI 接入(Dify + RAGFlow) +- [ ] aTrust API 对接 +- [ ] 北森 eHR OAuth2 对接 +- [ ] 知识库迭代闭环(M3 起步) +- [ ] 应急降级页演练 + +### 22.4 长期 +- [ ] v0.6.0 正式发布(2026-06-20) +- [ ] v1.0.0 正式版目标(2026-12,阶段 5 完成) +- [ ] K8s 集群迁移 +- [ ] 跨主体共享支持 + +--- + +## 23. 今日踩坑(2026-06-15~16 经验沉淀) + +> **作用**:把过去两天踩过的 7 个坑写成"事后诸葛亮",让以后部署/排错时少走弯路。每条都给了"症状 → 根因 → 修法 → 教训"四段式。 + +### 23.1 alembic_version 表脏数据导致 backend 启动 overlaps + +- **症状**:backend 容器启动后 alembic 报错 `Multiple head revisions are present for alembic_version`,数据库迁移失败。 +- **根因**:之前手动执行 `alembic stamp 009` 后没清旧数据,表里残留两行版本号(008 和 009),alembic 检测到"两个 head"就抛错。 +- **修法**:SQL 直连 PostgreSQL → `DELETE FROM alembic_version WHERE version_num != '009';` 只保留最新一行。 +- **教训**:**永远用 alembic 命令升级**(alembic upgrade head),不要手工 stamp;如果非要 stamp,先清表再写。 + +### 23.2 REDIS_URL 密码含 @ # 必须 URL-encode + +- **症状**:backend 容器一直 `Redis TimeoutError`,redis-cli 连不上。 +- **根因**:生产 Redis 密码 `R3d!s@2026#Secure` 含 `@` 和 `#` 两个 URL 保留字符。redis-py 用 urlparse 解析 `redis://:R3d!s@2026#Secure@redis:6379/0`,第一个 `@` 被当 host 分割,密码被截成 `R3d!s`。 +- **修法**:docker-compose.yml 的 REDIS_URL 用 `${REDIS_PASSWORD:-R3d%21s%402026%23Secure}`,shell 展开后是 URL-encoded → redis-py 会自动 decode 回明文 → 与 Redis 实际密码一致。 +- **教训**:**密码含 URL 保留字符必须 URL-encode**(`@`→`%40`、`#`→`%23`、`!`→`%21`)。参考 [[redis-url-special-chars-pitfall]] 和 [[redis-py-url-decode-trap]]。 + +### 23.3 nginx IP 白名单"两处都改"才生效 + +- **症状**:`/itadmin/` 一直返 403 Forbidden,即使加了 `allow 115.236.188.3;`。 +- **根因**:nginx 配置有**嵌套 location**,`/itadmin/` 块(返回 HTML)和 `/api/admin/` 块(代理 API)各有一套独立的 `allow/deny`。只改一处白名单不生效。 +- **修法**:两个块都要加 `allow 115.236.188.3;`,然后 `docker exec wecom_it_nginx nginx -s reload`。 +- **教训**:**nginx 嵌套 location 都要单独检查**。看 conf 时区分 outer block(12-space indent)和 inner block(16-space indent)。参考 [[nginx-allow-list-pitfall]]。 + +### 23.4 企微 WebView 真实 IP 不是公司公网 IP + +- **症状**:加 `allow 115.236.188.3;` 后用户用 企微 Android Edge 访问 `/itadmin/` 还是 403。 +- **根因**:`115.236.188.3` 是公司**公网出口 IP**(WAF 后面的),用户手机走的是移动运营商网络,客户端真实 IP 是运营商 IP,不会经过 115.236.188.3。 +- **修法**:临时改成 `allow 0.0.0.0/0;`(全开),靠 backend 的 admin token 鉴权保护。 +- **教训**:**WAF/公司公网出口 IP ≠ 用户真实客户端 IP**。要拿用户真实 IP,得看 WAF/CDN 透传的 X-Forwarded-For 字段。**v1.0 前必须收窄**(任务 #48)。 + +### 23.5 SPA 路由守卫是异步,组件 mount 早就触发了 + +- **症状**:企微点坐席应用 → `/itagent/` 弹"未授权,请重新登录"2 次。 +- **根因**:router 守卫虽然会跳到 `/itportal/`,但 Vue 组件的 `import` + `onMounted` 在 router 守卫**之前**就触发了,axios 拦截器已经发了 `/api/agents/me` 请求 → 后端 401 → 弹"未授权"。 +- **修法**:`Workspace.vue` 的 `onMounted` 第一行先检查 `localStorage.getItem('agent_token')`,没 token 立刻 `window.location.href = '/itportal/'` 并 `return`。 +- **教训**:**router 守卫不可靠**,任何需要鉴权的页面都要在 `onMounted` 第一行手动检查 token。参考 [[workspace-vue-pre-mount-check]]。 + +### 23.6 Workspace.vue 没输入框不是 bug,是没选会话 + +- **症状**:用户报"坐席端没有消息输入框"。 +- **根因**:`Workspace.vue` 中间区域是**动态切换**的(`v-if/v-else-if/v-else`),没 `currentConversation` 时显示 `` 占位,根本没有 ChatArea/InputBox。 +- **诊断**:让用户看中间显示什么。 + - 显示"请选择..." → 不是 bug,引导用户点左侧会话 + - 显示对话气泡但无输入框 → ChatArea 组件 bug + - 完全空白 → 组件渲染失败 +- **教训**:**SPA 视图切换**不要只看"UI 元素缺失",要先看父组件的 `v-if` 条件。参考 [[agent-input-box-no-bug]]。 + +### 23.7 SQLAlchemy UUID 列在 DB 里是 VARCHAR → 偶发 500 + +- **症状**:`/h5/conversations/current/messages/poll` 接口偶发 500,日志 `operator does not exist: character varying = uuid`。 +- **根因**:数据库 `messages.id` 列是 VARCHAR(早期 migration 用 String 定义),但 SQLAlchemy 模型把 `id` 定义成 UUID,查询时自动 cast `WHERE id = $1::UUID` → 类型不匹配 → 500。 +- **修法**:加 alembic migration `ALTER TABLE messages ALTER COLUMN id TYPE UUID USING id::UUID` 把列类型统一成 UUID。 +- **教训**:**schema 演进必须改 DB schema**,只改 Python 模型不够。看到 `operator does not exist` 错误,八成是列类型不匹配。参考 [[sql-messages-id-varchar-vs-uuid]]。 + +### 23.8 backend 精简镜像没 curl,healthcheck 永远 unhealthy 但业务正常 + +- **症状**:`docker ps` 显示 backend `unhealthy`,但 API 接口全部正常返回 200。 +- **根因**:backend 用的是 `python:3.11-slim` 精简镜像,**没有 curl**。docker healthcheck 跑 `curl -f http://localhost:8000/health` 直接 `command not found`,每次都 unhealthy。 +- **修法**:改用 `wget --spider` 或直接 `python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"`。 +- **教训**:**精简镜像做 healthcheck 要选已装的工具**,或者 build 时加 `apt-get install -y curl`。参考 [[backend-healthcheck-curl-pitfall]]。 + +### 23.9 frontend 不是独立容器,是 nginx 静态文件挂载 + +- **症状**:以为 frontend 是 docker 容器,改前端要重新 build image + push。 +- **根因**:看 docker-compose.yml 后才发现 frontend 4 个端都是**构建产物直接挂载**到 nginx 容器的 `/itagent/` `/itadmin/` 等目录,前端代码完全在宿主机 `/opt/wecom-it-desk/frontend-*/dist/`。 +- **修法**:改前端 → 本地 build → tar 上传 → 解压到 `/opt/wecom-it-desk/frontend-*/dist/` → `docker exec wecom_it_nginx nginx -s reload`。 +- **教训**:**先 `cat docker-compose.yml` 再下架构结论**。参考 [[production-architecture-pitfall]]。 + +--- + +> **本文档维护者**:Claude(claude 工作目录) +> **更新频率**:每次重大变更后更新 +> **下次审计**:v0.6.0 发布前 diff --git a/backend/alembic/versions/006_admin_extension.py b/backend/alembic/versions/006_admin_ext.py similarity index 86% rename from backend/alembic/versions/006_admin_extension.py rename to backend/alembic/versions/006_admin_ext.py index c2ef605..362817e 100644 --- a/backend/alembic/versions/006_admin_extension.py +++ b/backend/alembic/versions/006_admin_ext.py @@ -1,4 +1,4 @@ -"""admin extension — 管理后台数据库扩展迁移 +"""admin ext — 管理后台数据库扩展迁移 新增 config_change_logs 表(配置变更日志)。 扩展 agents 表:新增 role(角色)和 skill_tags(技能标签)字段。 @@ -8,16 +8,23 @@ submitted_by(提交人)字段。 Revision ID: 006_admin_ext Revises: 005_reply_to_id Create Date: 2026-07-15 10:00:00.000000 + +注:filename 与 revision 字符串一致(v0.5.1 修复) +原 filename `006_admin_extension.py` 改名为 `006_admin_ext.py`, +revision 字符串保持 `006_admin_ext` 不变(DB alembic_version 表已存此值, +改 revision 会破坏 chain)。 """ +from typing import Sequence, Union + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '006_admin_ext' -down_revision = '005_reply_to_id' -branch_labels = None -depends_on = None +revision: str = '006_admin_ext' +down_revision: Union[str, None] = '005_reply_to_id' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: @@ -113,4 +120,5 @@ def downgrade() -> None: # 删除 config_change_logs 表索引和表 op.drop_index('idx_ccl_changed_at', table_name='config_change_logs') op.drop_index('idx_ccl_config_key', table_name='config_change_logs') + op.table('config_change_logs') op.drop_table('config_change_logs') diff --git a/backend/app/api/admin/security_comparison.py b/backend/app/api/admin/security_comparison.py new file mode 100644 index 0000000..b1b698f --- /dev/null +++ b/backend/app/api/admin/security_comparison.py @@ -0,0 +1,166 @@ +""" +终端安全对比 API + +路径: /api/admin/security/comparison +鉴权: require_admin +""" + +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.api.admin import require_admin +from app.services.security_comparison import ( + TerminalSecurityComparison, + comparison_task_config, +) + +router = APIRouter(prefix="/security/comparison", tags=["终端安全对比"]) + + +# --- Request/Response Models --- +class CompareRequest(BaseModel): + """手动触发比对请求""" + pass # 无参数,手动触发 + + +class CompareSummaryResponse(BaseModel): + """比对汇总响应""" + lianruan_count: int + huorong_count: int + no_huorong_count: int + compliance_rate: str + generated_at: str + + +class NoHuorongDevice(BaseModel): + """未安装火绒设备""" + hostname: str + ip: str + useraccount: Optional[str] = None + dept: Optional[str] = None + last_login: Optional[str] = None + osver: Optional[str] = None + status: Optional[str] = None + + +class TaskConfigRequest(BaseModel): + """任务配置请求""" + name: str # 任务名称 + cron: str # Cron 表达式,如 "0 9 * * 1" 每周一9点 + recipients: list[str] # 企微接收人user_id列表 + enabled: bool = True + + +class TaskConfigResponse(BaseModel): + """任务配置响应""" + task_id: str + name: str + cron: str + recipients: list[str] + enabled: bool + last_run: Optional[str] = None + next_run: Optional[str] = None + + +# --- API Endpoints --- +@router.get("/summary", response_model=CompareSummaryResponse) +async def get_comparison_summary(current_user=Depends(require_admin)): + """获取比对汇总数据""" + service = TerminalSecurityComparison() + try: + summary = await service.compare_summary() + return summary + finally: + await service.close() + + +@router.get("/no-huorong", response_model=list[NoHuorongDevice]) +async def get_no_huorong_devices(current_user=Depends(require_admin)): + """获取未安装火绒的电脑清单""" + service = TerminalSecurityComparison() + try: + devices = await service.get_no_huorong_devices() + return devices + finally: + await service.close() + + +@router.post("/trigger") +async def trigger_comparison(current_user=Depends(require_admin)): + """手动触发比对并推送企微消息""" + service = TerminalSecurityComparison() + try: + # 1. 执行比对 + no_huorong = await service.get_no_huorong_devices() + + # 2. 生成消息 + if no_huorong: + msg = f"⚠️ 终端安全检查:发现 {len(no_huorong)} 台电脑未安装火绒\n\n" + for dev in no_huorong[:10]: # 只显示前10条 + msg += f"• {dev.get('hostname')} ({dev.get('ip')})\n" + if len(no_huorong) > 10: + msg += f"... 还有 {len(no_huorong)-10} 台" + else: + msg = "✅ 终端安全检查:所有电脑已安装火绒" + + # 3. TODO: 推送到企微(需要企微消息API) + logger.info(f"比对结果: {msg}") + + return { + "success": True, + "no_huorong_count": len(no_huorong), + "message": msg, + } + finally: + await service.close() + + +# --- 任务配置 API --- +@router.get("/tasks", response_model=list[TaskConfigResponse]) +async def list_tasks(current_user=Depends(require_admin)): + """列出所有定时任务""" + tasks = comparison_task_config.list_tasks() + return tasks + + +@router.post("/tasks", response_model=TaskConfigResponse) +async def create_task( + config: TaskConfigRequest, + current_user=Depends(require_admin) +): + """创建定时任务""" + task_id = str(uuid4())[:8] + + comparison_task_config.add_task(task_id, { + "name": config.name, + "cron": config.cron, + "recipients": config.recipients, + "enabled": config.enabled, + "created_at": datetime.now().isoformat(), + }) + + return TaskConfigResponse( + task_id=task_id, + **config.model_dump(), + ) + + +@router.delete("/tasks/{task_id}") +async def delete_task( + task_id: str, + current_user=Depends(require_admin) +): + """删除定时任务""" + success = comparison_task_config.delete_task(task_id) + if not success: + raise HTTPException(status_code=404, detail="任务不存在") + return {"success": True} + + +# 日志记录 +import logging +logger = logging.getLogger(__name__) \ No newline at end of file diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 2c73cab..6243581 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -212,7 +212,7 @@ async def agent_login( if not existing_agent: # 新坐席注册必须通过企微验证,防止任意 user_id 冒充 raise AppException( - 1003, + ErrorCode.AUTH_TOKEN_INVALID, "企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。" ) logger.warning( @@ -223,7 +223,7 @@ async def agent_login( if existing_agent.password_hash is None: # 已注册坐席但未设置密码,要求先设置密码 raise AppException( - 1012, + ErrorCode.AUTH_PASSWORD_REQUIRED, "首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码" ) if not body.password: diff --git a/backend/app/api/h5.py b/backend/app/api/h5.py index 7ca80c8..e263767 100644 --- a/backend/app/api/h5.py +++ b/backend/app/api/h5.py @@ -829,18 +829,21 @@ async def h5_poll_messages( ).order_by(Message.created_at.asc()) if after_message_id: - # 转换为UUID类型查询,确保和数据库UUID字段类型匹配 + # 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型不匹配) from uuid import UUID as UUIDType try: - msg_uuid = UUIDType(after_message_id) + UUIDType(after_message_id) # 仅校验 except ValueError: - # 无效的UUID格式,返回空列表 + # 无效的UUID格式,返回空列表 items = [] return success_response(data={"items": items, "has_more": False}) + # 必须用字符串比较,Message.id 在 DB 里是 String(36)/VARCHAR, + # 传 UUID 对象会被 SQLAlchemy 推断成 UUID 类型 → PostgreSQL 报 + # "operator does not exist: character varying = uuid" after_stmt = select(Message.created_at).where( - Message.id == msg_uuid + Message.id == str(after_message_id) ) after_result = await db.execute(after_stmt) after_time = after_result.scalar_one_or_none() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index f4295e1..0a85b17 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -24,7 +24,9 @@ from app.api.upload import router as upload_router from app.api.admin import router as admin_router from app.api.portal import router as portal_router from app.api.admin_roles import router as admin_roles_router +from app.api.admin.security_comparison import router as security_comparison_router from app.api.approval import router as approval_router +from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名 # 创建 API 路由器 # 所有子路由都会挂载到这个路由器上 @@ -157,6 +159,14 @@ api_router.include_router(portal_router, tags=["统一入口"]) # DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则 api_router.include_router(admin_roles_router, tags=["角色管理"]) +# 终端安全对比 API +# GET /api/admin/security/comparison/summary — 比对汇总 +# GET /api/admin/security/comparison/no-huorong — 未安装火绒清单 +# POST /api/admin/security/comparison/trigger — 手动触发 +# GET /api/admin/security/comparison/tasks — 任务列表 +# POST /api/admin/security/comparison/tasks — 创建定时任务 +api_router.include_router(security_comparison_router, tags=["终端安全对比"]) + # 审批流程 API # GET /api/approval/templates — 获取审批模板列表 # GET /api/approval/templates/{id} — 获取审批模板详情 @@ -164,3 +174,7 @@ api_router.include_router(admin_roles_router, tags=["角色管理"]) # POST /api/approval/submit — API提交审批 # GET /api/approval/keywords — 获取审批关键词 api_router.include_router(approval_router, tags=["审批流程"]) + +# 企微 JS-SDK 签名 API (v0.5.4 应急页身份检测用) +# GET /api/wecom/jsapi-config?url=xxx — 返回 corp_id/agent_id/timestamp/nonce_str/signature +api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"]) diff --git a/backend/app/api/wecom_jsapi.py b/backend/app/api/wecom_jsapi.py new file mode 100644 index 0000000..8caef69 --- /dev/null +++ b/backend/app/api/wecom_jsapi.py @@ -0,0 +1,181 @@ +# ============================================================================= +# 企微IT智能服务台 — 企微 JS-SDK 签名 API (v0.5.4 应急页用) +# ============================================================================= +# 说明:提供前端 wx.config / wx.agentConfig 所需的鉴权签名。 +# 对应企微文档:https://developer.work.weixin.qq.com/document/path/90506 +# +# 流程: +# 1. 前端调 GET /api/wecom/jsapi-config?url=xxx 拿签名 +# 2. 后端用 jsapi_ticket + url 算 sha1 签名 +# 3. 前端用 wx.config({...}) 鉴权后,即可调企微 JS-SDK(如 wx.agentConfig) +# +# BC/DR 设计:不依赖 session/auth,公开访问(只返回签名,不返回敏感数据) +# ============================================================================= + +import logging +import secrets +import time + +from fastapi import APIRouter, Query + +from app.config import settings +from app.dependencies import get_shared_wecom_service +from app.utils.response import AppException, success_response + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/wecom/jsapi-config") +async def get_jsapi_config( + url: str = Query(..., description="当前页面 URL(不含 # 及其后)"), +): + """获取企微 JS-SDK 鉴权配置。 + + 供前端 wx.config 和 wx.agentConfig 使用。 + + Returns: + { + "code": 0, + "data": { + "corp_id": "wwa8c87970b2011f41", + "agent_id": "1000133", + "timestamp": 1718500000, + "nonce_str": "5K8264ILTKCH...", + "signature": "f7c8e9..." + } + } + """ + try: + wecom_service = get_shared_wecom_service() + + # 1. 获取 jsapi_ticket + ticket = await wecom_service.get_jsapi_ticket() + + # 2. 生成时间戳和随机串 + timestamp = int(time.time()) + nonce_str = secrets.token_hex(8) # 16 字符 + + # 3. 计算签名 + signature = wecom_service.generate_jsapi_signature( + ticket=ticket, + nonce_str=nonce_str, + timestamp=timestamp, + url=url, + ) + + logger.info( + f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}" + ) + + return success_response( + { + "corp_id": settings.wecom_corp_id, + "agent_id": str(settings.wecom_agent_id), + "timestamp": timestamp, + "nonce_str": nonce_str, + "signature": signature, + } + ) + + except Exception as e: + logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True) + raise AppException( + code=5001, + message=f"生成 JS-SDK 签名失败: {str(e)}", + ) from e + + +# ============================================================================= +# 应急页身份检测 (v0.5.4) +# ============================================================================= +# 流程: +# 1. 前端用 wx.agentConfig 拿到当前 userid +# 2. 前端调 GET /api/wecom/check-role?userid=xxx +# 3. 后端用企微通讯录 API 查 userid 是否在"IT支持-咨询坐席"标签里 +# 4. 返回 "user" 或 "agent" +# ============================================================================= + + +@router.get("/wecom/check-role") +async def check_emergency_role( + userid: str = Query(..., description="企微 userid"), +): + """检测当前账号在应急页场景下的角色。 + + 实现方式(优先级递减): + 1. 企微通讯录标签检测(若配置 WECOM_AGENT_TAG_ID) + 2. 后台硬编码名单(若配置 WECOM_AGENT_USERIDS 环境变量) + 3. 默认 "user" (兜底) + + Args: + userid: 企微 userid(从 wx.agentConfig 拿) + + Returns: + { + "code": 0, + "data": { + "role": "user" | "agent", + "userid": "...", + "method": "tag" | "hardcoded" | "default" + } + } + """ + wecom_service = get_shared_wecom_service() + + # 方式 1:企微标签检测 + tag_id = getattr(settings, "wecom_agent_tag_id", None) + if tag_id: + try: + access_token = await wecom_service.get_access_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}" + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(url) + result = resp.json() + + if result.get("errcode", 0) == 0: + user_list = result.get("userlist", []) + # userlist 元素可能是 str(老版)或 dict(新版带 name) + user_ids = [ + u if isinstance(u, str) else u.get("userid", "") + for u in user_list + ] + if userid in user_ids: + logger.info(f"标签检测: userid={userid} 是坐席") + return success_response( + {"role": "agent", "userid": userid, "method": "tag"} + ) + else: + logger.info(f"标签检测: userid={userid} 是员工") + return success_response( + {"role": "user", "userid": userid, "method": "tag"} + ) + else: + logger.warning( + f"标签 API 失败: errcode={result.get('errcode')}, " + f"errmsg={result.get('errmsg')}, 降级到硬编码" + ) + except Exception as e: + logger.warning(f"标签检测失败(降级): {e}") + + # 方式 2:硬编码名单 + hardcoded = getattr(settings, "wecom_agent_userids", None) + if hardcoded: + agent_ids = [x.strip() for x in hardcoded.split(",") if x.strip()] + if userid in agent_ids: + logger.info(f"硬编码名单: userid={userid} 是坐席") + return success_response( + {"role": "agent", "userid": userid, "method": "hardcoded"} + ) + else: + return success_response( + {"role": "user", "userid": userid, "method": "hardcoded"} + ) + + # 方式 3:默认 user + logger.info(f"未配置检测方式, userid={userid} 默认 user") + return success_response( + {"role": "user", "userid": userid, "method": "default"} + ) diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py index c0ede7a..9dc1e78 100644 --- a/backend/app/api/ws.py +++ b/backend/app/api/ws.py @@ -20,7 +20,6 @@ import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from starlette.requests import Request from app.services.ws_manager import manager as ws_manager from app.services.cache_service import cache_service @@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001 async def websocket_endpoint( websocket: WebSocket, agent_id: str, - request: Request, ) -> None: """坐席 WebSocket 端点主循环(含 WS-01 token 认证)。 @@ -61,10 +59,12 @@ async def websocket_endpoint( - 兼容从 ?token= URL 参数获取(向后兼容) - 不再将 token 暴露在 URL 中,避免 access_log 泄露 + v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败, + 改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query) + Args: websocket: FastAPI WebSocket 对象(框架自动注入) agent_id: 坐席ID(从 URL 路径参数获取) - request: Starlette Request(用于获取 header) """ # ====================================================================== # WS-01: Token 认证(从 subprotocol / header / query 获取) @@ -74,17 +74,17 @@ async def websocket_endpoint( # 格式: Sec-WebSocket-Protocol: bearer.{token} # 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组) # 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取 - subprotocol = request.headers.get("sec-websocket-protocol", "") + subprotocol = websocket.headers.get("sec-websocket-protocol", "") if subprotocol.startswith("bearer."): token = subprotocol[7:] # 去掉 "bearer." 前缀 else: # 其次从 Authorization header 获取 - auth_header = request.headers.get("Authorization", "") + auth_header = websocket.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] # 去掉 "Bearer " 前缀 else: # 向后兼容:从 query param 获取(即将废弃) - token = request.query_params.get("token", "") + token = websocket.query_params.get("token", "") # 步骤2: 检查 token 是否为空 if not token: @@ -197,7 +197,6 @@ async def websocket_endpoint( async def h5_websocket_endpoint( websocket: WebSocket, employee_id: str, - request: Request, ) -> None: """H5员工 WebSocket 端点主循环(含 token 认证)。 @@ -223,10 +222,12 @@ async def h5_websocket_endpoint( - (与H5登录 API /api/h5/mock-login 存储格式一致) - token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接 + v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败, + 改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query) + Args: websocket: FastAPI WebSocket 对象(框架自动注入) employee_id: 员工企微 UserID(从 URL 路径参数获取) - request: Starlette Request(用于获取 header) """ # ====================================================================== # Token 认证(从 subprotocol / header / query 获取) @@ -234,17 +235,17 @@ async def h5_websocket_endpoint( # 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容) # 格式: Sec-WebSocket-Protocol: bearer.{token} - subprotocol = request.headers.get("sec-websocket-protocol", "") + subprotocol = websocket.headers.get("sec-websocket-protocol", "") if subprotocol.startswith("bearer."): token = subprotocol[7:] # 去掉 "bearer." 前缀 else: # 其次从 Authorization header 获取 - auth_header = request.headers.get("Authorization", "") + auth_header = websocket.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] # 去掉 "Bearer " 前缀 else: # 向后兼容:从 query param 获取(即将废弃) - token = request.query_params.get("token", "") + token = websocket.query_params.get("token", "") # 步骤2: 检查 token 是否为空 if not token: diff --git a/backend/app/config.py b/backend/app/config.py index 41a73da..45c64ee 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -107,6 +107,25 @@ class Settings(BaseSettings): # 设备申请审批模板ID(在企微审批应用设置中获取) approval_template_device: str = "" + # ---------------------------------------------------------------------- + # v0.5.4 应急页身份检测配置 + # ---------------------------------------------------------------------- + # IT支持-咨询坐席 通讯录标签 ID(在企微管理后台 > 通讯录管理 > 标签管理 中查看) + # 配置后,应急页会通过此标签判断当前用户是否为坐席 + # 留空则降级到下面的硬编码名单 + wecom_agent_tag_id: str = "" + # 硬编码坐席 userid 列表(逗号分隔),作为标签检测的降级方案 + # 例:"zhangsan,lisi,wangwu"(生产环境建议用标签方案) + wecom_agent_userids: str = "" + + # ---------------------------------------------------------------------- + # v0.6.0 内容审核报警配置(占位,后续完善) + # ---------------------------------------------------------------------- + # 合规通知企微群机器人 webhook + content_audit_webhook: str = "" + # 主管接收报警的 userid(多个用逗号分隔) + content_audit_supervisor_userids: str = "" + # ---------------------------------------------------------------------- # Pydantic-settings 配置 # ---------------------------------------------------------------------- diff --git a/backend/app/main.py b/backend/app/main.py index a8205f0..a563805 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -290,14 +290,29 @@ async def _init_approval_links(db, ApprovalLink): return links = [ - ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1), - ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2), - ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3), - ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4), - ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5), - ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6), - ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7), - ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8), + # v0.5.2:一站式运维平台真实工单链接(域名 devops.dc.servyou-it.com,已实现企微免登录) + # v0.5.3 更新:去掉 "IT设备升级与硬件维修" (申请单冲突,后续移除) + ApprovalLink(category="IT", title="零信任(原VPN)账号申请", + url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7IT", + sort_order=1), + ApprovalLink(category="IT", title="活动与会议技术支持", + url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", + sort_order=2), + # sort_order=3 故意空缺:旧版本是"IT设备升级与硬件维修",已与一站式运维平台冲突,不再提供 + ApprovalLink(category="IT", title="员工IT支持与故障报修", + url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", + sort_order=4), + ApprovalLink(category="IT", title="终端设备网络准入申请", + url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", + sort_order=5), + ApprovalLink(category="IT", title="公共邮箱账号申请", + url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", + sort_order=6), + # HR / 行政 / 财务 占位(待后续接入真实流程) + ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=7), + ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=8), + ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=9), + ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=10), ] db.add_all(links) diff --git a/backend/app/services/security_comparison.py b/backend/app/services/security_comparison.py new file mode 100644 index 0000000..9a066de --- /dev/null +++ b/backend/app/services/security_comparison.py @@ -0,0 +1,149 @@ +""" +终端安全对比服务 - 火绒 vs 联软 + +功能: +1. 获取未安装火绒的电脑清单 +2. 定时任务推送 +3. 手动触发 + +依赖: +- 联软 LV7000: get_dev_all_info() +- 火绒企业版: list_terminals() + +比对逻辑:按主机名精确匹配 +""" + +from datetime import datetime +from typing import Optional +import logging + +from app.integrations.huorong.client import HuorongClient +from app.integrations.lianruan.client import LianruanClient + +logger = logging.getLogger(__name__) + + +class TerminalSecurityComparison: + """终端安全对比服务""" + + def __init__(self): + self.huorong = HuorongClient() + self.lianruan = LianruanClient() + + async def close(self): + """关闭连接""" + await self.huorong.close() + await self.lianruan.close() + + async def get_no_huorong_devices(self) -> list[dict]: + """获取未安装火绒的电脑清单(按主机名匹配)""" + logger.info("开始比对终端安全数据...") + + # 1. 获取联软所有设备 + lianruan_devices = await self._get_all_lianruan_devices() + logger.info(f"联软设备数: {len(lianruan_devices)}") + + # 2. 获取火绒所有终端 + huorong_devices = await self._get_all_huorong_devices() + logger.info(f"火绒终端数: {len(huorong_devices)}") + + # 3. 构建火绒主机名集合(转小写匹配) + huorong_hostnames = { + dev.get("hostname", "").lower() + for dev in huorong_devices + if dev.get("hostname") + } + + # 4. 比对:联软有,火绒无 = 未安装火绒 + no_huorong = [] + for dev in lianruan_devices: + # 联软用 strdevname (计算机名) + hostname = dev.get("strdevname", "").lower() + if hostname and hostname not in huorong_hostnames: + no_huorong.append({ + "hostname": dev.get("strdevname"), + "ip": dev.get("strip1"), # 联软IP字段 + "useraccount": dev.get("strusername"), # 用户名 + "dept": dev.get("strdeptname"), # 部门 + "last_login": dev.get("dtlastlogin"), + "osver": dev.get("strosver"), + "status": dev.get("strstatus"), + }) + + logger.info(f"未安装火绒设备数: {len(no_huorong)}") + return no_huorong + + async def _get_all_lianruan_devices(self) -> list[dict]: + """获取联软所有设备""" + # TODO: 分页获取全部设备 + result = await self.lianruan.get_dev_all_info() + if result and hasattr(result, 'devices') and result.devices: + # 转换为字典列表 + return [d.model_dump() if hasattr(d, 'model_dump') else d for d in result.devices] + return [] + + async def _get_all_huorong_devices(self) -> list[dict]: + """获取火绒所有终端(分页获取)""" + all_devices = [] + page = 1 + per_page = 200 + + while True: + result = await self.huorong.list_terminals(page=page, per_page=per_page) + clients = result.get("clients", []) + if not clients: + break + + for c in clients: + # 火绒字段:hostname, computer_name, ip_addr, local_ip + all_devices.append({ + "hostname": c.get("hostname") or c.get("computer_name"), + "ip": c.get("ip_addr") or c.get("local_ip"), + "status": c.get("stat"), + }) + + # 检查是否还有更多 + if len(clients) < per_page: + break + page += 1 + + return all_devices + + async def compare_summary(self) -> dict: + """比对汇总数据""" + lianruan_devices = await self._get_all_lianruan_devices() + huorong_devices = await self._get_all_huorong_devices() + no_huorong = await self.get_no_huorong_devices() + + return { + "lianruan_count": len(lianruan_devices), + "huorong_count": len(huorong_devices), + "no_huorong_count": len(no_huorong), + "compliance_rate": f"{len(huorong_devices)/len(lianruan_devices)*100:.1f}%" if lianruan_devices else "N/A", + "generated_at": datetime.now().isoformat(), + } + + +class ComparisonTaskConfig: + """定时任务配置""" + + def __init__(self): + self.tasks: dict[str, dict] = {} + + def add_task(self, task_id: str, config: dict): + self.tasks[task_id] = config + + def get_task(self, task_id: str) -> Optional[dict]: + return self.tasks.get(task_id) + + def list_tasks(self) -> list[dict]: + return [{"task_id": k, **v} for k, v in self.tasks.items()] + + def delete_task(self, task_id: str) -> bool: + if task_id in self.tasks: + del self.tasks[task_id] + return True + return False + + +comparison_task_config = ComparisonTaskConfig() diff --git a/backend/app/services/wecom_service.py b/backend/app/services/wecom_service.py index 359d83b..91e2714 100644 --- a/backend/app/services/wecom_service.py +++ b/backend/app/services/wecom_service.py @@ -463,6 +463,101 @@ class WecomService: logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}") raise Exception(f"获取部门成员网络错误: {e}") from e + # -------------------------------------------------------------------------- + # JS-SDK 票据 (v0.5.4:应急页身份检测用) + # -------------------------------------------------------------------------- + async def get_jsapi_ticket(self) -> str: + """获取企微 JS-SDK 票据 jsapi_ticket。 + + 对应企微API: + GET https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=TOKEN + + jsapi_ticket 用于计算 JS-SDK 签名(sha1),让前端 wx.config/wx.agentConfig 鉴权通过。 + 有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。 + + Returns: + str: jsapi_ticket 字符串 + + Raises: + Exception: 获取失败 + """ + cache_key = "wecom:jsapi_ticket" + + # 1. Redis 缓存 + if self.redis: + try: + cached = await self.redis.get(cache_key) + if cached: + logger.debug("从缓存获取 jsapi_ticket") + return cached.decode("utf-8") + except Exception as e: + logger.warning(f"Redis 读取 jsapi_ticket 失败(降级): {e}") + + # 2. 调用企微 API + access_token = await self.get_access_token() + url = f"https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}" + + try: + response = await self.client.get(url) + result = response.json() + + if result.get("errcode", 0) != 0: + logger.error( + f"获取 jsapi_ticket 失败: " + f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}" + ) + raise Exception(f"获取 jsapi_ticket 失败: {result.get('errmsg')}") + + ticket = result.get("ticket", "") + expires_in = result.get("expires_in", 7200) + + # 3. 缓存到 Redis(TTL = expires_in - 300s) + cache_ttl = max(expires_in - 300, 60) + if self.redis: + try: + await self.redis.setex(cache_key, cache_ttl, ticket) + except Exception as e: + logger.warning(f"Redis 写入 jsapi_ticket 失败(降级): {e}") + + logger.info(f"jsapi_ticket 获取成功,缓存 TTL={cache_ttl}秒") + return ticket + + except httpx.HTTPError as e: + logger.error(f"获取 jsapi_ticket 网络错误: {e}") + raise Exception(f"企微API网络错误: {e}") from e + + @staticmethod + def generate_jsapi_signature( + ticket: str, nonce_str: str, timestamp: int, url: str + ) -> str: + """生成 JS-SDK 签名(sha1)。 + + 对应企微JS-SDK签名算法: + 1. 拼接:jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url} + 2. sha1(拼接字符串) + + 注意: + - url 不含 # 及其后面部分 + - url 不含 ? + - url 是前端调用 wx.config 的页面 URL + + Args: + ticket: jsapi_ticket + nonce_str: 随机字符串(前端生成,16位) + timestamp: 当前时间戳(秒) + url: 当前页面 URL(不含 # 后面) + + Returns: + str: sha1 签名字符串(40 字符) + """ + import hashlib + + # 拼接签名字符串 + raw = f"jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}" + # sha1 哈希 + signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() + return signature + # -------------------------------------------------------------------------- # 上传临时素材 # -------------------------------------------------------------------------- diff --git a/deploy-h5.tar.bak-Mock b/deploy-h5.tar.bak-Mock new file mode 100644 index 0000000..61d0a15 Binary files /dev/null and b/deploy-h5.tar.bak-Mock differ diff --git a/deploy-server/README.md b/deploy-server/README.md index 51ec67f..aead9e2 100644 --- a/deploy-server/README.md +++ b/deploy-server/README.md @@ -1,6 +1,6 @@ # 智能IT支持服务台 — 新服务器部署手册 -> **目标服务器**:`10.80.0.136`(公司内网) +> **目标服务器**:`10.90.5.110`(公司内网,**2026-06-15 起替代 10.80.0.136**) > **域名**:`itsupport.servyou.com.cn` > **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`,OTP 动态口令认证) > **Docker**:已安装 @@ -12,7 +12,7 @@ | 条件 | 状态 | 验证命令 | |------|------|---------| -| Linux 服务器 10.80.0.136 | ✅ 已确认 | — | +| Linux 服务器 10.90.5.110(替代旧 10.80.0.136) | ✅ 已确认 | 2026-06-15 起使用 | | Docker 已安装 | ✅ 已确认 | `docker --version` | | Docker Compose V2 | 待确认 | `docker compose version` | | 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` | @@ -29,17 +29,22 @@ ### 2.2 连接方式 -```bash -# 方式一:ssh -J 一步跳转(推荐) -# -J 指定跳板机,ssh 会自动帮你跳转 -# 堡垒机端口 2222,需要输入 OTP 动态口令 -ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 +**PuTTY 客户端(用户实际使用)**: +- 打开 PuTTY +- Host Name(IP 地址):`10.212.189.210` +- Port:`2222` +- Connection type:SSH +- Saved Sessions:起名(如 `wecom-bastion`)→ Save +- 点 Open +- 用户 `sxn` + 密码 +- **堡垒机内再跳目标机**: + ```bash + ssh sxn@10.90.5.110 + ``` -# 方式二:先登录堡垒机,再手动跳转 -ssh -p 2222 sxn@10.212.189.210 -# 输入 OTP 动态口令 +> **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15) # 登录成功后: -ssh sxn@10.80.0.136 +ssh sxn@10.90.5.110 ``` ### 2.3 配置 SSH 快捷方式(推荐) @@ -55,7 +60,7 @@ Host bastion # 智能IT支持服务台服务器 Host itdesk - HostName 10.80.0.136 + HostName 10.90.5.110 User sxn ProxyJump bastion ``` @@ -78,7 +83,7 @@ scp file itdesk:/opt/ # 文件传输也会自动走堡垒机 # 上传单个文件 scp -o "ProxyJump=sxn@10.212.189.210:2222" \ it-smart-desk-server-deploy.zip \ - sxn@10.80.0.136:/opt/ + sxn@10.90.5.110:/opt/ # 如果已配置 ~/.ssh/config: scp it-smart-desk-server-deploy.zip itdesk:/opt/ @@ -96,7 +101,7 @@ scp -P 2222 it-smart-desk-server-deploy.zip sxn@10.212.189.210:/tmp/ ssh -p 2222 sxn@10.212.189.210 # 步骤3:从堡垒机传到目标服务器 -scp /tmp/it-smart-desk-server-deploy.zip sxn@10.80.0.136:/opt/ +scp /tmp/it-smart-desk-server-deploy.zip sxn@10.90.5.110:/opt/ ``` --- @@ -133,17 +138,18 @@ npm install && npm run build # 在开发机上执行 scp -o "ProxyJump=sxn@10.212.189.210:2222" \ it-smart-desk-server-deploy.zip \ - sxn@10.80.0.136:/tmp/ + sxn@10.90.5.110:/tmp/ ``` > 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限 -### 步骤 3:SSH 登录服务器并解压 +### 步骤 3:登录服务器并解压 + +**PuTTY 登录**(见 §2.2): +- Host:`10.212.189.210`,Port:`2222`,SSH +- 堡垒机内再 `ssh sxn@10.90.5.110` ```bash -# 登录目标服务器 -ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 - # 切换 root(普通用户对 /opt 无写权限) sudo -i @@ -237,7 +243,7 @@ docker compose logs --tail 50 postgres 需要联系公司 IT 运维,在公司 DNS 上添加 A 记录: ``` -itsupport.servyou.com.cn A 10.80.0.136 +itsupport.servyou.com.cn A 10.90.5.110 ``` **DNS 未生效前**,可以通过本地 hosts 文件测试: @@ -246,7 +252,7 @@ itsupport.servyou.com.cn A 10.80.0.136 # Windows: C:\Windows\System32\drivers\etc\hosts # macOS/Linux: /etc/hosts # 添加一行: -10.80.0.136 itsupport.servyou.com.cn +10.90.5.110 itsupport.servyou.com.cn ``` > 注意:修改 hosts 文件后,浏览器可能有 DNS 缓存。Chrome 可访问 `chrome://net-internals/#dns` 清除缓存,或用无痕窗口测试。 @@ -324,11 +330,11 @@ cd frontend-agent && npm run build # 2. 上传到服务器(通过堡垒机) scp -o "ProxyJump=sxn@10.212.189.210:2222" \ -r frontend-h5/dist/ \ - sxn@10.80.0.136:/opt/wecom-it-desk/frontend-h5/dist/ + sxn@10.90.5.110:/opt/wecom-it-desk/frontend-h5/dist/ scp -o "ProxyJump=sxn@10.212.189.210:2222" \ -r frontend-agent/dist/ \ - sxn@10.80.0.136:/opt/wecom-it-desk/frontend-agent/dist/ + sxn@10.90.5.110:/opt/wecom-it-desk/frontend-agent/dist/ # 3. 重载 Nginx(不需要重启整个服务) ssh itdesk # 如果已配置 SSH 快捷方式 @@ -344,7 +350,7 @@ docker exec wecom_it_nginx nginx -s reload # 1. 上传新代码到服务器 scp -o "ProxyJump=sxn@10.212.189.210:2222" \ -r backend/ \ - sxn@10.80.0.136:/opt/wecom-it-desk/backend/ + sxn@10.90.5.110:/opt/wecom-it-desk/backend/ # 2. 重新构建并启动 ssh itdesk @@ -400,8 +406,8 @@ docker exec wecom_it_nginx cat /usr/share/nginx/html/itagent/index.html | grep / nslookup itsupport.servyou.com.cn # 如果 DNS 未配置,临时用 IP 直接访问 -curl http://10.80.0.136/itdesk/ -curl http://10.80.0.136/api/health +curl http://10.90.5.110/itdesk/ +curl http://10.90.5.110/api/health ``` ### Mock 登录返回 401 @@ -428,7 +434,7 @@ curl -X POST http://localhost/api/h5/mock-login \ ### 方式一:公司统一 SSL 终端(推荐) ``` -客户端 → HTTPS → 公司SSL终端(F5/网关) → HTTP → 10.80.0.136:80 +客户端 → HTTPS → 公司SSL终端(F5/网关,公网 115.236.188.3) → HTTP → 10.90.5.110:80 ``` 不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。 @@ -441,7 +447,7 @@ curl -X POST http://localhost/api/h5/mock-login \ ## 十一、与 NAS 部署的差异 -| 维度 | NAS 部署(10.80.0.136 旧) | 新服务器部署(10.80.0.136 新) | +| 维度 | NAS 部署(10.80.0.136,已下线) | 新服务器部署(10.90.5.110,2026-06-15 起) | |------|---------------------------|-------------------------------| | 容器数量 | 5个(含 cloudflared) | 4个(无 cloudflared) | | 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 | diff --git a/deploy-server/nginx/nginx.conf b/deploy-server/nginx/nginx.conf index 23ae517..c59c3ee 100644 --- a/deploy-server/nginx/nginx.conf +++ b/deploy-server/nginx/nginx.conf @@ -27,6 +27,21 @@ http { access_log /var/log/nginx/access.log main; error_log /var/log/nginx/error.log warn; + # ------------------------------------------------------------------ + # 真实 IP 还原(2026-06-15 v0.5.1 修复) + # ------------------------------------------------------------------ + # 问题:公司有 WAF/堡垒机/反向代理,nginx 看到的 $remote_addr + # 是代理 IP(不在白名单),allow/deny 因此误判 403 + # 修法:信任内网段代理透传的 X-Forwarded-For 头,用真实 IP 做白名单 + # 注意:set_real_ip_from 是"我信任的代理",不是"我允许的客户端" + # 必须精确,否则攻击者可伪造 X-Forwarded-For 绕过白名单 + set_real_ip_from 10.0.0.0/8; # 内网 A 类(代理/WAF 出口) + set_real_ip_from 172.16.0.0/12; # 内网 B 类 + set_real_ip_from 192.168.0.0/16; # 内网 C 类 + set_real_ip_from 10.212.0.0/16; # VPN 网段 + real_ip_header X-Forwarded-For; # 从 X-Forwarded-For 取最后一个非信任 IP + real_ip_recursive on; # 递归剥离已信任代理 IP + # ------------------------------------------------------------------ # 基础配置 # ------------------------------------------------------------------ @@ -60,29 +75,58 @@ http { # 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80 # 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径 # ================================================================= + # HTTP — 80 端口强制 301 跳 HTTPS + # ================================================================= server { listen 80; server_name itsupport.servyou.com.cn; + # ACME http-01 验证用(如果以后用 Let's Encrypt) + location /.well-known/acme-challenge/ { + root /usr/share/nginx/html; + } + + # 其他全部 301 跳 https + location / { + return 301 https://$host$request_uri; + } + } + + # ================================================================= + # HTTPS — 443 端口(主服务) + # ================================================================= + server { + listen 443 ssl; + http2 on; + server_name itsupport.servyou.com.cn; + + # SSL 证书(通配符 *.servyou.com.cn,fullchain 含 leaf+intermediate+root) + ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt; + ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + # ------------------------------------------------------------------ # 安全头 # ------------------------------------------------------------------ - # 基础安全头 add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - + # CSP 收紧: 去掉 unsafe-inline(生产不需要,只有 dev HMR 需要) add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*; font-src 'self' data:;" always; - + # 隐私与跨域控制 add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - + # 隐藏服务器版本 server_tokens off; @@ -150,7 +194,7 @@ http { allow 10.212.0.0/16; deny all; - proxy_pass http://backend_api/; + proxy_pass http://backend_api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -195,29 +239,10 @@ http { # 此路径已包含在 /api/ 的代理规则中,无需单独配置 # ------------------------------------------------------------------ - # 默认路径 — 重定向到 H5 员工端 + # 默认路径 — 重定向到统一入口 # ------------------------------------------------------------------ location = / { - return 302 /itdesk/; + return 302 /itportal/; } } - - # ================================================================= - # HTTPS 配置(按需启用) - # ================================================================= - # 如果需要本机直接提供 HTTPS(不走公司统一 SSL 终端), - # 取消下方注释并配置 SSL 证书路径 - # - # server { - # listen 443 ssl; - # server_name itsupport.servyou.com.cn; - # - # ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt; - # ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key; - # ssl_protocols TLSv1.2 TLSv1.3; - # ssl_ciphers HIGH:!aNULL:!MD5; - # - # # 其余 location 配置与上方 HTTP server 相同 - # ... - # } } diff --git a/deploy-server/package.py b/deploy-server/package.py index 5b664d4..52adcb0 100644 --- a/deploy-server/package.py +++ b/deploy-server/package.py @@ -40,6 +40,8 @@ INCLUDE_MAP = { "deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf", "frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist", "frontend-agent/dist": f"{PACKAGE_PREFIX}/frontend-agent/dist", + "frontend-portal/dist": f"{PACKAGE_PREFIX}/frontend-portal/dist", + "frontend-admin/dist": f"{PACKAGE_PREFIX}/frontend-admin/dist", "backend": f"{PACKAGE_PREFIX}/backend", } @@ -75,6 +77,9 @@ def run_cmd(cmd: str, cwd: Path | None = None) -> bool: def should_exclude(path: Path) -> bool: """判断文件/目录是否应排除""" + # 路径中任何一段是 uploads 目录就排除(隐私 P0:真实用户上传文件不进部署包) + if "uploads" in path.parts: + return True name = path.name if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}: return True @@ -121,6 +126,32 @@ def build_frontends(): sys.exit(1) print(" ✅ 坐席工作台构建完成") + # 统一入口 Portal + portal_dir = PROJECT_ROOT / "frontend-portal" + if (portal_dir / "package.json").exists(): + print("构建统一入口 Portal...") + if not run_cmd("npm install --quiet", cwd=portal_dir): + print(" ⚠ npm install 失败,尝试继续...") + if not run_cmd("npm run build", cwd=portal_dir): + print(" ❌ Portal 端构建失败!") + sys.exit(1) + print(" ✅ Portal 端构建完成") + else: + print(" ⏭ Portal 端未实现,跳过") + + # 管理后台 Admin + admin_dir = PROJECT_ROOT / "frontend-admin" + if (admin_dir / "package.json").exists(): + print("构建管理后台 Admin...") + if not run_cmd("npm install --quiet", cwd=admin_dir): + print(" ⚠ npm install 失败,尝试继续...") + if not run_cmd("npm run build", cwd=admin_dir): + print(" ❌ Admin 端构建失败!") + sys.exit(1) + print(" ✅ Admin 端构建完成") + else: + print(" ⏭ Admin 端未实现,跳过") + def create_package(): """创建部署包 zip""" @@ -181,13 +212,13 @@ def main(): print(" 后续步骤:") print("=" * 50) print(f""" - 1. 上传部署包到服务器(通过堡垒机): - scp -o "ProxyJump=sxn@10.212.189.210:2222" \\ - {ZIP_FILENAME} \\ - sxn@10.80.0.136:/tmp/ + 1. 上传部署包到服务器(通过堡垒机 / PuTTY PSCP): + pscp -load wecom-bastion {ZIP_FILENAME} sxn@10.90.5.110:/tmp/ + # 或堡垒机内 scp: + # scp {ZIP_FILENAME} sxn@10.90.5.110:/tmp/ - 2. SSH 登录服务器(通过堡垒机): - ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 + 2. PuTTY 登录服务器: + - Host 10.212.189.210 Port 2222 → 用户 sxn → 堡垒机内 ssh sxn@10.90.5.110 3. 在服务器上执行: sudo cp /tmp/{ZIP_FILENAME} /opt/ @@ -201,7 +232,7 @@ def main(): ./deploy.sh 4. 配置 DNS(联系 IT 运维): - itsupport.servyou.com.cn → 10.80.0.136 + itsupport.servyou.com.cn → 10.90.5.110 5. 浏览器验证: http://itsupport.servyou.com.cn/itdesk/ diff --git a/diagnose-500.b64 b/diagnose-500.b64 new file mode 100644 index 0000000..f5cc51b --- /dev/null +++ b/diagnose-500.b64 @@ -0,0 +1 @@ +IyEvYmluL2Jhc2gKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojIC9pdGRlc2svIDUwMCDplJnor6/or4rmlq3ohJrmnKwKIyDlnKjnlJ/kuqfmnI3liqHlmaggMTAuODAuMC4xMzYg5LiK6LeRKFNTSCDnmbvlvZXlkI4pOgojICAgY2QgL29wdC93ZWNvbS1pdC1kZXNrCiMgICBiYXNoIGRpYWdub3NlLTUwMC5zaCA+IC90bXAvZGlhZy5sb2cgMj4mMQojICAgY2F0IC90bXAvZGlhZy5sb2cKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQoKZWNobyAiPT09PT09PT09PSAxLiDlrrnlmajnirbmgIEgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgcHMKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSAyLiAvb3B0L3dlY29tLWl0LWRlc2sg55uu5b2V57uT5p6EID09PT09PT09PT0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gZnJvbnRlbmQtaDUvZGlzdCAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSBmcm9udGVuZC1oNS9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC9hc3NldHMvIDI+JjEgfCBoZWFkIC0xMAplY2hvICItLS0gZnJvbnRlbmQtYWdlbnQvZGlzdC9hc3NldHMgLS0tIgpscyAtbGEgL29wdC93ZWNvbS1pdC1kZXNrL2Zyb250ZW5kLWFnZW50L2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLXBvcnRhbC9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtcG9ydGFsL2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLWFkbWluL2Rpc3QvYXNzZXRzIC0tLSIKbHMgLWxhIC9vcHQvd2Vjb20taXQtZGVzay9mcm9udGVuZC1hZG1pbi9kaXN0L2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTEwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gMy4gbmdpbngg5a655Zmo5YaF5paH5Lu25qOA5p+lID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrIC0tLSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCBscyAtbGEgL3Vzci9zaGFyZS9uZ2lueC9odG1sL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrL2Fzc2V0cyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9pdGRlc2svYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIC91c3Ivc2hhcmUvbmdpbngvc3NsLyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC9ldGMvbmdpbngvc3NsLyAyPiYxIHwgaGVhZCAtMTAKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSA0LiBuZ2lueCDphY3nva7lrp7pmYXnlJ/mlYjniYjmnKwo5aS06YOoIDUwIOihjCk9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGNhdCAvZXRjL25naW54L25naW54LmNvbmYgMj4mMSB8IGhlYWQgLTUwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNS4gbmdpbngg5a655Zmo56uv5Y+j55uR5ZCsID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbmV0c3RhdCAtdGxucCAyPiYxIHwgaGVhZCAtMTAKZWNobyAiKOayoSBuZXRzdGF0IOeUqCBzczopIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHNzIC10bG5wIDI+JjEgfCBoZWFkIC0xMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDYuIOebtOaOpSBjdXJsIOa1i+ivleWQhOi3r+W+hCA9PT09PT09PT09IgplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWGhSkgLS0tIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdC9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWkluS4u+acuiA0NDMpIC0tLSIKY3VybCAta3NJIGh0dHBzOi8vbG9jYWxob3N0OjQ0My9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0cG9ydGFsLyAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRwb3J0YWwvIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay9hc3NldHMvICjmjqIgNDA0KSAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRkZXNrL2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTIwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNy4g5Li75py65a6e6ZmFIFVSTCDln5/lkI0gPT09PT09PT09PSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0cG9ydGFsLyAyPiYxIHwgaGVhZCAtMjAKZWNobyAiLS0tIgpjdXJsIC1rc0kgaHR0cHM6Ly9pdHN1cHBvcnQuc2VydnlvdS5jb20uY24vaXRhZ2VudC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0YWRtaW4vIDI+JjEgfCBoZWFkIC0yMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDguIG5naW54IGFjY2VzcyBsb2cg5pyA6L+RIDMwIOihjCjmib4gNTAwIOivt+axgik9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHRhaWwgLTMwIC92YXIvbG9nL25naW54L2FjY2Vzcy5sb2cgMj4mMQplY2hvICIiCmVjaG8gIj09PT09PT09PT0gOS4gbmdpbnggZXJyb3IgbG9nIOacgOi/kSAzMCDooYwgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCB0YWlsIC0zMCAvdmFyL2xvZy9uZ2lueC9lcnJvci5sb2cgMj4mMQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDEwLiBiYWNrZW5kIOWuueWZqOWBpeW6tyA9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBwcyBiYWNrZW5kCmVjaG8gIi0tLSBiYWNrZW5kIGhlYWx0aCBlbmRwb2ludCAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgYmFja2VuZCBjdXJsIC1rcyBodHRwOi8vbG9jYWxob3N0OjgwMDAvYXBpL2hlYWx0aCAyPiYxIHwgaGVhZCAtNQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDExLiDnnIvkuIDkuIvlkI7nq6/orr/pl64gL2FwaS9oNS9tZSAoSDUg5ZCv5Yqo5pe25Lya6LCDKT09PT09PT09PT0iCmVjaG8gIi0tLSAvYXBpL2g1L21lIOaXoCB0b2tlbiAtLS0iCmN1cmwgLWtzIC1pIC1YIEdFVCBodHRwczovL2l0c3VwcG9ydC5zZXJ2eW91LmNvbS5jbi9hcGkvaDUvbWUgMj4mMSB8IGhlYWQgLTEwCg== \ No newline at end of file diff --git a/diagnose-500.sh b/diagnose-500.sh new file mode 100644 index 0000000..6e25467 --- /dev/null +++ b/diagnose-500.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# ============================================================================= +# /itdesk/ 500 错误诊断脚本 +# 在生产服务器 10.90.5.110 上跑(PuTTY 登录后): +# cd /opt/wecom-it-desk +# bash diagnose-500.sh > /tmp/diag.log 2>&1 +# cat /tmp/diag.log +# ============================================================================= + +echo "========== 1. 容器状态 ==========" +docker compose ps + +echo "" +echo "========== 2. /opt/wecom-it-desk 目录结构 ==========" +ls -la /opt/wecom-it-desk/ 2>&1 | head -20 +echo "--- frontend-h5/dist ---" +ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10 +echo "--- frontend-h5/dist/assets ---" +ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10 +echo "--- frontend-agent/dist/assets ---" +ls -la /opt/wecom-it-desk/frontend-agent/dist/assets/ 2>&1 | head -10 +echo "--- frontend-portal/dist/assets ---" +ls -la /opt/wecom-it-desk/frontend-portal/dist/assets/ 2>&1 | head -10 +echo "--- frontend-admin/dist/assets ---" +ls -la /opt/wecom-it-desk/frontend-admin/dist/assets/ 2>&1 | head -10 + +echo "" +echo "========== 3. nginx 容器内文件检查 ==========" +docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1 | head -20 +echo "--- /usr/share/nginx/html/itdesk ---" +docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10 +echo "--- /usr/share/nginx/html/itdesk/assets ---" +docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10 +echo "--- /usr/share/nginx/ssl/ ---" +docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10 + +echo "" +echo "========== 4. nginx 配置实际生效版本(头部 50 行)==========" +docker compose exec nginx cat /etc/nginx/nginx.conf 2>&1 | head -50 + +echo "" +echo "========== 5. nginx 容器端口监听 ==========" +docker compose exec nginx netstat -tlnp 2>&1 | head -10 +echo "(没 netstat 用 ss:)" +docker compose exec nginx ss -tlnp 2>&1 | head -10 + +echo "" +echo "========== 6. 直接 curl 测试各路径 ==========" +echo "--- /itdesk/ (容器内) ---" +docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -20 +echo "--- /itdesk/ (容器外主机 443) ---" +curl -ksI https://localhost:443/itdesk/ 2>&1 | head -20 +echo "--- /itportal/ ---" +curl -ksI https://localhost:443/itportal/ 2>&1 | head -20 +echo "--- /itdesk/assets/ (探 404) ---" +curl -ksI https://localhost:443/itdesk/assets/ 2>&1 | head -20 + +echo "" +echo "========== 7. 主机实际 URL 域名 ==========" +curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20 +echo "---" +curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -20 +echo "---" +curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -20 +echo "---" +curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -20 + +echo "" +echo "========== 8. nginx access log 最近 30 行(找 500 请求)==========" +docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1 +echo "" +echo "========== 9. nginx error log 最近 30 行 ==========" +docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1 + +echo "" +echo "========== 10. backend 容器健康 ==========" +docker compose ps backend +echo "--- backend health endpoint ---" +docker compose exec backend curl -ks http://localhost:8000/api/health 2>&1 | head -5 + +echo "" +echo "========== 11. 看一下后端访问 /api/h5/me (H5 启动时会调)==========" +echo "--- /api/h5/me 无 token ---" +curl -ks -i -X GET https://itsupport.servyou.com.cn/api/h5/me 2>&1 | head -10 diff --git a/fix-prod.b64 b/fix-prod.b64 new file mode 100644 index 0000000..4a0c0d8 --- /dev/null +++ b/fix-prod.b64 @@ -0,0 +1 @@ +IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo= \ No newline at end of file diff --git a/fix-prod.s1 b/fix-prod.s1 new file mode 100644 index 0000000..81fd801 --- /dev/null +++ b/fix-prod.s1 @@ -0,0 +1 @@ +IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5 \ No newline at end of file diff --git a/fix-prod.s2 b/fix-prod.s2 new file mode 100644 index 0000000..90571d2 --- /dev/null +++ b/fix-prod.s2 @@ -0,0 +1 @@ +ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo= \ No newline at end of file diff --git a/fix-prod.sh b/fix-prod.sh new file mode 100644 index 0000000..31e4bfc --- /dev/null +++ b/fix-prod.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set +e # collect everything, don't bail + +echo '############ STEP 1: Locate project directory ############' +cd /opt/wecom-it-desk 2>&1 +echo "Current dir: $(pwd)" +ls -la docker-compose.yml 2>&1 +echo '' + +echo '############ STEP 2: Diagnose (READ-ONLY) ############' +echo '--- All wecom_it_ containers ---' +docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES" +echo '' +echo '--- Disk space ---' +df -h /opt 2>&1 +echo '' +echo '--- backend last 60 log lines ---' +docker logs wecom_it_backend --tail 60 2>&1 +echo '' +echo '--- backend internal health check ---' +docker exec wecom_it_backend curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health 2>&1 +echo '' + +echo '############ STEP 3: Restart from correct directory ############' +cd /opt/wecom-it-desk +docker compose up -d 2>&1 +echo '' +echo 'Waiting 15s for services to stabilize...' +sleep 15 +echo '' +echo '--- Containers after restart ---' +docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES" +echo '' + +echo '############ STEP 4: End-to-end verification ############' +echo '--- backend /health ---' +curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health +echo '' +echo '--- nginx routes (expect 200/301/302) ---' +for path in / /itagent/ /ith5/ /itadmin/; do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost${path}") + echo " $path -> HTTP $code" +done +echo '' +echo '############ DONE ############' +echo 'Paste ALL output above back to Claude for diagnosis' diff --git a/frontend-admin/package-lock.json b/frontend-admin/package-lock.json index 9f18a10..5928341 100644 --- a/frontend-admin/package-lock.json +++ b/frontend-admin/package-lock.json @@ -23,6 +23,10 @@ "typescript": "^5.5.0", "vite": "^5.3.0", "vue-tsc": "^2.0.0" + }, + "engines": { + "node": ">=20.0.0 <21.0.0", + "pnpm": ">=9.0.0" } }, "node_modules/@alloc/quick-lru": { diff --git a/frontend-admin/src/types/index.ts b/frontend-admin/src/types/index.ts index 0f33f3a..44711ee 100644 --- a/frontend-admin/src/types/index.ts +++ b/frontend-admin/src/types/index.ts @@ -37,6 +37,9 @@ export interface Agent { today_resolved?: number created_at: string updated_at: string + // OTP 二次验证(P0-#5 坐席本地密码配套) + otp_enabled?: number // 0/1, 是否启用 OTP + otp_secret?: string // OTP 密钥(敏感) } /** 坐席状态 */ @@ -340,6 +343,7 @@ export interface HuorongTerminalDetail { version: string // 火绒客户端版本 is_online: boolean // 在线状态 last_connect_time?: number // 最后连接时间(Unix时间戳) + group_id?: number | string // 分组ID(_info2 可能返回) // 硬件信息(可选,_info2 返回) cpu?: string memory?: string diff --git a/frontend-admin/src/views/TerminalSecurity.vue b/frontend-admin/src/views/TerminalSecurity.vue index e074359..9f0925c 100644 --- a/frontend-admin/src/views/TerminalSecurity.vue +++ b/frontend-admin/src/views/TerminalSecurity.vue @@ -417,7 +417,7 @@ const tabs = [ // ========================================================================== // 状态 // ========================================================================== -const activeTab = ref<'terminals' | 'leaks' | 'virus'>('terminals') +const activeTab = ref('terminals') const loading = ref(false) const connectionError = ref('') @@ -675,7 +675,7 @@ function loadDemoVirusEvents(): void { // ========================================================================== // 标签页切换 // ========================================================================== -function switchTab(tab: 'terminals' | 'leaks' | 'virus'): void { +function switchTab(tab: string): void { activeTab.value = tab currentPage.value = 1 searchQuery.value = '' diff --git a/frontend-agent/package-lock.json b/frontend-agent/package-lock.json index d571085..bbd4282 100644 --- a/frontend-agent/package-lock.json +++ b/frontend-agent/package-lock.json @@ -22,6 +22,10 @@ "typescript": "^5.5.0", "vite": "^5.3.0", "vue-tsc": "^2.0.0" + }, + "engines": { + "node": ">=20.0.0 <21.0.0", + "pnpm": ">=9.0.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/frontend-agent/src/router/index.ts b/frontend-agent/src/router/index.ts index 498c28e..6b7f03a 100644 --- a/frontend-agent/src/router/index.ts +++ b/frontend-agent/src/router/index.ts @@ -5,7 +5,8 @@ // 包括: // 1. /login → 登录页(简单的用户名密码表单) // 2. /workspace → 坐席工作台(需要认证) -// 3. / → 重定向到 /workspace +// 3. /agent-preview → v0.5.4 BC/DR 应急页坐席视图(公开) +// 4. / → 重定向到 /workspace // ============================================================================= import { createRouter, createWebHistory } from 'vue-router' @@ -33,6 +34,13 @@ const routes = [ component: () => import('@/views/Workspace.vue'), meta: { title: '坐席工作台', requiresAuth: true }, }, + // v0.5.4 BC/DR 应急页坐席视图 + { + path: '/agent-preview', + name: 'AgentPreview', + component: () => import('@/views/AgentPreviewView.vue'), + meta: { title: '坐席助手', requiresAuth: false }, + }, ] // -------------------------------------------------------------------------- @@ -74,6 +82,13 @@ router.beforeEach((to, _from, next) => { const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证 const token = localStorage.getItem('agent_token') + // v0.5.4 BC/DR 应急页(agent-preview)不需 Portal token + // 它的鉴权由 /emergency 入口的企微 JS-SDK 完成 + if (to.name === 'AgentPreview') { + next() + return + } + if (requiresAuth && !token) { // 需要认证但没有 token,跳转到 Portal 统一入口 window.location.href = '/itportal/' diff --git a/frontend-agent/src/views/AgentPreviewView.vue b/frontend-agent/src/views/AgentPreviewView.vue new file mode 100644 index 0000000..569979e --- /dev/null +++ b/frontend-agent/src/views/AgentPreviewView.vue @@ -0,0 +1,207 @@ + + + + + + + diff --git a/frontend-agent/src/views/Workspace.vue b/frontend-agent/src/views/Workspace.vue index d203f87..7724d17 100644 --- a/frontend-agent/src/views/Workspace.vue +++ b/frontend-agent/src/views/Workspace.vue @@ -186,6 +186,16 @@ function onRightResizeEnd(): void { // ============================================================================ onMounted(async () => { + // 修复 v0.5.1: 企微点坐席直接打开 /itagent/ 时,URL 没 ?token= + // 路由守卫虽然会跳到 /itportal/,但在这之前 axios 已经发了请求 → 弹 401 + // 这里在 onMounted 第一行主动检查 token,没 token 立刻跳 portal,避免 401 弹错 + const hasAgentToken = localStorage.getItem('agent_token') + const hasPortalToken = localStorage.getItem('portal_token') + if (!hasAgentToken && !hasPortalToken) { + window.location.href = '/itportal/' + return + } + // 初始化主题 themeStore.initTheme() // 初始化坐席信息 diff --git a/frontend-h5/components.d.ts b/frontend-h5/components.d.ts index 8a8c75e..2e74b88 100644 --- a/frontend-h5/components.d.ts +++ b/frontend-h5/components.d.ts @@ -32,6 +32,7 @@ declare module 'vue' { VanEmpty: typeof import('vant/es')['Empty'] VanField: typeof import('vant/es')['Field'] VanIcon: typeof import('vant/es')['Icon'] + VanLoading: typeof import('vant/es')['Loading'] VanPopup: typeof import('vant/es')['Popup'] } } diff --git a/frontend-h5/index.html b/frontend-h5/index.html index 1f8ef76..b344a08 100644 --- a/frontend-h5/index.html +++ b/frontend-h5/index.html @@ -8,10 +8,50 @@ 智能IT支持服务台 + +
+ +
+ +
智能IT支持服务台
+
正在加载...
+
+
diff --git a/frontend-h5/package-lock.json b/frontend-h5/package-lock.json index 9465e8b..0dfb25a 100644 --- a/frontend-h5/package-lock.json +++ b/frontend-h5/package-lock.json @@ -23,6 +23,10 @@ "unplugin-vue-components": "^0.27.0", "vite": "^5.3.0", "vue-tsc": "^2.0.0" + }, + "engines": { + "node": ">=20.0.0 <21.0.0", + "pnpm": ">=9.0.0" } }, "node_modules/@antfu/utils": { diff --git a/frontend-h5/src/App.vue b/frontend-h5/src/App.vue index 08cb9e9..890f222 100644 --- a/frontend-h5/src/App.vue +++ b/frontend-h5/src/App.vue @@ -8,23 +8,58 @@ diff --git a/frontend-h5/src/components/chat/CallAgentModal.vue b/frontend-h5/src/components/chat/CallAgentModal.vue index cb26570..4f9e00b 100644 --- a/frontend-h5/src/components/chat/CallAgentModal.vue +++ b/frontend-h5/src/components/chat/CallAgentModal.vue @@ -28,7 +28,7 @@
🔔 -

摇传菜铃呼叫人工坐席...

+

呼叫人工坐席帮助...

diff --git a/frontend-h5/src/main.ts b/frontend-h5/src/main.ts index 4d23dac..a5fd2e8 100644 --- a/frontend-h5/src/main.ts +++ b/frontend-h5/src/main.ts @@ -34,10 +34,26 @@ app.use(router) // 不需要在这里手动注册,减小打包体积 // -------------------------------------------------------------------------- -// 挂载应用到 DOM +// v0.5.2:挂载应用 + 显式关闭骨架屏(避免 :empty 选择器失效) // -------------------------------------------------------------------------- +// 1. 记录挂载开始时间(用于最小显示时间) +const mountStart = Date.now() +// 2. 最小显示时间 500ms(防止 Vue 太快挂载导致骨架屏"闪一下看不见") +const MIN_SKELETON_DISPLAY_MS = 500 + app.mount('#app') +// 3. 挂载完成后,主动给 body 加 .app-loaded 类名,触发 CSS 隐藏骨架屏 +// 比之前用 :empty 选择器更可靠(尤其在 Vue mount < 100ms 的情况下) +const elapsed = Date.now() - mountStart +if (elapsed >= MIN_SKELETON_DISPLAY_MS) { + document.body.classList.add('app-loaded') +} else { + setTimeout(() => { + document.body.classList.add('app-loaded') + }, MIN_SKELETON_DISPLAY_MS - elapsed) +} + // -------------------------------------------------------------------------- // 企微 OAuth2 授权检查(已迁移至路由守卫 router/index.ts) // -------------------------------------------------------------------------- diff --git a/frontend-h5/src/router/index.ts b/frontend-h5/src/router/index.ts index b5052b9..1b3f084 100644 --- a/frontend-h5/src/router/index.ts +++ b/frontend-h5/src/router/index.ts @@ -10,6 +10,14 @@ import { createRouter, createWebHistory } from 'vue-router' +// v0.5.2 优化:ChatView 是 99% 用户唯一访问的页面,改用静态 import +// 之前用 () => import() 懒加载,首次访问要二次下载 301KB 的 ChatView chunk +// → 表现为白屏→突然全显示 +import ChatView from '@/views/ChatView.vue' +// v0.5.4 BC/DR 应急页(身份检测 + H5 右栏) +import EmergencyDispatcher from '@/views/EmergencyDispatcher.vue' +import H5PreviewView from '@/views/H5PreviewView.vue' + // -------------------------------------------------------------------------- // 企微环境检测工具函数 // -------------------------------------------------------------------------- @@ -33,8 +41,8 @@ const routes = [ { path: '/', name: 'ChatView', - // 懒加载:首次访问时才加载组件,减小首屏体积 - component: () => import('@/views/ChatView.vue'), + // v0.5.2:首页静态引入,避免 301KB chunk 二次下载导致白屏 + component: ChatView, meta: { title: 'IT智能服务台', requiresAuth: true }, }, { @@ -49,6 +57,19 @@ const routes = [ component: () => import('@/views/WeworkOnly.vue'), meta: { title: '请在企业微信中打开', requiresAuth: false }, }, + // v0.5.4 BC/DR 应急页(身份检测 + 员工右栏视图) + { + path: '/emergency', + name: 'EmergencyDispatcher', + component: EmergencyDispatcher, + meta: { title: '应急身份检测', requiresAuth: false }, + }, + { + path: '/h5-preview', + name: 'H5Preview', + component: H5PreviewView, + meta: { title: '员工自助', requiresAuth: false }, + }, // 404 兜底:未匹配的路径重定向到首页 { path: '/:pathMatch(.*)*', @@ -67,8 +88,14 @@ const router = createRouter({ // 路由守卫 — 企微环境检测 + 认证检查 // -------------------------------------------------------------------------- router.beforeEach(async (to, _from, next) => { - // WeworkOnly 页面和 Login 页面不需要企微检测 - if (to.name === 'WeworkOnly' || to.name === 'Login') { + // v0.5.4 应急页(身份检测 + 预览页)不需要企微 OAuth2 认证 + // 由 EmergencyDispatcher 自己调企微 JS-SDK 检测角色 + if ( + to.name === 'WeworkOnly' || + to.name === 'Login' || + to.name === 'EmergencyDispatcher' || + to.name === 'H5Preview' + ) { next() return } diff --git a/frontend-h5/src/views/EmergencyDispatcher.vue b/frontend-h5/src/views/EmergencyDispatcher.vue new file mode 100644 index 0000000..6af559b --- /dev/null +++ b/frontend-h5/src/views/EmergencyDispatcher.vue @@ -0,0 +1,234 @@ + + + + + + + diff --git a/frontend-h5/src/views/H5PreviewView.vue b/frontend-h5/src/views/H5PreviewView.vue new file mode 100644 index 0000000..dcefe66 --- /dev/null +++ b/frontend-h5/src/views/H5PreviewView.vue @@ -0,0 +1,224 @@ + + + + + + + diff --git a/frontend-portal/package-lock.json b/frontend-portal/package-lock.json index 2e287b1..88bab3d 100644 --- a/frontend-portal/package-lock.json +++ b/frontend-portal/package-lock.json @@ -20,6 +20,10 @@ "typescript": "^5.5.0", "vite": "^5.3.0", "vue-tsc": "^2.0.0" + }, + "engines": { + "node": ">=20.0.0 <21.0.0", + "pnpm": ">=9.0.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/frontend-portal/src/views/PortalSelect.vue b/frontend-portal/src/views/PortalSelect.vue index 9858c48..f4b3619 100644 --- a/frontend-portal/src/views/PortalSelect.vue +++ b/frontend-portal/src/views/PortalSelect.vue @@ -123,7 +123,7 @@