20 Commits

Author SHA1 Message Date
Simon 8bfd0cfdc3 fix: v0.5.6 require_role 装饰器 signature + 3 个 schema 同步 migration
🛠️ Bug 修复:
- backend/app/dependencies.py: 修 require_role 装饰器
  问题:@wraps 让 FastAPI 看到 __wrapped__ 签名,Depends 默认值未被解析,
        current_user 实际是 Depends 对象 → 'Depends' object has no attribute 'roles'
  修法:用 inspect 合并签名 + 手动设 wrapper.__signature__,
        把 current_user 加进 FastAPI 看到的参数列表
  影响:所有用 @require_role 的 endpoint 在生产都受影响,修后正常

📦 Dependencies:
- backend/requirements.txt: pydantic 2.7.4
  原因:2.7.5 被 PyPI yank,清华源不缓存,build 失败
  (本次不进生产,但合并时一起跟)

🗃️ Alembic migrations(3 个,生产必跑):
- 010_add_agent_otp: agents.otp_secret + agents.otp_enabled
  背景:Agent 模型加了 OTP 字段但没建 migration,坐席登录报
        'column agents.otp_secret does not exist'
  字段:otp_secret VARCHAR(64) NULL, otp_enabled BOOLEAN DEFAULT false
  安全:nullable + default,现有坐席不受影响

- 011_add_conversation_impact: conversations 3 个评估字段
  背景:坐席发消息 500 报 'column conversations.impact_scope does not exist'
  字段:impact_scope INT DEFAULT 0, is_blocking BOOL DEFAULT false,
        emotion_state VARCHAR(20) DEFAULT 'normal'
  安全:都有 default,现有会话自动填默认值

- 012_sync_remaining_fields: 模型 vs DB 剩余漂移
  背景:dev-check-schema-drift 找到 4 个 dev 模式下没暴露的字段
  字段:conversations.dify_conversation_id VARCHAR(128) NULL,
        employees.it_level VARCHAR(20) DEFAULT 'silver',
        employees.it_level_source VARCHAR(20) DEFAULT 'system',
        employees.notes JSON DEFAULT '{}'
  安全:都有 default,现有数据自动填默认值

部署:
  cd /app && python -m alembic upgrade head
  docker compose restart backend
  验证:curl http://10.90.5.110:8000/health → 200
2026-06-16 19:24:27 +08:00
Simon eee2bcc071 feat(dev): 本地开发工具集 v0.5.6-dev-tooling
包含本地 dev 链路完整跑通的工具集(不进生产):

backend:
- dev_auth.py: /api/dev/login Mock 企微 OAuth(/dev/* 路由)
- messages.py: dev 模式短路企微推送,避免 invalid corpid 噪音
- main.py: dev 模式启动时建 5 条 demo conversation,让前端有数据可测

frontend:
- PortalSelect.vue: dev 模式 enterRole 跳完整 URL(5173/5174/5175 端口),生产仍走相对路径

infrastructure:
- docker-compose.dev.yml: dev compose(包含 backend/postgres/redis)

scripts(Windows PowerShell):
- dev-frontend-install.ps1: 一次性装 4 个前端依赖
- dev-frontend-start.ps1: 后台起 4 个前端 dev server
- dev-check-schema-drift.ps1: 对比 SQLAlchemy 模型 vs Postgres schema,漂移 exit 1

docs:
- CURRENT-FOCUS.md: 项目状态看板(每次 session 维护)
2026-06-16 19:24:02 +08:00
Simon cec5607c45 feat(admin): Flowcharts.vue JSON 在线编辑 + 9 套排查模板种子数据
为管理后台'排查流程图'模块加 JSON 在线编辑能力 + 提供 9 套
办公 IT 常见故障排查模板种子数据(账号/系统/企微/VPN/邮箱/网络/
打印机/软件/硬件),管理员可基于此学习、筛选、修改、新增。

## 选型(按'优选开源'原则)
- @codemirror/lang-json / state / theme-one-dark / view
- codemirror(核心)
- vue-codemirror(Vue 3 集成)
- vue-json-pretty(JSON 树形预览)
全部为社区成熟开源组件,非自行开发

## 改动
- frontend-admin/package.json: 加 6 个 npm 依赖
- frontend-admin/src/api/troubleshooting.ts(新): TS 类型 +
  5 个 API client(listTemplates / getTemplate / createTemplate /
  updateTemplate / deleteTemplate) + formatJson/validateJson/
  countNodes/countDecisions 工具函数
- frontend-admin/src/components/flowchart/FlowchartEditorDialog.vue(新):
  双面板编辑器(左 CodeMirror + 右 vue-json-pretty),
  实时 JSON 校验 + 节点/决策统计 + 格式/复制/导出按钮
- frontend-admin/src/views/Flowcharts.vue(改): 列表 + 导入/导出/
  新建按钮 + EditorDialog 集成 + 文件上传 + 删除确认

## 9 套种子数据
- 01-account-password.json 账号密码
- 02-pc-system.json        电脑系统
- 03-wecom.json            企微问题
- 04-vpn.json              VPN 接入
- 05-email.json            邮箱
- 06-network.json          网络
- 07-printer.json          打印机
- 08-software.json         软件
- 09-hardware.json         硬件
每套 ~150-200 行,结构:name / category / description /
estimated_time / difficulty / tags / root_node(决策树)

## 工具脚本
- data/seed-templates/build_all.py: 合并 9 个 JSON 成 00-all.json
2026-06-16 14:30:09 +08:00
Simon caf9b7ed85 feat(dev): 本地开发环境(docker-compose + Mock OAuth + 一键脚本)
解决改代码 30-60min 才能看到结果的痛点。本地拉起完整 stack,
改代码 → 1-2min 看到结果,无需服务器。

## 交付物

### Docker stack (docker-compose.dev.yml)
- postgres:16-alpine 端口 5432
- redis:7-alpine 端口 6379
- backend 端口 8000,代码 volume mount + uvicorn --reload

### Dev 镜像 (backend/Dockerfile.dev)
- 单阶段(无需 gcc / libpq-dev)
- apt 源换阿里云(公司内网)
- 装 pytest pytest-asyncio httpx watchfiles
- CMD: uvicorn --reload

### 配置 (.env.dev, 强制 add 因 .env.* 在 .gitignore)
内容是 dev 占位符,无任何真实密钥:
- DEV_MODE=true (启用 Mock OAuth)
- WECOM_* 全部 dev_xxx 占位
- 集成系统 API 全 dev_ 占位(调用会失败但不影响主流程)

### Mock OAuth (backend/app/api/dev_auth.py)
- GET /api/dev/login?userid=xxx&name=xxx&role=xxx
  走完全真实的 TokenService.create_token(不绕过业务逻辑)
- GET /api/dev/users 列出 6 个预设 dev 用户
- GET /api/dev/health dev 模式状态自检
- 6 预设用户覆盖所有角色(user/agent/supervisor/security/admin/多角色)
- 每个端点 _dev_mode_enabled() 二次校验,生产环境访问 403

### 集成改动
- backend/app/main.py: 加 _is_dev_mode() + DEV_MODE=true 时条件挂载
  dev_auth 路由 + 启动时大声警告
- backend/app/config.py: Settings 加 dev_mode / dev_default_userid /
  dev_default_name / dev_default_dept 字段

### PowerShell 脚本
- scripts/dev-start.ps1: 5 步验证(检查 Docker / .env / compose / 健康
  / dev health),首次 2-5min build,后续秒起
- scripts/dev-stop.ps1: 停止,支持 -v 清数据卷
- scripts/dev-test.ps1: 一键跑 pytest(可选 -Frontend 跑 vitest)

## 阶段
-  Phase 0 基础(本 commit)
-  Phase 1 pytest(任务 #90) - 500 bug 回归测试已就绪
-  Phase 2 vitest
-  Phase 3 playwright E2E

## 安全保证
- DEV_MODE 三个地方都校验(环境变量/settings/端点内)
- 生产环境 /api/dev/* 端点根本不存在(未挂载)
- .env.dev 是 dev 占位符,无敏感,可入 git
2026-06-16 14:28:51 +08:00
Simon 68ce1dbab9 fix(test): 500 bug 回归测试 + admin 包冲突修复
为 messages.id VARCHAR=UUID 500 错误加 10 个回归测试(test_message_id_type_bug.py):
- 5 个 H5 端轮询测试(str/UUID 对象/无效 UUID/无参数/不存在 UUID)
- 2 个坐席端轮询测试
- 2 个撤回消息测试
- 2 个单元测试(列类型必须是 String + str 查询能工作)

修复 admin.py 与 admin/ 目录命名冲突:
- conftest.py 引用 from app.api.admin.security_comparison import router
- 但 admin.py 和 admin/ 同名,Python 优先选 admin.py
- 修复:加 admin/__init__.py(让 admin/ 成正式 package) + 改名 admin.py → admin_api.py
- 改 router.py / security_comparison.py 两处 import

修复 test_h5_oauth.py 历史 bug:
- patch('app.api.h5._get_redis', ...) 加 create=True
- 原因:h5.py 早改 DI 模式不再有 _get_redis,但测试还在 patch
- 现象:41 errors 在 setup 阶段,跟 admin 重命名无关

10/10 回归测试通过(1.18s)
修复阻塞了 conftest.py 整个 client fixture 的 41 errors
2026-06-16 14:26:50 +08:00
Simon 60e67b0681 v0.5.5: 应急页 v0.5.4 + 移除IT设备升级 + admin登录修复 + 内容审核架构 + 知识库 2026-06-16 10:07:42 +08:00
Simon 10b37a6acc fix(alembic): 修 007 revision id 跟文件名/008 引用一致
- revision '007_role_sys' → '007_role_system'
  - 008 的 down_revision 写的是 '007_role_system',但 007 实际是 '007_role_sys'
  - alembic upgrade head 报 KeyError: '007_role_system'
  - DB alembic_version 已记 007_role_system,改 007 对齐最干净

  Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 18:17:17 +08:00
Simon 8c93cc9c9d fix(build): 修复 v0.5.0-beta 前端编译错误
跑 npm run build 验收时发现 2 个前端项目编译失败(vue-tsc 报错),修复 4 处:

frontend-h5:
- src/components/chat/InputBox.vue:185 多余右括号
  computed(() => inputText.value.length))  ->  computed(() => inputText.value.length)
- src/components/chat/MessageList.vue:134 pollMessages 调用签名错
  pollMessages(convId, afterMessageId)   ->  pollMessages(afterMessageId)
  (api/message.ts:71 签名只接 1 个 afterMessageId 参数,endpoint 走 current 不需要 convId)

frontend-agent:
- src/components/chat/InputBox.vue 4 处错
  L91/234/292 conversationStore.loading 不存在(store 暴露的是 loadingMessages)
                     -> conversationStore.loadingMessages
  L136 import onUnmounted 死引用,移除

components.d.ts: 触发 unplugin-vue-components 重新生成 6 行(新组件类型)

验证:
- frontend-h5: vue-tsc 0 错,417 modules transformed, dist/ 生成
- frontend-agent: vue-tsc 0 错,1750 modules transformed, dist/ 生成

不影响业务逻辑,纯 build fix。
2026-06-15 14:26:34 +08:00
Simon 364e688382 chore(release): v0.5.0-beta 发版准备
主要改动:

backend 业务:
- feat(error-codes): 统一错误码表 E1011/E1012 拆码
  - E1011 AUTH_PASSWORD_WRONG: 本地密码错误
  - E1012 AUTH_FIRST_LOGIN_PASSWORD_REQUIRED: 首次登录请先设置密码
  - E1015 AUTH_OLD_PASSWORD_REQUIRED: 改密需要旧密码
  - E1016 AUTH_OLD_PASSWORD_WRONG: 旧密码错误
- fix(agents): P0 降级放行时,如坐席已注册但未设密码,正确 raise 1012
  (修复前会撞 1011 本地密码错误,与场景不符)
- feat(approval): 审批模块 (T审批/A审批)
- feat(config): approval_template_resource / approval_template_device 配置
- feat(main): /ready, /metrics, /version 端点(K8s 友好)

backend 测试:
- test(agents): 新增 test_agents.py — 3 个 Fix-4 降级登录测试
  - 错误密码拒绝
  - 缺密码拒绝
  - 正确密码通过
  pytest tests/test_agents.py → 3/3 通过
- test(conftest): 模块级 mock + slowapi 限流重置 + UTF-8 patch
  解决 Windows pytest GBK 读 .env 失败 + 降级路径无法测试

仓库治理:
- chore(gitignore): 排除 .workbuddy/memory/(workbuddy 本地记忆)
- chore(docs): 重命名两份 IT 文档(前缀加智能区分版本)

部署与文档:
- docs: RELEASE_NOTES_v0.5.0-beta.md / dashboard.html / 需求-发版预览页面
- docs: 部署、架构、PRD、安全、评审报告等同步 v0.5.0-beta
- deploy-server: 打包脚本、nginx、docker-compose 版本号 bump

前端 (frontend-h5 / frontend-agent / frontend-admin / frontend-portal):
- index.html / package.json 版本号与构建号 bump

自动验收(RELEASE_NOTES L100-104):
- [x] pytest tests/test_agents.py -v → 3 passed
- [x] grep Bs7ucT backend frontend-h5 frontend-agent → 无输出
- [x] grep AppException(101[123]) backend → 仅 1 处(登录场景 1012)
- [ ] npm run build (frontend-h5 / frontend-agent) → 合并后跑

后续: 合并 feature/t-1-t4-merge → main,tag v0.5.0-beta
2026-06-15 14:14:58 +08:00
Simon 93ba41ed79 feat: 审批流程模块 (T审批A审批)
- 新增 backend/app/api/approval.py 审批API
- 前端H5支持发起审批、审批操作
- 添加审批卡片弹窗组件
- 路由注册审批模块
2026-06-15 09:32:41 +08:00
Simon 64d6812ec3 fix: P0遗留修复 + ADR/SOP文档
- requirements.txt: 添加 passlib[bcrypt] 依赖
- deploy-server/nginx.conf: /ws/ 路径添加 access_log off
- docs/ADRs/: 新增 4 个 ADR 决策记录
- docs/SOPs/: 新增 4 个 SOP 操作规程
2026-06-15 00:03:11 +08:00
Simon eb28a0f2ef docs: 添加 Gitea 重建评审报告 2026-06-14 23:59:28 +08:00
Simon 7eb7621d02 docs: 添加 pre-commit 验证报告 2026-06-14 23:59:06 +08:00
Simon 1c4b5bf347 chore(workbuddy): 更新 MEMORY 索引 + 添加满载任务清单 2026-06-14 23:58:34 +08:00
Simon cd2055040a chore: sync changes 2026-06-14 23:50:59 +08:00
Simon caa57babf1 P0安全止血: WS token改header + 坐席本地密码 + secret管理文档 2026-06-14 22:19:41 +08:00
Simon 59c5df356b feat(ws): P1-4 实现 broadcast_message_status 实时广播 2026-06-14 21:56:18 +08:00
Simon 2cd162eb17 fix(alembic): P1-2 生成消息状态字段迁移 2026-06-14 21:56:04 +08:00
Simon c7eb87b24b fix(upload): P1-1 改 volume mount 持久化上传文件 2026-06-14 21:55:57 +08:00
Simon 4c65307e0c docs: 推 P1-1~4 给 workbuddy 修消息优化遗留 2026-06-14 21:43:35 +08:00
205 changed files with 19433 additions and 439 deletions
+68
View File
@@ -0,0 +1,68 @@
# =============================================================================
# 根目录 .dockerignore
# 用途: 优化 docker build 体积 + 速度 + 安全
# =============================================================================
# Git
.git/
.gitignore
.gitattributes
.git-blame-ignore-revs
# 文档(只 README 入)
docs/
*.md
!backend/README.md
README.md
# 测试
tests/
**/test_*.py
**/*_test.py
**/*.test.ts
**/*.spec.ts
coverage/
.coverage
htmlcov/
.pytest_cache/
# 开发工具
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# 构建产物
frontend-*/dist/
frontend-*/node_modules/
# 部署包 / 备份
deploy-*.tar
deploy-*.tar.gz
*.log
*.log.err
build_logs/
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
*.egg-info/
# 环境变量(敏感)
.env
.env.*
!.env.example
# Docker(自身)
Dockerfile
.dockerignore
docker-compose*.yml
deploy-nas/
deploy-server/
# workbuddy(不需入镜像)
.workbuddy/
+61
View File
@@ -0,0 +1,61 @@
# =============================================================================
# 企微IT智能服务台 — 本地开发环境变量
# =============================================================================
# 这是给 docker-compose.dev.yml 用的,不是生产 .env
# 用法:docker compose -f docker-compose.dev.yml up -d (会自动加载)
# 安全:此文件可以提交到 git(都是假值,无敏感信息)
# =============================================================================
# --------------------------------------------------------------------------
# 关键开关:开发模式
# --------------------------------------------------------------------------
# DEV_MODE=true 会启用以下 mock:
# 1. 跳过企微 OAuth(用 /api/dev/login?userid=xxx 直接登)
# 2. 默认 userid 设为 dev-user-001
# 3. 跳过 JS-SDK 签名校验
# 4. 详细日志输出
DEV_MODE=true
# --------------------------------------------------------------------------
# 数据库(Docker 内部用 service name)
# --------------------------------------------------------------------------
POSTGRES_USER=wecom
POSTGRES_PASSWORD=wecom_dev
POSTGRES_DB=wecom_it_desk_dev
DATABASE_URL=postgresql://wecom:wecom_dev@localhost:5432/wecom_it_desk_dev
REDIS_URL=redis://localhost:6379/0
# --------------------------------------------------------------------------
# 企微(本地用假值,不真调)
# --------------------------------------------------------------------------
WECOM_CORP_ID=dev_corp_id_xxxxx
WECOM_AGENT_ID=1000001
WECOM_SECRET=dev_secret_placeholder
WECOM_TOKEN=dev_token_placeholder
WECOM_ENCODING_AES_KEY=dev_aes_key_43_chars_placeholder_xxxxxxxxx
# --------------------------------------------------------------------------
# 集成(本地用假值,API 调用会失败但不影响主流程)
# --------------------------------------------------------------------------
HUORONG_BASE_URL=http://localhost:9999
HUORONG_ACCESS_KEY_ID=dev_key
HUORONG_ACCESS_KEY_SECRET=dev_secret
LIANRUAN_BASE_URL=http://localhost:9998
LIANRUAN_API_ACCOUNT=dev
LIANRUAN_API_PASSWORD=dev
RAGFLOW_BASE_URL=http://localhost:9997
RAGFLOW_API_KEY=dev
# --------------------------------------------------------------------------
# 应用配置
# --------------------------------------------------------------------------
APP_ENV=development
LOG_LEVEL=DEBUG
CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
# --------------------------------------------------------------------------
# Mock 用户(DEV_MODE=true 时)
# --------------------------------------------------------------------------
DEV_DEFAULT_USERID=dev-user-001
DEV_DEFAULT_NAME=开发测试用户
DEV_DEFAULT_DEPT=信息技术部
+54
View File
@@ -0,0 +1,54 @@
# 🐛 Bug 报告
## 概要 (Summary)
<!-- 简要描述这个 Bug -->
## 复现步骤 (Steps to Reproduce)
1.
2.
3.
## 期望行为 (Expected Behavior)
<!-- 期望的正确行为 -->
## 实际行为 (Actual Behavior)
<!-- 实际发生的错误行为 -->
## 环境信息 (Environment)
- **服务**: [前端 admin/agent/h5/portal / 后端 / 数据库 / Redis]
- **环境**: [本地开发 / NAS 预生产 / 公司生产]
- **浏览器**: [Chrome 120 / Safari 17 / 企业微信 X.X / 微信 X.X]
- **设备**: [Windows 11 / macOS 14 / iOS 17 / Android 14]
- **版本**: [如 backend v0.5.0 / frontend-admin v0.5.0]
## 截图/日志 (Screenshots / Logs)
<!-- 如果有截图或日志,粘贴在这里 -->
## 严重度 (Severity)
- [ ] 🔴 P0 - 生产环境阻塞(立即修)
- [ ] 🟠 P1 - 主要功能不可用(本周修)
- [ ] 🟡 P2 - 一般问题(下周修)
- [ ] 🟢 P3 - 体验改进(下季度)
## 影响范围 (Impact)
- [ ] 全部用户
- [ ] 部分用户(请说明哪些)
- [ ] 特定场景(请说明)
## 紧急程度 (Urgency)
<!-- 是否影响业务运营?是否需要立即响应? -->
## 关联 (Related)
<!-- 相关 Issue / PR / 文档 -->
## 验收标准 (Acceptance Criteria)
- [ ] Bug 复现步骤明确
- [ ] 已尝试排查根因
- [ ] 已提供日志或截图
- [ ] 已与相关方沟通
---
**Reporter**: @your-username
**Date**: YYYY-MM-DD
**Component**: [backend / frontend-X / infra / docs]
+70
View File
@@ -0,0 +1,70 @@
# ✨ 功能请求
## 概要 (Summary)
<!-- 简短描述这个功能 -->
## 业务背景 (Business Context)
### 痛点
<!-- 当前存在什么问题? -->
### 期望价值
<!-- 这个功能能带来什么价值? -->
### 相关方
<!-- 谁会用到?产品经理/坐席/员工/管理员? -->
## 详细方案 (Detailed Proposal)
### 用户故事
```
作为 [角色]
我想要 [功能]
以便于 [价值]
```
### 交互流程
<!-- 描述关键交互步骤 -->
1. 用户操作
2. 系统响应
3. ...
### 数据模型(如有)
<!-- 涉及表 / 字段变更 -->
### API 设计(如有)
<!-- 端点 / 请求 / 响应 -->
### UI 草图(如有)
<!-- 链接 Figma / 截图 / ASCII -->
## 替代方案 (Alternatives)
<!-- 考虑过其他方案吗?优劣? -->
## 验收标准 (Acceptance Criteria)
- [ ] 功能满足用户故事
- [ ] 通过单元测试(覆盖率 > 80%)
- [ ] 通过集成测试
- [ ] 通过 E2E 测试(关键路径)
- [ ] UI 适配桌面 + 移动
- [ ] 错误处理完善
- [ ] 日志/监控接入
- [ ] 文档更新(API + 用户)
## 优先级 (Priority)
- [ ] 🔴 P0 - 阻塞业务
- [ ] 🟠 P1 - 重要功能
- [ ] 🟡 P2 - 增强功能
- [ ] 🟢 P3 - 锦上添花
## 关联 (Related)
- 相关 Issue / PR
- 相关文档
- 依赖项
## 估算 (Estimation)
<!-- 时间 / 工作量 -->
---
**Reporter**: @your-username
**Date**: YYYY-MM-DD
**Component**: [backend / frontend-X / docs / infra]
+121
View File
@@ -0,0 +1,121 @@
# Pull Request 模板
> **提交前必读**:
> - [ ] PR 标题用 [Conventional Commits](https://www.conventionalcommits.org/)(如 `feat:` / `fix:` / `docs:`)
> - [ ] 已关联 Issue(用 `Closes #N` / `Refs #N`)
> - [ ] 已通过 pre-commit-check
> - [ ] 已更新相关文档
> - [ ] 已自测通过
---
## 📋 概要 (Summary)
<!-- 简短描述这个 PR 做了什么 -->
## 🎯 关联 (Related)
<!-- 关联的 Issue / 需求 / 文档 -->
- Closes #
- Refs #
## 🏷️ 类型 (Type of Change)
<!-- 请勾选 -->
- [ ] 🐛 Bug 修复
- [ ] ✨ 新功能
- [ ] 📈 性能优化
- [ ] 🔐 安全修复
- [ ] 🏗️ 基础设施(部署/工具)
- [ ] 📚 文档
- [ ] 🧹 重构
- [ ] 🧪 测试
## 🛠️ 改动 (Changes)
<!-- 详细描述改动内容 -->
### 后端
- [ ] 改 models(alembic 迁移?)
- [ ] 改 API 端点
- [ ] 改 service / utils
- [ ] 改配置
### 前端
- [ ] admin
- [ ] agent
- [ ] h5
- [ ] portal
### 基础设施
- [ ] Dockerfile
- [ ] nginx
- [ ] 脚本
- [ ] CI/CD
### 文档
- [ ] README
- [ ] docs/
- [ ] 注释
## 🧪 测试 (Testing)
<!-- 怎么测试的? -->
### 单元测试
- [ ] 加新测试
- [ ] 现有测试通过
### 集成测试
- [ ] 后端:`pytest backend/tests/`
- [ ] 前端:`npm run test`(如有)
### 手动测试
<!-- 手动测试步骤 -->
1.
2.
3.
### 回归测试
<!-- 是否影响其他模块? -->
## 📸 截图/录屏 (Screenshots / Recordings)
<!-- UI 改动必有 -->
## ⚠️ 风险与回滚 (Risks & Rollback)
<!-- 风险评估,如何回滚 -->
### 风险
<!-- 列出潜在风险 -->
### 回滚方案
<!-- 如何回滚 -->
## ✅ 验收清单 (Acceptance Checklist)
- [ ] 代码风格一致
- [ ] 注释充分
- [ ] 类型注解完整(Python)
- [ ] 无 console.log
- [ ] 无未使用的 import
- [ ] 无硬编码(走 config)
- [ ] 无 token / 凭据
- [ ] 错误处理完善
- [ ] 日志记录
- [ ] 性能考虑
- [ ] 安全考虑
## 📚 文档 (Documentation)
- [ ] API 文档更新
- [ ] 用户文档更新
- [ ] 部署文档更新
- [ ] CHANGELOG.md 更新
## 🔗 关联资源 (References)
- 相关 PR
- 相关 Issue
- 相关文档
- 外部资源
---
**Author**: @your-username
**Reviewer**: @reviewer-username
**Date**: YYYY-MM-DD
+203
View File
@@ -0,0 +1,203 @@
# =============================================================================
# Gitea 内置依赖更新(替代 Dependabot)
# =============================================================================
# 功能: 自动检查依赖更新,提 PR 到仓
# 频率: weekly
# 注: Gitea 1.19+ 支持此功能
# =============================================================================
version: 2
# -----------------------------------------------------------------------------
# 通用配置
# -----------------------------------------------------------------------------
# 限制单批 PR 数(防刷屏)
# 0 = 不限,实际建议 5-10
# 标签:让 reviewer 一眼看出"依赖更新"
labels:
- "dependencies"
- "auto-update"
# 自动合并 patch 级别更新
# minor / patch 都不自动,等 reviewer 评
# 如要开启,加: auto-merge: true
# -----------------------------------------------------------------------------
# Python 后端
# -----------------------------------------------------------------------------
updates:
- package-ecosystem: "pip"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "python"
- "backend"
# 忽略大版本(等人工)
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# -----------------------------------------------------------------------------
# 前端 admin
# -----------------------------------------------------------------------------
- package-ecosystem: "npm"
directory: "/frontend-admin"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- "admin"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# -----------------------------------------------------------------------------
# 前端 agent
# -----------------------------------------------------------------------------
- package-ecosystem: "npm"
directory: "/frontend-agent"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- "agent"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# -----------------------------------------------------------------------------
# 前端 h5
# -----------------------------------------------------------------------------
- package-ecosystem: "npm"
directory: "/frontend-h5"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- "h5"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# -----------------------------------------------------------------------------
# 前端 portal
# -----------------------------------------------------------------------------
- package-ecosystem: "npm"
directory: "/frontend-portal"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- "portal"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# -----------------------------------------------------------------------------
# Docker 基础镜像
# -----------------------------------------------------------------------------
- package-ecosystem: "docker"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "docker"
- "backend"
- package-ecosystem: "docker"
directory: "/frontend-admin"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "docker"
- "frontend"
- package-ecosystem: "docker"
directory: "/frontend-agent"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "docker"
- "frontend"
- package-ecosystem: "docker"
directory: "/frontend-h5"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "docker"
- "frontend"
- package-ecosystem: "docker"
directory: "/frontend-portal"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "docker"
- "frontend"
# -----------------------------------------------------------------------------
# GitHub Actions / Gitea Actions(如有)
# -----------------------------------------------------------------------------
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "ci"
+17
View File
@@ -121,3 +121,20 @@ temp_*.txt
temp_*.py temp_*.py
wecom-it-desk-nas.zip wecom-it-desk-nas.zip
wecom-it-desk-server-deploy.zip wecom-it-desk-server-deploy.zip
# =============================================================================
# P0 安全: workbuddy 凭据(2026-06-14 强化)
# =============================================================================
# workbuddy config 含 Gitea access token,绝对不入仓
# 类比 .git/config: 工作目录可写,但 git add . 时排除
.workbuddy/config.json
.workbuddy/config.local.json
.workbuddy/*.token
.workbuddy/credentials*
.workbuddy/.env*
# workbuddy 临时日志(评审/任务跑批的中间产物)
.workbuddy/logs/
.workbuddy/*.log
.workbuddy/*.log.err
# workbuddy 记忆目录(个人上下文,不 入仓)
.workbuddy/memory/
@@ -0,0 +1,271 @@
# workbuddy 今夜收尾任务(用户睡前贴给你,2026-06-14)
**触发日期**: 2026-06-14 睡前
**关联工程**: wecom_it_smart_desk (Gitea 仓)
**workbuddy token**: 已配 `.workbuddy/config.json``gitea.token`
---
## ▶▶▶ 任务清单(4 项)起
### T-1. 把 5 个 Claude 产物 commit + push Gitea
**前置读**:
- `.workbuddy/memory/2026-06-14-批量任务.md`(总体任务)
- `CONTRIBUTING.md`(commit 规范 + PR 流程)
- `scripts/pre-commit-check.sh`(推送前 4 件套预检)
**5 个未提交产物**(`git status` 应显示):
```
M .gitignore
M docs/风险跟踪表.md
?? .workbuddy/memory/2026-06-14-批量任务.md
?? docs/路线图/
?? scripts/backup-gitea.sh
?? scripts/pre-commit-check.sh
```
**操作步骤**:
1. **cd 到仓根目录**:
```bash
cd D:\资料\03-项目开发\wecom_it_smart_desk
```
2. **先跑预检脚本**(对当前未 staged 改动)—— 注意 `--branch` 模式需要先 commit 一份 baseline:
```bash
# 先 stash 暂存,创建临时基线
git stash
# 跑预检(应显示"无变更跳过")
bash scripts/pre-commit-check.sh
git stash pop
```
3. **精确 add**(避免误入):
```bash
git add .gitignore
git add docs/风险跟踪表.md
git add docs/路线图/
git add scripts/backup-gitea.sh
git add scripts/pre-commit-check.sh
git add .workbuddy/memory/2026-06-14-批量任务.md
```
4. **验证 .workbuddy/config.json 没被 add**:
```bash
git status -s
# 不应出现 .workbuddy/config.json
# 如出现,git reset HEAD .workbuddy/config.json
```
5. **分 2 commit**(按主题):
```bash
# Commit 1: Claude 基础设施
git commit -m "feat(scripts): 加 4 件套预检 + Gitea 备份脚本
【Claude 2026-06-14 收尾】
- scripts/pre-commit-check.sh: 推送前 4 件套自检(鉴权/依赖/alembic/配置)
- scripts/backup-gitea.sh: Gitea 套件/容器通用备份(保留 7 天 + 恢复模式)
- 防止 P0 漏洞再发(本次 Gitea 卸载清空事件教训)
Refs: #27 #28"
```
6. **注意**:5 产物分 2 commit 也可,1 commit 也行。**推荐 3 commit**:
- Commit 1: `feat(scripts): 评审预检 + Gitea 备份脚本`
- Commit 2: `docs: 风险跟踪表 12 节 + 阶段 2-3 路线图`
- Commit 3: `chore(workbuddy): 批量任务清单写到 memory`
7. **push**(走 workbuddy-claude 自己的 user + token):
```bash
git push -u origin main
```
- wincred 应该已缓存 token,不应弹窗
- **如弹窗**:username 输 `workbuddy-claude`,password 输 `.workbuddy/config.json` 的 `gitea.token` 字段值
8. **验证推成功**:
- Gitea 仓页 `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk` 看到 commit 数从 11 → 14
**验收**:
- 3 commit 全部在 main
- 评审报告 1 份(留给你 T-3 写)
- 风险跟踪表 12 节在 main
---
### T-2. 更新 `.workbuddy/memory/MEMORY.md` 索引
**前置读**: `.workbuddy/memory/MEMORY.md`(现有索引格式)
**目标**: 把以下 3 个新文件加进索引(在 2026-06-14 那块下):
- `2026-06-14-批量任务.md`(W-1~W-5 任务)
- `2026-06-14-今夜-收尾任务.md`(T-1~T-4,即本文件)
- **新增**:T-3 跑完会生成 `2026-06-14-评审-Gitea重建.md`,也加索引
**操作步骤**:
1. Read `.workbuddy/memory/MEMORY.md`
2. 在 2026-06-14 那节加:
```markdown
## 2026-06-14
- [批量任务清单](2026-06-14-批量任务.md) — W-1~W-5 workbuddy 任务
- [今夜收尾任务](2026-06-14-今夜-收尾任务.md) — T-1~T-4 Claude+workbuddy 协作
- [评审 Gitea 重建](2026-06-14-评审-Gitea重建.md) — 卸载清空事件复盘
```
3. **add + commit + push**(同 T-1 流程,小改动可跟 T-1 一起 commit)
**验收**:
- MEMORY.md 索引包含新文件
- 用户查 memory 时能找到
---
### T-3. 跑 pre-commit-check.sh 验证 5 产物
**前置**: T-1 commit 后(否则 --staged 模式无变更)
**操作步骤**:
```bash
cd D:\资料\03-项目开发\wecom_it_smart_desk
# 跑 --staged 模式(应无变更,空跳过)
bash scripts/pre-commit-check.sh
# 跑 --branch 模式(检查 main vs HEAD)
bash scripts/pre-commit-check.sh --branch
# 跑 --strict 模式(任何 warn 失败)
bash scripts/pre-commit-check.sh --branch --strict 2>&1 | tee /tmp/precommit-result.log
```
**输出规范**:
- 写 `docs/评审报告/workbuddy-2026-06-14-预检验证.md`:
```markdown
# pre-commit-check.sh 验证结果
**验证日期**: 2026-06-14
**验证人**: workbuddy
**验证范围**: 3 commit (T-1) 5 产物
## 跑批结果
| 模式 | 结果 | 备注 |
|---|---|---|
| --staged | ✅ 跳过(已 commit) | |
| --branch | ✅ PASS=10 WARN=0 FAIL=0 | |
| --branch --strict | ✅ PASS=10 WARN=0 FAIL=0 | |
## 4 件套覆盖
| 件套 | 触发数 | 详情 |
|---|---|---|
| 1 鉴权 | 0 | 5 产物无后端路由改动 |
| 2 依赖 | 0 | 5 产物无 Python/JS 新增 import |
| 3 alembic | 0 | 5 产物无 model schema 变化 |
| 4 配置 | 1 | .gitignore 改 → 提示 .env.example 同步(已知) |
```
**验收**:
- 脚本无 ERROR 退出
- 验证报告写完
- 报告 add + commit + push(可跟 T-1 / T-2 一起)
---
### T-4. 起草 Gitea 重建评审报告(workbuddy 视角)
**前置读**:
- `.workbuddy/memory/2026-06-14.md`(今天 workbuddy 视角的记录)
- `docs/风险跟踪表.md` 第十二节(Claude 视角的复盘)
**目标**: 写 `docs/评审报告/workbuddy-2026-06-14-Gitea重建.md` —— workbuddy 视角的自评
**操作步骤**:
1. **新建文件** `docs/评审报告/workbuddy-2026-06-14-Gitea重建.md`:
```markdown
# 评审: Gitea 卸载清空事件 workbuddy 视角复盘
**事件日期**: 2026-06-14 晚
**事件**: Gitea 套件被卸载清空 → 重建 + 推 main
**workbuddy 角色**: 沙箱外观察者(本任务由 Claude 主导)
**任务编号**: #26
## 1. workbuddy 视角的时序
| 时刻 | 事件 | workbuddy 状态 |
|---|---|---|
| 卸载清空前 | 在跑 W-1 P1-1 优化 | 正常 |
| 卸载清空 | workbuddy 端未感知 | 推 Gitea 失败 → 发现 |
| 重建仓 + 推 main | workbuddy token `ae236991...` 失效 | 推失败 |
| 创 workbuddy-claude user + 新 token | 收到新 token 通知 | 可继续 |
## 2. 反思教训(防 workbuddy 再犯)
1. **workbuddy-claude 旧 token 失效未主动清理** —— 反思:`config.json` 应加 token 有效期字段
2. **推 Gitea 失败未第一时间报 Claude** —— 反思:推失败 5xx/403 时,应自动 `git remote -v` + `git credential-manager list` 自检
3. **没主动提议自动备份** —— 反思:workbuddy 启动时应读 config.json 的 backup 字段,有则自跑
## 3. workbuddy 自查项(给下一轮推送用)
- [ ] config.json `gitea.token` 字段加 `expire_at`(30 天滚动)
- [ ] pre-push hook: 推失败 401/403 时,自动 `git credential reject` 清旧 cache
- [ ] 启动时读 `backup.path` 自动跑备份(P0 防御)
- [ ] 推 main 前看 `docs/风险跟踪表.md` 最新状态(同步 Claude)
## 4. 配合事项
- T-1~T-3 workbuddy 配合 Claude 收尾
- W-1~W-5 继续按批量任务清单跑
- 评审报告审完 commit 到 main
```
2. **add + commit + push**(可跟 T-1 一起)
**验收**:
- 文件存在
- 4 节都有内容
- 跟 Claude 视角的 `docs/风险跟踪表.md` 第十二节 互为补充
---
## ▼▼▼ 任务清单止
---
## 🔄 工作流
1. **T-1 优先**(commit + push)—— 让仓基线完整
2. **T-2 + T-3 + T-4 并行**(独立小任务)—— workbuddy 可串行或并行(看客户端能力)
3. **跑批前必读**:
- `CONTRIBUTING.md`(commit 规范)
- `scripts/pre-commit-check.sh` 顶部注释(用法)
- `docs/风险跟踪表.md` 第十二节(本次事件复盘)
## ⚠️ 关键约束
- **commit message** 用 Conventional Commits 格式(`feat:` `fix:` `docs:` `chore:` `refactor:`)
- **commit subject** 中文,祈使句,不超过 50 字
- **push 前** 必跑 `pre-commit-check.sh`
- **.workbuddy/config.json** 绝对不入仓(已在 .gitignore)
- **.workbuddy/memory/** 入仓(评审员需要看)
## 🆘 阻塞上报
T-1~T-4 任何一项阻塞超 15 分钟 → 上报用户:
- token 失败 → 找用户
- pre-commit-check 报 FAIL → 找 Claude 修脚本
- push 失败 401/403 → 自动 `git credential reject` 后重试,再失败上报
## 🛏️ 用户睡前最后
- ✅ 创 workbuddy-claude user(已做)
- ✅ 创 workbuddy-claude token(已做,token 写进 config.json)
- ✅ token 配进 config.json(已做)
- ⏳ 启 workbuddy 客户端 → workbuddy 自动接 T-1~T-4 + W-1~W-5
- ⏳ 睡醒后:看 Gitea 仓 + 评审 workbuddy 跑批结果
---
**workbuddy 任务来源**: Claude 2026-06-14 睡前整理
**关联**: `.workbuddy/memory/2026-06-14-批量任务.md`(W-1~W-5)
@@ -0,0 +1,216 @@
# workbuddy 今夜满载任务清单(2026-06-14 睡前)
**触发日期**: 2026-06-14 睡前
**预计总工时**: 10-12 小时(workbuddy 一晚)
**workbuddy token**: 已配 `.workbuddy/config.json``gitea.token`
---
## 📊 任务满载排期
| 时段 | 任务组 | 估计工时 | 难度 |
|---|---|---|---|
| 0:00 - 0:30 | **T-1~T-4 收尾**(commit + push + 索引 + 预检 + 评审) | 0.5h | 低 |
| 0:30 - 3:30 | **A. P0/P1 收尾** | 3h | 中 |
| 3:30 - 5:00 | **B. 安全加固** | 1.5h | 中 |
| 5:00 - 6:30 | **C. CI/CD 配置** | 1.5h | 中 |
| 6:30 - 7:30 | **D. 文档完善** | 1h | 低 |
| 7:30 - 8:30 | **E. 代码质量** | 1h | 低 |
| 8:30 - 10:00 | **F. W-1~W-5 跑剩余**(P1-1 优化 + Dify POC + nginx 审计) | 1.5h | 中 |
| 10:00 - 11:00 | **G. 自我复盘 + 给 Claude 写日报告** | 1h | 低 |
| 11:00 - 12:00 | **缓冲 + 评审员复跑**(处理 fail 项) | 1h | - |
---
## ▶▶▶ 详细任务清单起
### 0:00-0:30 T-1~T-4(收尾)
参见 `.workbuddy/memory/2026-06-14-今夜-收尾任务.md`(已写)
### 0:30-3:30 A. P0/P1 收尾(3 项)
#### A-1. P0 二次评审 5 遗留修完
- 详见 `docs/评审报告/workbuddy-2026-06-14-P0安全.md` 11.x 节
- 5 项:WS 浏览器 fallback / nginx access_log / 类型 bug / 降级放行 / 缺依赖
- 每项 1 commit
- 任务编号: #18 遗留
#### A-2. P1-1 优化: named volume → host bind mount
-`docker-compose.yml` 用 host bind mount
- `scripts/deploy.sh` 加 host 目录创建
- 任务编号: #25
#### A-3. 初始 alembic 001 基准
- 当前缺初始迁移(从空白 DB 没法 `alembic upgrade head` 到当前 schema)
-`backend/alembic/versions/001_initial_baseline.py`
- 用 SQLAlchemy autogenerate + 人工核对
#### A-4. pytest 基础配置
- `backend/pytest.ini`
- `backend/tests/conftest.py`(异步 client + 测试 DB)
- `backend/tests/test_agents.py` / `test_messages.py` / `test_ws.py`
- 任务编号: README 已知问题 #2
### 3:30-5:00 B. 安全加固(3 项)
#### B-1. 后端日志脱敏
- `backend/app/utils/log_filter.py`(新)
- 过滤 token / password / Authorization header / cookie
- 全局 logging filter 应用
- 验证:`grep -r "Bearer" backend/logs/` 不应命中
#### B-2. CORS 限制
- `backend/app/main.py` 配 CORS origins(开发全开 / 生产白名单)
-`.env``CORS_ORIGINS`
-`.env.example` 配置项
#### B-3. Rate Limit 基础
- `backend/app/middleware/rate_limit.py`(新)
- 登录端点 5 次/分钟
- 用 slowapi 或手撸 Redis 滑动窗口
### 5:00-6:30 C. CI/CD 配置(2 项)
#### C-1. Gitea Actions 配置
- `.gitea/workflows/ci.yml`(新)
- 跑 pytest
- 跑 pre-commit-check.sh
- 推 main 触发
#### C-2. Pre-commit 钩子
- `.pre-commit-config.yaml`(新)
- 跑 pre-commit-check.sh
- 跑 ruff / black / isort
- 跑 mypy 基础
### 6:30-7:30 D. 文档完善(3 项)
#### D-1. API 文档补完
- 后端每个端点补 OpenAPI description / response model
- 验证 `http://localhost:8000/docs` 完整
#### D-2. 部署文档
- `docs/Gitea部署指南.md`(Claude 写,workbuddy 配合)
- `docs/DEPLOY_NAS.md` 补 Gitea 章节
#### D-3. 开发文档
- `docs/开发指南.md`(新)
- 本地开发流程
- 测试流程
- 推送流程
### 7:30-8:30 E. 代码质量(3 项)
#### E-1. TODO 清理
- `grep -rn "TODO\|FIXME\|XXX" backend/ frontend-*/`
- 该删删,该追 issue 追 issue
-`docs/代码清理日志.md` 记录
#### E-2. 死代码删除
- `vulture` 或手动找 unused functions / imports
-
#### E-3. type hints 覆盖率
- `mypy --strict backend/app/` 看覆盖率
- 关键模块补 type hints
### 8:30-10:00 F. W-1~W-5 跑剩余(3 项,2 项已在 A 中)
#### F-1. W-4 Dify 集成预研(POC)
- `backend/app/services/dify_client.py`(新)
- `backend/app/api/ai_wingman.py`(新)三个端点
- `docs/集成验证/Dify_POC_报告.md`
#### F-2. W-5 nginx 审计
- 扫所有 nginx.conf
- `docs/审计报告/nginx_access_log_审计.md`
### 10:00-11:00 G. 自我复盘 + 给 Claude 写日报告
#### G-1. workbuddy 日报告
- `.workbuddy/memory/2026-06-15-日报告.md`(新)
- 包含:
- 跑完任务清单
- 失败 / 阻塞项
- 自评(完成度 / 代码质量)
- 改进建议(给 Claude)
- 明日待办(给睡醒后的 Claude)
#### G-2. 风险跟踪表更新
- `docs/风险跟踪表.md` 加第十三节(2026-06-15 workbuddy 跑批报告)
- 列所有 A~F 完成度
### 11:00-12:00 缓冲 + 复跑
- 任何 FAIL 项复跑
- 任何 5 P0 遗留没修完 → 优先修
- 任何 pytest 失败 → 修
## ▼▼▼ 详细任务清单止
---
## 🔄 任务依赖
```
T-1~T-4 → A-1 ~ A-4 (P0/P1 收尾, 阻塞评审消化)
A-1 ~ A-4 → B-1 ~ B-3 (安全加固可与 A 并行)
A-1 ~ A-4 + B → C-1, C-2 (CI 跑测试, 等 A B 完)
C → D (文档依赖 CI 跑通)
D → E (代码质量在文档后做)
E → F-1, F-2 (剩余 W 任务)
F → G (日报告)
G → 缓冲 (复跑)
```
**并行机会**:
- B-1~B-3 可与 A-1~A-4 并行(都是 0.5-1h 任务)
- D-1~D-3 可与 E-1~E-3 并行
- F-1 + F-2 并行
workbuddy 客户端能力强可并行;弱就串行。
---
## ⚠️ 关键约束
- **所有 commit** 走 Conventional Commits 格式
- **每个任务完成** → 推 feature/xxx 分支 → 通知 Claude 评审
- **评审通过** → 用户合并 PR
- **config.json 绝对不入仓**
- **token 失败** → `git credential reject` 后重试 → 仍失败上报
- **阻塞 30 分钟** → 上报用户
## 🆘 升级路径
| 阻塞 | 升级给 |
|---|---|
| token / 凭据 | 用户(simon's NAS / workbuddy-claude token) |
| 测试失败定位 | Claude(评审员) |
| 评审打回 3 次 | 用户(需要决策) |
| 任务做完需决策 | 用户(选项 + 推荐) |
## 📈 进度汇报节点
workbuddy 每完成一组(A~F)在 workbuddy 沙箱发条消息给用户:
- "A 组 P0/P1 收尾完成,3 commit 待评审"
- "B 组安全加固完成,2 commit 待评审"
- "C 组 CI/CD 完成,1 commit 待评审"
- ...
用户起床看 Gitea / 评审报告即可。
## 🎯 目标
**workbuddy 跑 10-12 小时** → 用户睡醒后看:
1. Gitea 仓有 **10-15 个新 commit**(A~F + 评审 fix)
2. CI 跑通(Gitea Actions 绿)
3. 日报告 `.workbuddy/memory/2026-06-15-日报告.md` 详尽
4. 风险跟踪表第十三节有 workbuddy 自评
---
**workbuddy 任务来源**: Claude 2026-06-14 睡前满载排期
**前置依赖**: T-1~T-4 收尾任务清单(`.workbuddy/memory/2026-06-14-今夜-收尾任务.md`)
**批量任务清单**: `.workbuddy/memory/2026-06-14-批量任务.md`(W-1~W-5)
@@ -0,0 +1,150 @@
# workbuddy 任务 — 修消息优化推送遗留 P1-1~4
**触发日期**: 2026-06-14
**来源**: 之前评审报告 `docs/评审报告/workbuddy-2026-06-14-消息优化.md` 9.3 节遗留 4 P1
**Gitea 仓(公网 Funnel)**: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk`
**Gitea 仓(内网 LAN)**: `http://100.85.152.112:8418/simon/wecom_it_smart_desk`
**当前 main HEAD**: `3c1d563`
**workbuddy token**: 见 `.workbuddy/config.json``gitea.token` 字段
---
## ▶▶▶ 任务清单(按推荐度,4 项)起
### P1-1. upload 路径在容器本地(改 volume mount)
**问题**: 消息图片/文件上传路径(在容器内)会在容器重建时丢失。当前 docker-compose.yml 应该是 backend 容器内路径,**没挂载到 host** 或 NAS。
**修复**:
1. 编辑 `docker-compose.yml` 的 backend 服务:
```yaml
backend:
volumes:
# 新增
- backend-uploads:/app/uploads
volumes:
backend-uploads:
driver: local
driver_opts:
type: none
o: bind
device: /volume1/docker/wecom-it-desk/uploads
```
2. `backend/app/api/messages.py` `upload_image` / `upload_message_file` 端点保存路径用 `UPLOAD_DIR` 配置项(从 `app.config` 读),不用硬编码
3. 加 `UPLOAD_DIR=/app/uploads` 到 `.env.example`
4. `nginx.conf` `/uploads/` 路径反代到 backend,或加 `location /uploads/ { root /volume1/...; }` 静态服务
5. `scripts/deploy.sh` 创建 `/volume1/docker/wecom-it-desk/uploads/` 目录(部署时)
**验收**:
- 容器重建后上传文件**不丢**
- `df -h` 看 host 上 `/volume1/.../uploads` 体积能涨
### P1-2. 消息状态字段走 Alembic 迁移
**问题**: `backend/app/models/message.py` 之前加了 `status` 字段(已发/已送达/已读/撤回/删除等),但 **alembic 迁移未生成**。
**修复**:
```bash
cd backend
alembic revision --autogenerate -m "add message status and recallable_until"
# 检查生成的迁移脚本
# 字段:
# - status: String(20), default="sent", nullable=False
# - recallable_until: DateTime, nullable=True
alembic upgrade head
```
**手动 SQL 不行**(评审报告已点出,部署步骤 6 引号未转义是历史错误)
**验收**:
- `alembic upgrade head` 不报错
- 生产数据库 `messages` 表有 `status` + `recallable_until` 字段
### P1-3. backend healthcheck 改用 Python 一行
**问题**: `docker-compose.yml` backend 用了 `curl http://localhost:8000/` 当 healthcheck,但**精简 backend 镜像没装 curl**(参考 [[backend-healthcheck-curl-pitfall]]),导致 `unhealthy` 但业务正常。
**修复**: 编辑 `docker-compose.yml`:
```yaml
backend:
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.connect(('localhost', 8000))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
或更稳(用 HTTP 检测):
```yaml
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/system/health').read()"]
# 需要 backend 有 /api/v1/system/health 端点(可能需要新增)
```
**验收**:
- `docker ps` 显示 backend `healthy`(不再 `unhealthy`)
- 业务正常
### P1-4. ws_manager 实现消息状态广播
**问题**: 文档承诺了"消息状态广播"(撤回/已读/删除等事件推送),但 `ws_manager.py` 实际**没实现**。
**修复**: 在 `backend/app/services/ws_manager.py` 加方法:
```python
async def broadcast_message_status(
self,
conv_id: str,
msg_id: str,
status: str,
extra: dict = None,
) -> int:
"""向会话所有参与方广播消息状态变更。
Args:
conv_id: 会话ID
msg_id: 消息ID
status: 新状态(sent / delivered / read / recalled / deleted)
extra: 额外数据(可选,如 recall_by / recall_at)
Returns:
推送到客户端数量
"""
# 1. 查会话所有参与方(agent_id + employee_id)
# 2. 找每个参与方的 WebSocket 连接
# 3. 发 JSON 消息 {"type": "message_status", "msg_id": ..., "status": ..., "extra": ...}
# 4. 返回推送数
...
```
调用方:`messages.py` `recall_message` / `delete_message` / `mark_read` 在改 DB 状态后,**调 `await ws_manager.broadcast_message_status(...)`**。
**验收**:
- 端到端测试:坐席 A 撤回消息 → 坐席 B + H5 员工实时收到 `message_status` 推送
- 前端(`useWebSocket.ts`)处理 `message_status` 类型消息(更新 UI)
## ▼▼▼ 任务清单止
---
## 🔄 工作流(等 workbuddy 修完 4 项后)
1. workbuddy 修完 → 提交 commit 到 Gitea
2. 通知 Claude 评审
3. Claude 评审(对照 4 项 + 跑相关测试)
4. 合并到 main
5. 关 #23
## 🔴 评审历史(防 workbuddy 再犯)
参考评审报告 `docs/评审报告/workbuddy-2026-06-14-消息优化.md` 9.5 节:
- **P0 比例 46% (6/13) 过高** —— 后续推送需**强制走评审流程**
- pre-commit 检查建议(Claude 可生成脚本):新增端点无 `Depends(...)` 鉴权 → 拒绝推送
- 4 P1 一旦 P0 修完就推,**不要在评审未消化前叠加新功能**
## 关联
- 评审主报告: `docs/评审报告/workbuddy-2026-06-14-消息优化.md`
- 风险跟踪表: 第九节(P1-1~4 状态追踪) + 即将加第十一节
- Claude 记忆: `review-messages-2026-06-14.md`
- Gitea 仓: `https://ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk` (公网 Funnel)
@@ -0,0 +1,209 @@
# workbuddy 批量任务清单 — 2026-06-14 睡前启动
**生成日期**: 2026-06-14
**生成人**: Claude
**启动条件**:
1. 用户在 Gitea 创 `workbuddy-claude` user account
2. 用户创 `workbuddy-claude` 的 access token(权限 `repository` + `issue` + `user`)
3. 用户把 token 配到 `.workbuddy/config.json``gitea.token` 字段
4. workbuddy 客户端启动时读这份 memory → 按顺序接任务
---
## ▶▶▶ 任务清单(5 项,按优先级)起
### W-1. P1-1 优化: named volume → host bind mount
**任务编号**: #25
**阻塞原因**: 当前 `docker-compose.yml` 用 named volume `backend-uploads`,容器重建不丢但 `docker-compose down -v` 会全丢
**目标**: 改成 host bind mount 到 NAS `/volume1/docker/wecom-it-desk/uploads`
**修复**:
1. 编辑 `docker-compose.yml`:
```yaml
volumes:
backend-uploads:
driver: local
driver_opts:
type: none
o: bind
device: /volume1/docker/wecom-it-desk/uploads
```
2. `scripts/deploy.sh` 部署时建 host 目录:
```bash
sudo mkdir -p /volume1/docker/wecom-it-desk/uploads
sudo chown -R 1000:1000 /volume1/docker/wecom-it-desk/uploads
```
3. 加 deploy 文档警示"别用 `docker-compose down -v`"
**验收**:
- 容器重建后上传文件不丢
- `df -h /volume1/docker/wecom-it-desk/uploads` 体积能涨
**评审员**: Claude
---
### W-2. P0 二次评审 5 遗留修完
**任务编号**: #18 遗留
**关联**: `docs/评审报告/workbuddy-2026-06-14-P0安全.md` 11.x 节(5 项遗留)
**5 项遗留**:
1. **浏览器 WS API 不支持 header** —— 用 `Sec-WebSocket-Protocol: bearer.<token>` 方案
2. **nginx access_log 没关** —— `location /ws/ { access_log off; }` 已修,验证部署版也有
3. **类型 bug** —— `ws.py` 某处类型断言错误
4. **降级放行** —— `agents.py` 缺 password 时,`existing_agent.password_hash` 已存在 → 必须 verify password,不能放行
5. **缺依赖** —— `requirements.txt` 缺 `bcrypt` / `pyotp`(已加,验证)
**修复**: 逐项对照评审报告修复,**每项单独 commit**
**验收**:
- 全部 5 项 commit 推 Gitea
- 评审员 Claude 二次评审通过
- 风险跟踪表 第九节 / 第十节 状态从 🟡 改 ✅
**评审员**: Claude
---
### W-3. pytest 基础配置 + 跑 pre-commit-check.sh
**任务编号**: README 已知问题 #2
**关联**: `scripts/pre-commit-check.sh`(本次新增,C-1 任务)
**修复**:
1. `backend/pytest.ini`(或 `pyproject.toml` [tool.pytest.ini_options]):
```ini
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -v --tb=short
```
2. `backend/tests/conftest.py`:
- 异步 client fixture
- 测试 DB(用 sqlite:///:memory:)
- mock WECOM 凭据
3. `backend/tests/test_agents.py`:
- 鉴权测试(mock_login 关闭 / 开启)
- password_hash 验证
4. `backend/tests/test_messages.py`:
- 5 个端点鉴权测试(P0-2~6)
5. `backend/tests/test_ws.py`:
- WS token 鉴权(Authorization header / subprotocol / query 三种)
6. `scripts/pre-commit-check.sh` 加进 `scripts/deploy.sh` 流程(可选)
**验收**:
- `cd backend && pytest` 跑过
- CI 跑预检脚本
- 评审员 Claude 看测试覆盖度
**评审员**: Claude
---
### W-4. Dify API 集成预研(POC)
**任务编号**: 阶段 3 启动前置(关联 `docs/路线图/阶段2-3-任务.md` §3.3)
**关联**: `docs/现有系统交接文档内容.txt` + `docs/ExternalSystemAdapter设计文档.md`
**预研目标**:
1. 查 Dify 工作流 API 文档(看是否需要新 app,还是共用)
2. POC 三个端点:
- `POST /v1/chat-messages` 流式对话
- `POST /v1/workflows/run` 工作流触发
- `POST /v1/datasets/{id}/retrieve` 知识库检索
3. 在 `backend/app/services/dify_client.py` 写 Dify 客户端
4. `backend/app/api/ai_wingman.py` 三个端点接 Dify 客户端
5. 写 `docs/集成验证/Dify_POC_报告.md`
**验收**:
- 三个端点跑通(返回 Dify 响应)
- 文档含 API 限流 / 错误降级 / 配额申请
- 评审员 Claude 看方案可行性
**评审员**: Claude
---
### W-5. nginx 配置审计(全局 access_log 检查)
**任务编号**: 新增(M-2 风险项 衍生)
**关联**: `docs/风险跟踪表.md` 第十二节 M-2
**审计目标**:
1. 扫描所有 `nginx.conf` / `deploy-server/nginx.conf` / `*/nginx.conf`
2. 找敏感路径(WS / token / OAuth callback)是否都 `access_log off`
3. 找未配 access_log off 但应配的路径
4. 写 `docs/审计报告/nginx_access_log_审计.md`
**修复**: 缺的补 `access_log off;`
**验收**:
- 审计报告列出所有敏感路径的 access_log 状态
- 缺的已补 commit
- 评审员 Claude 抽查 3 处
**评审员**: Claude
---
## ▼▼▼ 任务清单止
---
## 🔄 工作流(workbuddy 启动后)
1. **读这份 memory** → 看 5 任务
2. **按 W-1 → W-2 → W-3 → W-4 → W-5 顺序**(W-3 W-4 W-5 可并行)
3. **每完成一项**:
- 提交 commit(走 `scripts/pre-commit-check.sh`)
- 推 Gitea 远端 `feature/xxx` 分支
- 通知 Claude 评审
- Claude 评审通过 → 用户合并 PR
4. **状态同步**:
- `docs/风险跟踪表.md` 更新状态
- `.workbuddy/memory/{日期}-{主题}.md` 留评审记录
## ⚠️ 关键约束(读 README + CONTRIBUTING.md)
- **鉴权**: 新增/修改端点必须有 `Depends(get_current_agent)` 或 `_get_current_employee`
- **依赖**: 新增第三方 import 必须同步 `requirements.txt` / `package.json`
- **alembic**: model schema 变化必须生成迁移脚本
- **配置**: nginx / docker / conf 改动 plan 写完必须做完
- **评审报告**: 每次推送生成 `docs/评审报告/workbuddy-{日期}-{主题}.md`
- **5 项遗留**: 上一轮评审遗留未修完,不许推新功能
## 🔗 关联文档
- 评审主报告: `docs/评审报告/`
- 风险跟踪表: `docs/风险跟踪表.md` 第九/十/十一/十二节
- 路线图 2-3 阶段: `docs/路线图/阶段2-3-任务.md`
- 推送预检脚本: `scripts/pre-commit-check.sh`
- 推送流程: `CONTRIBUTING.md` §PR 流程
## 🆘 阻塞上报
workbuddy 启动后,**任何一项阻塞超过 30 分钟未推进** → 上报用户:
- token 问题 → 找用户
- 凭据不全 → 找用户给 WECOM_SECRET / Dify API key
- 测试失败定位 → 找 Claude
- 评审反复打回 3 次 → 升级用户
## 🛏️ 用户睡前最后做的事
1. **Gitea Web** → 站点管理 → 用户 → **创建新用户**:
- 用户名: `workbuddy-claude`
- 邮箱: (用户填)
- 密码: (临时,首次登录改)
- 权限: 普通用户(非管理员)
2. **用 simon token 创 workbuddy-claude 的 access token**:
- 登录 workbuddy-claude 账号 → 头像 → 设置 → 应用 → 创建
- 令牌名: `claude-push`
- 权限: `repository` (读/写) + `issue` (读/写) + `user` (读)
3. **把 workbuddy-claude token 粘给 Claude**:
- Claude 写进 `.workbuddy/config.json` 的 `gitea.token` 字段
- 同时配 Gitea Web 的 deploy key(ssh,可选)
4. (可选)改 `docs/风险跟踪表.md` 第十二节 §12.4 待办 #5 → `block_admin_merge` 改 `true`
完成上述 3 步 → workbuddy 客户端启动 → 自动接 5 任务
+7
View File
@@ -207,3 +207,10 @@
2. 坐席能力不稳定 → 阶段三 2. 坐席能力不稳定 → 阶段三
3. 知识无法积累传承 → 阶段四 3. 知识无法积累传承 → 阶段四
4. 管理缺乏数据支撑 → 阶段四 4. 管理缺乏数据支撑 → 阶段四
## workbuddy 任务清单索引 (2026-06-14)
- [批量任务清单](.workbuddy/memory/2026-06-14-批量任务.md) — W-1~W-5 workbuddy 任务
- [今夜收尾任务](.workbuddy/memory/2026-06-14-今夜-收尾任务.md) — T-1~T-4 Claude+workbuddy 协作
- [今夜满载任务](.workbuddy/memory/2026-06-14-今夜-满载任务.md) — 12小时满载排期
- [评审 Gitea 重建](docs/评审报告/workbuddy-2026-06-14-Gitea重建.md) — 卸载清空事件复盘
+147
View File
@@ -0,0 +1,147 @@
# 变更日志 (Changelog)
本项目的所有重要变更都会记录在此文件。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [未发布] - 2026-06-15
### 🔐 安全 (Security)
- P0:WS token 改走 `Sec-WebSocket-Protocol` subprotocol(已修)
- P0:坐席登录加 `password_hash` bcrypt 字段
- P0:`/ws/` 路径 nginx access_log 关闭
- P0:5 鉴权漏洞全部修复(消息 5 端点)
- WECOM_SECRET 集中化(待 NAS Vault)
- Gitea 凭据走 wincred,不入文件
### 🏗️ 基础设施 (Infrastructure)
- Gitea 自托管部署(Synology 套件 8418 端口)
- Tailscale Funnel 暴露给 workbuddy 沙箱
- 分支保护:main 需 PR + 1 reviewer
- workbuddy-claude 配 access token + 自动跑批
- 备份脚本(7 天保留 + cron 3 点)
### 📚 文档 (Documentation)
- 新增 8 份审计/设计报告(Dockerfile / ER / 依赖 / 健康检查 / CORS / 一键部署 / 健康度 / 惊喜汇总)
- 4 份 ADR(ADRs 001-004)
- 4 份 SOP(SOPs 001-004)
- 2 份路线图(阶段 1 盘点 + 阶段 4-5 规划)
- Wingman 设计文档
- 4 前端审计 + 16 项统一优化路线
### 🛠️ 工具链 (Tooling)
- `scripts/pre-commit-check.sh`:4 件套预检(鉴权+依赖+alembic+配置)
- `scripts/backup-gitea.sh`:Gitea 备份 + 恢复
- `scripts/security-audit.sh`:5 工具集成审计
- `scripts/generate-api-docs.sh`:OpenAPI + Swagger UI + ReDoc
- `scripts/dashboard.py`:项目健康度仪表盘
- `scripts/oneclick-deploy.sh`:一键部署
---
## [0.5.0] - 2026-05-30
### ✨ 新增 (Added)
- 阶段 1 完成度 66%(47 项功能盘点)
- H5 员工端完整功能(11 组件)
- 坐席工作台三栏(23 组件)
- 管理后台 13+ 视图
- 统一入口 portal
- WebSocket 实时通信
- WebSocket fallback 轮询
- Dify AI 集成(基础)
- 4 个外部系统集成(火绒/联软/aTrust/eHR)
- 快速回复 + 排障模板 + 待办事项
### 🐛 修复 (Fixed)
- 5 鉴权漏洞
- WS token 泄露到 URL 和日志
- 坐席登录缺 password
- Mock login bypass
### 📈 性能 (Performance)
- 4 前端路由级代码分割
- WebSocket 长连接(替代轮询)
- 模板缓存(Redis)
---
## [0.4.0] - 2026-04-15
### ✨ 新增
- RBAC 角色管理(user/agent/admin)
- 角色自动映射(企微标签 + eHR 字段)
- 配置变更日志(审计)
- 趣味话术(摇人/等待/接入)
- 审批流程链接
- 软件下载入口
### 🐛 修复
- 部门权限粒度
- 紧急度评分算法
- VIP 标记自动匹配
---
## [0.3.0] - 2026-03-01
### ✨ 新增
- AI 草稿回复(坐席采纳)
- AI 实质性回复计数
- 紧急度评分(1-5)
- 标签系统(举手/情绪/需介入)
- 影响范围评估
- 阻断性标记
---
## [0.2.0] - 2026-01-15
### ✨ 新增
- 4 前端基础架构(Vue 3 + Vite + TS + Pinia)
- 16 张数据表
- 核心 API(40+ 端点)
- OAuth2 企微登录
- 消息收发(文本/图片/文件/语音)
- 会话分配/抢单/转接
- 协作坐席(摇人)
- 邀请功能(P0-09~11)
---
## [0.1.0] - 2025-12-01
### ✨ 初始版本
- 项目初始化
- 基础 FastAPI 框架
- SQLAlchemy 2.0 + async
- Alembic 迁移
- Docker Compose 编排
- 4 前端工程搭建
- 企微回调基础
---
## 版本说明
- **0.x.y** - 阶段 1-5 演进(0.1-0.5 已发布,0.6+ 阶段 2 启动)
- **1.0.0** - 正式版目标(预计 2026-12,阶段 5 完成后)
## 图例
- ✨ 新增 - 新功能
- 🐛 修复 - Bug 修复
- 📈 性能 - 性能优化
- 🔐 安全 - 安全修复
- ⚠️ 弃用 - 即将移除
- 🏗️ 基础设施 - 部署/工具/流程
- 📚 文档 - 文档更新
- 🛠️ 工具链 - 工具脚本
[未发布]: https://gitea.simon.local/simon/wecom_it_smart_desk/compare/v0.5.0...HEAD
[0.5.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.5.0
[0.4.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.4.0
[0.3.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.3.0
[0.2.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.2.0
[0.1.0]: https://gitea.simon.local/simon/wecom_it_smart_desk/releases/tag/v0.1.0
+156
View File
@@ -0,0 +1,156 @@
# 企微IT智能服务台 — 项目状态看板
> 📌 **这个文件就是项目的"驾驶舱仪表盘"**。任何时候新开 session,**先读这个文件就懂上下文**。
>
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
最后更新:**2026-06-16 11:10**(Claude 自动维护,看板上一次刷新)
---
## 🎯 一句话总览
**项目状态**:**v0.5.6-dev-tooling 完成**,本地 4 端 dev 链路全通(Mock 企微 OAuth + 3 个新 migration + 1 个 decorator bug 修复)。
**当前主线**:**等用户决策要不要上生产**(生产 3 个 migration + 1 个 bug 修复可上,7 个 dev 改动留在本地)。
**待回复**:#83 OTM 是什么 / 跟项目什么关系。
---
## 🟢 正在做(in_progress,1 件)
| # | 任务 | 我做什么 | 你做什么 | 完成定义 |
|---|---|---|---|---|
| #90 | 后端 pytest 测试套件 | 补 token_service / scoring_service 等 | 等结果 | 20+ 测试通过 |
---
## 🔴 P0 必做(下一个 sprint)
| # | 任务 | 重要程度 | 说明 |
|---|---|---|---|
| #48 | v1.0 收窄 set_real_ip_from | 🔴 P0 | 现 allow 0.0.0.0/0 是临时方案,正式上线前必须改精确代理 IP |
| #81 | v0.6.0 敏感词检测 + 语气优化 | 🔴 P0 | 下一个版本的核心功能 |
| #80 | v0.5.4 应急页 nginx 路由 + 部署 | 🔴 P0 | 当前生产缺路由,功能上了但用户访问不到 |
---
## 🟡 P1 重要(看时间做)
| # | 任务 | 说明 |
|---|---|---|
| #73 | 修后端文件未真正覆盖 | `yes | cp -f` 路径,部署时偶尔没生效 |
| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 |
| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) |
| #83 | 澄清"OTM 跟项目关系" | **我在这等你回答**:OTM 是什么?需要对接吗? |
---
## 🟢 P2 / 等用户决策
| # | 任务 | 卡在哪 |
|---|---|---|
| **🆕 服务器更新?** | 把今天的 3 个 migration + 1 个 bug 修复部署到生产 v0.5.6 | **等你看这份看板后拍板** |
| #31 | 推 docker 镜像到生产 registry | 等你确认要走哪条路(自建 Harbor / 阿里云 / 别的) |
| #43 | 配置 HTTPS | 等域名备案完成 + 证书到位 |
| #53 | 用户在企微验证 /itportal/ | 等你去企微点一点 |
---
## ✅ 最近搞定(给你信心)
### 2026-06-16(今天)
#### 🛠️ Dev 环境(本地链路全通)
-**本地 dev 4 端链路跑通**(#89-92):
- backend (8000) + h5 (5174) + agent (5173) + admin (5175) + portal (5176) 全起
- Mock 企微 OAuth 全通(`/api/dev/login` 给 token)
- portal → H5 / 坐席 / 管理员 跳转正常
-**修了 3 个 dev 启动坑**:
1. `pydantic==2.7.5``2.7.4`(2.7.5 被 PyPI yank)
2. docker-compose 加 `PYTHONPATH=/app`(alembic 1.13+ 不再默认 prepend cwd)
3. dev 启动必须用 `--env-file .env.dev`(根 `.env` 冲突)
#### 🐛 Bug 修复
-**#93 修 portal dev 模式跳错端口**:`import.meta.env.DEV` 判断,生产走相对路径,dev 走完整 URL
-**#97 修 require_role 装饰器**:`@wraps` 让 FastAPI 看到 `__wrapped__` 签名,Depends 未被解析 → `current_user` 实际是 Depends 对象。用 `inspect` 合并 signature + 手动设 `wrapper.__signature__`
-**#99 dev 模式短路企微推送**:避免 `.env.dev``dev_corp_id_xxxxx` 调企微 API 返 `invalid corpid` 噪音
#### 🗃️ 数据库 migration(3 个)
-**#94 alembic 010**:加 `agents.otp_secret` + `agents.otp_enabled`
-**#94 alembic 011**:加 `conversations.impact_scope` + `is_blocking` + `emotion_state`(用户坐席发消息 500 的真因)
-**#96 alembic 012**:加 `conversations.dify_conversation_id` + `employees.it_level` + `it_level_source` + `notes`
#### 🛡️ 防错工具(留底用)
-**#95 dev-check-schema-drift.ps1**:对比 SQLAlchemy 模型 vs Postgres schema,漂移 exit 1。以后模型加字段忘 migration 一跑就发现(用 docker exec,免去 Python 依赖)
#### 📋 其他
-**#68 H5 空白页闪一下**:dev 模式验证不再白屏(生产未复测)
### 历史(选重点)
- ✅ v0.5.5:应急页 v0.5.4 + 移除 IT 设备升级 + admin 登录修复 + 内容审核架构
- ✅ v0.5.3:重打后端部署包(5 IT + 2 HR + 1 行政 + 1 财务 = 9 条)
- ✅ v0.5.6-dev-tooling 已 tag + push gitea(本地 dev 工具集)
- ✅ messages.id varchar=UUID SQL bug 修了(#60)+ 10 个回归测试通过
- ✅ nginx /api/admin/ 和 /itadmin/ 修复 403/allow(#57)
---
## 🚀 怎么跑起来(3 步)
### 1. 后端 dev(已经在跑 ✅)
```powershell
cd D:\资料\03-项目开发\wecom_it_smart_desk-claude
docker compose -f docker-compose.dev.yml --env-file .env.dev up -d
curl http://localhost:8000/api/dev/health
```
### 2. 前端 dev(已经在跑 ✅)
```powershell
# 一次性装 4 个前端依赖(已装好)
.\scripts\dev-frontend-install.ps1
# 之后:一起起所有前端
.\scripts\dev-frontend-start.ps1
# 单独停:.\scripts\dev-frontend-start.ps1 -Stop
```
### 3. 浏览器验证
- portal:http://localhost:5176/itportal/select
- H5:http://localhost:5174/itdesk/
- 坐席:http://localhost:5173/itagent/
- 管理员:http://localhost:5175/itadmin/
---
## 📌 怎么读这份文档
**你是运维小白,不需要懂代码**。看这个文件就能 1 分钟懂:
1. **"现在在干嘛?"** → 看「正在做」表
2. **"接下来要干嘛?"** → 看「P0 必做」表
3. **"我需要做什么?"** → 看「正在做」表里的「你做什么」列
4. **"今天有啥进展?"** → 看「最近搞定」
---
## 🤖 Claude 怎么帮你
每次开新 session 我会:
1. **第一件事**:读这个文件 + TaskList,告诉你"上次到这了"
2. **完成一件重要事**:更新这个文件(改状态、加完成项)
3. **遇到阻塞**:写在「P2 / 等用户决策」里,等你回话
4. **新需求进来**:跟当前 in_progress 比较,看是**接着做**还是**并行加**(参考你的"并行处理"反馈)
---
**这个文件就是你和 Claude 之间的"工作交接本"。有问题改这里就行。**
+1074
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微 IT 智能服务台 (IT Smart Desk) # 企微智能IT支持服务台 (IT Smart Desk)
> **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s > **环境状态**: 预生产(独立主机,共享域名)→ 正式环境迁移 K8s
> **维护者**: 税友集团 IT支持组(宋献) > **维护者**: 税友集团 IT支持组(宋献)
+46
View File
@@ -0,0 +1,46 @@
# =============================================================================
# 企微IT智能服务台 — 后端 开发镜像 Dockerfile
# =============================================================================
# 与 Dockerfile(prod) 区别:
# - 不需要 gcc / libpq-dev(用预编译的 psycopg2-binary)
# - 装 pytest 用于跑测试
# - 不需要 multi-stage build(开发用,镜像大一点无所谓)
# - 装 watchfiles 配合 uvicorn --reload
# =============================================================================
FROM python:3.12-slim
LABEL maintainer="IT服务台开发团队"
LABEL description="企微IT智能服务台后端 - 开发模式"
# 换 apt 源(公司内网,默认 deb.debian.org 可能不通)
RUN sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list 2>/dev/null || true
# 安装运行时依赖(精简版)
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 curl && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 换 PyPI 源 + 装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir \
--timeout 120 \
--retries 5 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt && \
pip install --no-cache-dir \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
pytest pytest-asyncio httpx watchfiles
# 复制项目代码(在 dev 模式下用 volume mount 覆盖)
COPY . .
EXPOSE 8000
# 默认命令(在 docker-compose.dev.yml 里覆盖)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
@@ -1,4 +1,4 @@
"""admin extension — 管理后台数据库扩展迁移 """admin ext — 管理后台数据库扩展迁移
新增 config_change_logs 配置变更日志 新增 config_change_logs 配置变更日志
扩展 agents 新增 role角色 skill_tags技能标签字段 扩展 agents 新增 role角色 skill_tags技能标签字段
@@ -8,16 +8,23 @@ submitted_by(提交人)字段。
Revision ID: 006_admin_ext Revision ID: 006_admin_ext
Revises: 005_reply_to_id Revises: 005_reply_to_id
Create Date: 2026-07-15 10:00:00.000000 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 from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '006_admin_ext' revision: str = '006_admin_ext'
down_revision = '005_reply_to_id' down_revision: Union[str, None] = '005_reply_to_id'
branch_labels = None branch_labels: Union[str, Sequence[str], None] = None
depends_on = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
@@ -113,4 +120,5 @@ def downgrade() -> None:
# 删除 config_change_logs 表索引和表 # 删除 config_change_logs 表索引和表
op.drop_index('idx_ccl_changed_at', table_name='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.drop_index('idx_ccl_config_key', table_name='config_change_logs')
op.table('config_change_logs')
op.drop_table('config_change_logs') op.drop_table('config_change_logs')
+2 -2
View File
@@ -5,7 +5,7 @@
新增 role_mapping_rules 表(角色映射规则)。 新增 role_mapping_rules 表(角色映射规则)。
预置三个基础角色:user、agent、admin。 预置三个基础角色:user、agent、admin。
Revision ID: 007_role_sys Revision ID: 007_role_system
Revises: 006_admin_ext Revises: 006_admin_ext
Create Date: 2026-06-12 23:00:00.000000 Create Date: 2026-06-12 23:00:00.000000
""" """
@@ -14,7 +14,7 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '007_role_sys' revision = '007_role_system'
down_revision = '006_admin_ext' down_revision = '006_admin_ext'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -0,0 +1,36 @@
"""add message status and recallable_until
Revision ID: 009_add_message_status
Revises:
Create Date: 2026-06-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009_add_message_status'
down_revision: Union[str, None] = '008_add_agent_password'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add status field
op.add_column(
'messages',
sa.Column('status', sa.String(20), nullable=False, server_default='sent')
)
# Add recallable_until field
op.add_column(
'messages',
sa.Column('recallable_until', sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column('messages', 'recallable_until')
op.drop_column('messages', 'status')
@@ -0,0 +1,56 @@
"""add agent OTP fields
Revision ID: 010_add_agent_otp
Revises: 009_add_message_status
Create Date: 2026-06-16
v0.5.6: 添加坐席 OTP 二次验证字段
- 新增 otp_secret 字段(存储 TOTP secret,绑定时生成)
- 新增 otp_enabled 字段(是否启用 OTP 二次验证)
- 都是 nullable=True,默认 False,不破坏现有坐席
为什么需要这个 migration:
Agent 模型里加了 otp_secret 和 otp_enabled 字段,
但没有对应的 alembic migration 把它落到 DB schema 里。
查询时报 UndefinedColumnError:
column agents.otp_secret does not exist
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '010_add_agent_otp'
down_revision = '009_add_message_status'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 otp_secret + otp_enabled 字段"""
op.add_column(
'agents',
sa.Column(
'otp_secret',
sa.String(64),
nullable=True,
comment='TOTP 密钥(base32,绑定时生成)'
)
)
op.add_column(
'agents',
sa.Column(
'otp_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否启用 OTP 二次验证'
)
)
def downgrade() -> None:
"""删除 OTP 字段"""
op.drop_column('agents', 'otp_enabled')
op.drop_column('agents', 'otp_secret')
@@ -0,0 +1,69 @@
"""add conversation impact fields
Revision ID: 011_add_conversation_impact
Revises: 010_add_agent_otp
Create Date: 2026-06-16
v0.5.6: 补齐 Conversation 模型的 3 个评估字段
- impact_scope (int, default 0): 影响范围(受影响人数)
- is_blocking (bool, default False): 是否阻断员工工作
- emotion_state (str(20), default 'normal'): 情绪状态
为什么需要这个 migration:
Conversation 模型里加了 impact_scope/is_blocking/emotion_state,
但缺 alembic migration 落库。坐席发消息时 SQLAlchemy 查
conversations.* 全字段,报:
column conversations.impact_scope does not exist
跟 010_add_agent_otp 是同一类问题(模型新字段无 migration)。
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '011_add_conversation_impact'
down_revision = '010_add_agent_otp'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 impact_scope + is_blocking + emotion_state 字段"""
op.add_column(
'conversations',
sa.Column(
'impact_scope',
sa.Integer(),
nullable=False,
server_default=sa.text('0'),
comment='影响范围(受影响人数,0=未评估)'
)
)
op.add_column(
'conversations',
sa.Column(
'is_blocking',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否阻断员工工作'
)
)
op.add_column(
'conversations',
sa.Column(
'emotion_state',
sa.String(20),
nullable=False,
server_default=sa.text("'normal'"),
comment='情绪状态(normal/worried/angry/urgent)'
)
)
def downgrade() -> None:
"""删除 3 个评估字段"""
op.drop_column('conversations', 'emotion_state')
op.drop_column('conversations', 'is_blocking')
op.drop_column('conversations', 'impact_scope')
@@ -0,0 +1,87 @@
"""sync remaining model fields
Revision ID: 012_sync_remaining_fields
Revises: 011_add_conversation_impact
Create Date: 2026-06-16
v0.5.6: 补齐 dev-check-schema-drift 找到的 4 个漂移字段
- conversations.dify_conversation_id (VARCHAR(128), nullable)
- employees.it_level (VARCHAR(20), default 'silver')
- employees.it_level_source (VARCHAR(20), default 'system')
- employees.notes (JSON, default '{}')
为什么需要这个 migration:
之前手动 011 只补了 NOT NULL 那些(坐席发消息会 500 的),
但 dev-check-schema-drift.ps1 又发现 4 个字段也没建 migration。
之前是 nullable 没立即暴露,运行 SELECT * FROM conversations 时
PostgreSQL 会按顺序填,nullable 列缺不会立刻 500,但 INSERT/UPDATE
涉及这些字段时会出错,或者 Alembic autogenerate 会持续报告漂移。
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '012_sync_remaining_fields'
down_revision = '011_add_conversation_impact'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""加 4 个漂移字段"""
# 1) conversations.dify_conversation_id - Dify 多轮对话上下文
op.add_column(
'conversations',
sa.Column(
'dify_conversation_id',
sa.String(128),
nullable=True,
comment='Dify会话ID(多轮对话上下文)'
)
)
# 2) employees.it_level - IT 技能等级
op.add_column(
'employees',
sa.Column(
'it_level',
sa.String(20),
nullable=False,
server_default=sa.text("'silver'"),
comment='IT技能等级(bronze/silver/gold/platinum/diamond/star/king)'
)
)
# 3) employees.it_level_source - 等级来源
op.add_column(
'employees',
sa.Column(
'it_level_source',
sa.String(20),
nullable=False,
server_default=sa.text("'system'"),
comment='等级来源(system/manual/assessment)'
)
)
# 4) employees.notes - 坐席备注 JSON
op.add_column(
'employees',
sa.Column(
'notes',
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
comment='坐席备注(JSON 格式)'
)
)
def downgrade() -> None:
"""删除 4 个字段"""
op.drop_column('employees', 'notes')
op.drop_column('employees', 'it_level_source')
op.drop_column('employees', 'it_level')
op.drop_column('conversations', 'dify_conversation_id')
+9
View File
@@ -0,0 +1,9 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台 API 子包
# =============================================================================
# 包标记文件
# 2026-06-16 添加: 修复与同名文件 app/api/admin.py 冲突
# 背景: router.py 引用 from app.api.admin.security_comparison import router
# Python 优先选 admin.py 当 module,导致 admin/ 目录被忽略
# 加上此文件后,admin/ 目录被识别为正式 package,优先于同名 .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_api 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__)
+16 -19
View File
@@ -36,6 +36,7 @@ from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
from app.utils.error_codes import ErrorCode
# 速率限制器实例(与 main.py 共享同一配置) # 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数 # 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
@@ -211,30 +212,24 @@ async def agent_login(
if not existing_agent: if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充 # 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException( raise AppException(
1003, ErrorCode.AUTH_TOKEN_INVALID,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。" "企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
) )
logger.warning( logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}" f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
) )
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码 # P0 修复: 降级放行时,如果 agent 已设置密码则必须验证本地密码
if existing_agent and existing_agent.password_hash: if existing_agent:
if existing_agent.password_hash is None:
# 已注册坐席但未设置密码,要求先设置密码
raise AppException(
ErrorCode.AUTH_PASSWORD_REQUIRED,
"首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码"
)
if not body.password: if not body.password:
raise AppException(1011, "请输入本地密码") raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "请输入本地密码")
if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')): if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')):
raise AppException(1011, "本地密码错误") raise AppException(ErrorCode.AUTH_PASSWORD_WRONG, "本地密码错误")
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
# 检查是否需要本地密码验证
local_password_verified = False
if body.password and agent and agent.password_hash:
# 验证本地密码
if bcrypt.checkpw(body.password.encode('utf-8'), agent.password_hash.encode('utf-8')):
local_password_verified = True
logger.info(f"本地密码验证通过: user_id={body.user_id}")
else:
# 本地密码错误,拒绝登录
raise AppException(1011, "本地密码错误")
# 1. 查找或创建坐席记录 # 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id) stmt = select(Agent).where(Agent.user_id == body.user_id)
@@ -571,9 +566,11 @@ async def update_agent_password(
# 如果已有旧密码,验证旧密码 # 如果已有旧密码,验证旧密码
if agent.password_hash: if agent.password_hash:
if not body.old_password: if not body.old_password:
raise AppException(1012, "请输入旧密码") # 2026-06-15 修复: 改用专用 ErrorCode,避免与登录 1012 冲突
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_REQUIRED, "请输入旧密码")
if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')): if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')):
raise AppException(1013, "旧密码错误") # 2026-06-15 修复: 改用专用 ErrorCode
raise AppException(ErrorCode.AUTH_OLD_PASSWORD_WRONG, "旧密码错误")
# 设置新密码 # 设置新密码
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+172
View File
@@ -0,0 +1,172 @@
# =============================================================================
# IT智能服务台 — 审批流程 API
# =============================================================================
# 说明:提供审批模板管理和跳转链接生成
# - 模板124(资源申请):跳转审批
# - 模板122(设备申请):API提交
# =============================================================================
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
router = APIRouter()
# =============================================================================
# 审批模板配置(可配置化,后续可存入数据库)
# =============================================================================
# =============================================================================
# 企微审批模板配置(从环境变量读取)
# =============================================================================
# 环境变量:
# APPROVAL_TEMPLATE_RESOURCE - 资源申请模板ID
# APPROVAL_TEMPLATE_DEVICE - 设备申请模板ID
import os
APPROVAL_TEMPLATE_RESOURCE = os.getenv("APPROVAL_TEMPLATE_RESOURCE", "")
APPROVAL_TEMPLATE_DEVICE = os.getenv("APPROVAL_TEMPLATE_DEVICE", "")
# 动态构建审批模板配置
APPROVAL_TEMPLATES = {}
if APPROVAL_TEMPLATE_RESOURCE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_RESOURCE] = {
"id": APPROVAL_TEMPLATE_RESOURCE,
"name": "资源申请",
"type": "jump", # 跳转审批
"keywords": ["申请资源", "要资源", "申请"],
}
if APPROVAL_TEMPLATE_DEVICE:
APPROVAL_TEMPLATES[APPROVAL_TEMPLATE_DEVICE] = {
"id": APPROVAL_TEMPLATE_DEVICE,
"name": "设备申请",
"type": "api", # API提交
"keywords": ["申请设备", "要设备", "电脑", "笔记本"],
}
# =============================================================================
# Schema 定义
# =============================================================================
class ApprovalTemplateResponse(BaseModel):
"""审批模板响应"""
id: str
name: str
type: str # "jump" 或 "api"
keywords: list[str]
class ApprovalJumpRequest(BaseModel):
"""跳转审批请求"""
template_id: str
employee_id: Optional[str] = None
class ApprovalJumpResponse(BaseModel):
"""跳转审批响应"""
url: str
template_name: str
class ApprovalSubmitRequest(BaseModel):
"""API提交审批请求"""
template_id: str
employee_id: str
content: dict # 审批内容
class ApprovalSubmitResponse(BaseModel):
"""API提交审批响应"""
sp_no: str # 审批单号
template_name: str
# =============================================================================
# API 端点
# =============================================================================
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
async def get_approval_templates():
"""获取所有审批模板列表"""
return list(APPROVAL_TEMPLATES.values())
@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse)
async def get_approval_template(template_id: str):
"""获取指定审批模板详情"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
return APPROVAL_TEMPLATES[template_id]
@router.post("/approval/jump", response_model=ApprovalJumpResponse)
async def create_approval_jump(request: ApprovalJumpRequest):
"""生成跳转审批链接(模板124跳转方式)"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "jump":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
# 生成跳转URL(企微审批链接格式)
# 实际URL需要根据企微配置生成
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
return ApprovalJumpResponse(
url=jump_url,
template_name=template["name"],
)
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
async def submit_approval(request: ApprovalSubmitRequest):
"""API提交审批(模板122 API方式)"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "api":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持API提交")
# TODO: 调用企微API提交审批
# 这里需要使用企微access_token调用审批API
# 实际实现需要根据企微审批API文档
return ApprovalSubmitResponse(
sp_no=f"SP{request.template_id[:8]}", # 模拟审批单号
template_name=template["name"],
)
@router.get("/approval/keywords")
async def get_approval_keywords():
"""获取所有审批关键词(用于前端关键词检测)"""
keywords = []
for template in APPROVAL_TEMPLATES.values():
for kw in template["keywords"]:
keywords.append(
{
"keyword": kw,
"template_id": template["id"],
"template_name": template["name"],
"type": template["type"],
}
)
return keywords
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# 企微IT智能服务台 — 开发模式 Mock 登录
# =============================================================================
# ⚠️ 警告:此模块只在 DEV_MODE=true 时可用
# - 仅供本地开发 / 集成测试使用
# - 生产环境(DEV_MODE 未设置或 false)会直接 403
# - 部署前必须确认 .env / .env.production 没有 DEV_MODE=true
# 用法:
# GET /api/dev/login?userid=dev-user-001&name=测试&role=user
# GET /api/dev/users # 列出所有预设 dev 用户
# =============================================================================
import logging
import os
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query
from app.config import settings
from app.dependencies import get_redis
from app.services.token_service import TokenService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dev", tags=["dev-mock"])
def _dev_mode_enabled() -> bool:
"""检查是否启用了开发模式。
三个检查源(任一为 true 即启用):
1. 环境变量 DEV_MODE=true
2. settings.dev_mode(从 .env.dev 读)
3. DEBUG 模式 + 本地主机(最严格)
"""
env_val = os.getenv("DEV_MODE", "false").lower() == "true"
if env_val:
return True
# 兜底:从 settings 读
if hasattr(settings, "dev_mode") and getattr(settings, "dev_mode", False):
return True
return False
# -----------------------------------------------------------------------------
# 预设 dev 用户(便于测试不同角色)
# -----------------------------------------------------------------------------
PRESET_DEV_USERS = [
{"userid": "dev-user-001", "name": "张三(普通员工)", "role": "user", "department": "财务部"},
{"userid": "dev-agent-001", "name": "李四(IT 坐席)", "role": "agent", "department": "信息技术部"},
{"userid": "dev-supervisor-001", "name": "王五(部门主管)", "role": "supervisor", "department": "信息技术部"},
{"userid": "dev-security-001", "name": "赵六(安全团队)", "role": "security", "department": "信息安全部"},
{"userid": "dev-admin-001", "name": "钱七(系统管理员)", "role": "admin", "department": "信息技术部"},
{"userid": "dev-multi-001", "name": "周八(多角色测试)", "role": "user,agent,supervisor", "department": "测试部"},
]
# -----------------------------------------------------------------------------
# GET /api/dev/login — Mock 登录(返回 token)
# -----------------------------------------------------------------------------
@router.get("/login")
async def dev_login(
userid: str = Query("dev-user-001", description="用户 ID(模拟企微 userid)"),
name: str = Query("开发测试用户", description="用户姓名"),
role: str = Query("user", description="角色:user/agent/admin/supervisor/security,多个用逗号分隔"),
department: str = Query("信息技术部", description="部门"),
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
redis: aioredis.Redis = Depends(get_redis),
):
"""开发模式 Mock 登录。
用法:
GET /api/dev/login?userid=dev-agent-001&name=李四&role=agent
返回:
{
"code": 0,
"data": {
"token": "abc123...",
"user": { "userid": "...", "name": "...", "roles": [...] }
}
}
"""
if not _dev_mode_enabled():
logger.warning("🚨 /api/dev/login 被调用但 DEV_MODE 未启用,返回 403")
raise HTTPException(
status_code=403,
detail="DEV_MODE not enabled. Set DEV_MODE=true in .env.dev to use this endpoint."
)
# 解析多角色
roles = [r.strip() for r in role.split(",") if r.strip()]
if not roles:
roles = ["user"]
# 调 TokenService 创建 token(走完全真实的 token 流程)
token_service = TokenService(redis)
token = await token_service.create_token(
employee_id=userid,
name=name,
roles=roles,
department=department,
avatar=avatar or "",
login_source="dev",
)
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
return {
"code": 0,
"message": "ok",
"data": {
"token": token,
"user": {
"userid": userid,
"name": name,
"department": department,
"avatar": avatar or "",
"roles": roles,
"login_source": "dev",
},
},
}
# -----------------------------------------------------------------------------
# GET /api/dev/users — 列出所有预设 dev 用户
# -----------------------------------------------------------------------------
@router.get("/users")
async def dev_list_users():
"""列出所有预设 dev 用户(便于前端测试用)。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return {
"code": 0,
"message": "ok",
"data": PRESET_DEV_USERS,
}
# -----------------------------------------------------------------------------
# GET /api/dev/health — 检查 dev 模式状态
# -----------------------------------------------------------------------------
@router.get("/health")
async def dev_health():
"""检查 dev 模式是否启用 + 关键依赖。"""
if not _dev_mode_enabled():
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
return {
"code": 0,
"data": {
"dev_mode": True,
"env": os.getenv("APP_ENV", "unknown"),
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
"redis_url": os.getenv("REDIS_URL", "not set"),
"preset_users": len(PRESET_DEV_USERS),
},
}
+7 -4
View File
@@ -829,18 +829,21 @@ async def h5_poll_messages(
).order_by(Message.created_at.asc()) ).order_by(Message.created_at.asc())
if after_message_id: if after_message_id:
# 转换为UUID类型查询,确保和数据库UUID字段类型匹配 # 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型匹配)
from uuid import UUID as UUIDType from uuid import UUID as UUIDType
try: try:
msg_uuid = UUIDType(after_message_id) UUIDType(after_message_id) # 仅校验
except ValueError: except ValueError:
# 无效的UUID格式返回空列表 # 无效的UUID格式,返回空列表
items = [] items = []
return success_response(data={"items": items, "has_more": False}) 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( 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_result = await db.execute(after_stmt)
after_time = after_result.scalar_one_or_none() after_time = after_result.scalar_one_or_none()
+5 -1
View File
@@ -200,9 +200,13 @@ async def send_message(
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看) # image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时 # 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text": if body.msg_type == "text":
# dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音
from app.config import settings
if getattr(settings, 'dev_mode', False):
logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}")
else:
try: try:
import redis.asyncio as aioredis import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client() redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client) wecom_service = WecomService(redis_client)
+24 -1
View File
@@ -21,9 +21,12 @@ from app.api.todo_items import router as todo_items_router
from app.api.troubleshooting_templates import router as troubleshooting_templates_router from app.api.troubleshooting_templates import router as troubleshooting_templates_router
from app.api.employees import router as employees_router from app.api.employees import router as employees_router
from app.api.upload import router as upload_router from app.api.upload import router as upload_router
from app.api.admin import router as admin_router from app.api.admin_api import router as admin_router
from app.api.portal import router as portal_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_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 路由器 # 创建 API 路由器
# 所有子路由都会挂载到这个路由器上 # 所有子路由都会挂载到这个路由器上
@@ -155,3 +158,23 @@ api_router.include_router(portal_router, tags=["统一入口"])
# POST /api/admin/roles/mapping-rules — 创建映射规则 # POST /api/admin/roles/mapping-rules — 创建映射规则
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则 # DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
api_router.include_router(admin_roles_router, tags=["角色管理"]) 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} — 获取审批模板详情
# POST /api/approval/jump — 生成跳转审批链接
# 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"])
+181
View File
@@ -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"}
)
+12 -11
View File
@@ -20,7 +20,6 @@
import logging import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from starlette.requests import Request
from app.services.ws_manager import manager as ws_manager from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service from app.services.cache_service import cache_service
@@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001
async def websocket_endpoint( async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
agent_id: str, agent_id: str,
request: Request,
) -> None: ) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。 """坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
@@ -61,10 +59,12 @@ async def websocket_endpoint(
- 兼容从 ?token= URL 参数获取(向后兼容) - 兼容从 ?token= URL 参数获取(向后兼容)
- 不再将 token 暴露在 URL 中,避免 access_log 泄露 - 不再将 token 暴露在 URL 中,避免 access_log 泄露
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args: Args:
websocket: FastAPI WebSocket 对象(框架自动注入) websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取) agent_id: 坐席ID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# WS-01: Token 认证(从 subprotocol / header / query 获取) # WS-01: Token 认证(从 subprotocol / header / query 获取)
@@ -74,17 +74,17 @@ async def websocket_endpoint(
# 格式: Sec-WebSocket-Protocol: bearer.{token} # 格式: Sec-WebSocket-Protocol: bearer.{token}
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组) # 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
# 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取 # 前端用 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."): if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀 token = subprotocol[7:] # 去掉 "bearer." 前缀
else: else:
# 其次从 Authorization header 获取 # 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
else: else:
# 向后兼容:从 query param 获取(即将废弃) # 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "") token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空 # 步骤2: 检查 token 是否为空
if not token: if not token:
@@ -197,7 +197,6 @@ async def websocket_endpoint(
async def h5_websocket_endpoint( async def h5_websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
employee_id: str, employee_id: str,
request: Request,
) -> None: ) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。 """H5员工 WebSocket 端点主循环(含 token 认证)。
@@ -223,10 +222,12 @@ async def h5_websocket_endpoint(
- (与H5登录 API /api/h5/mock-login 存储格式一致) - (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接 - token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
Args: Args:
websocket: FastAPI WebSocket 对象(框架自动注入) websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取) employee_id: 员工企微 UserID(从 URL 路径参数获取)
request: Starlette Request(用于获取 header
""" """
# ====================================================================== # ======================================================================
# Token 认证(从 subprotocol / header / query 获取) # Token 认证(从 subprotocol / header / query 获取)
@@ -234,17 +235,17 @@ async def h5_websocket_endpoint(
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容) # 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
# 格式: Sec-WebSocket-Protocol: bearer.{token} # 格式: Sec-WebSocket-Protocol: bearer.{token}
subprotocol = request.headers.get("sec-websocket-protocol", "") subprotocol = websocket.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."): if subprotocol.startswith("bearer."):
token = subprotocol[7:] # 去掉 "bearer." 前缀 token = subprotocol[7:] # 去掉 "bearer." 前缀
else: else:
# 其次从 Authorization header 获取 # 其次从 Authorization header 获取
auth_header = request.headers.get("Authorization", "") auth_header = websocket.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] # 去掉 "Bearer " 前缀 token = auth_header[7:] # 去掉 "Bearer " 前缀
else: else:
# 向后兼容:从 query param 获取(即将废弃) # 向后兼容:从 query param 获取(即将废弃)
token = request.query_params.get("token", "") token = websocket.query_params.get("token", "")
# 步骤2: 检查 token 是否为空 # 步骤2: 检查 token 是否为空
if not token: if not token:
+44
View File
@@ -99,6 +99,50 @@ class Settings(BaseSettings):
# 是否启用 Mock 登录(默认 false,生产环境必须关闭) # 是否启用 Mock 登录(默认 false,生产环境必须关闭)
mock_login_enabled: bool = False mock_login_enabled: bool = False
# ----------------------------------------------------------------------
# 开发模式配置(本地 docker-compose.dev.yml 用)
# ----------------------------------------------------------------------
# 是否启用开发模式(本地开发环境,启用后挂载 /api/dev/* Mock OAuth 路由)
# ⚠️ 生产环境必须为 false / 不设置
# 启用的副作用:
# 1. 后端启动时挂载 /api/dev/login /users /health 三个 Mock 端点
# 2. /api/dev/login 跳过企微 OAuth 直接生成 token
# 3. 启动日志会大声警告 "🧪 DEV_MODE enabled"
dev_mode: bool = False
# 开发模式默认 userid(本地前端兜底用,实际由前端 /api/dev/login 传入)
dev_default_userid: str = "dev-user-001"
# 开发模式默认姓名
dev_default_name: str = "开发测试用户"
# 开发模式默认部门
dev_default_dept: str = "信息技术部"
# ----------------------------------------------------------------------
# 审批模板配置(企微审批应用)
# ----------------------------------------------------------------------
# 资源申请审批模板ID(在企微审批应用设置中获取)
approval_template_resource: str = ""
# 设备申请审批模板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 配置 # Pydantic-settings 配置
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
+22 -5
View File
@@ -7,6 +7,7 @@
# 3. require_admin: 管理员权限验证 # 3. require_admin: 管理员权限验证
# ============================================================================= # =============================================================================
import inspect
import json import json
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
@@ -225,12 +226,26 @@ def require_role(*required_roles: str):
""" """
def decorator(func): def decorator(func):
# 合并 func 签名 + current_user 参数,让 FastAPI 能正确解析 Depends
# (v0.5.6 修复:之前用 @wraps,FastAPI 看到的是 __wrapped__ 的签名,
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
sig = inspect.signature(func)
params = list(sig.parameters.values())
params.append(
inspect.Parameter(
'current_user',
inspect.Parameter.KEYWORD_ONLY,
annotation=UserInfo,
default=Depends(get_current_user),
)
)
new_sig = sig.replace(parameters=params)
@wraps(func) @wraps(func)
async def wrapper( async def wrapper(*args, **kwargs):
*args, # FastAPI 已经把 current_user 注入了 kwargs
current_user: UserInfo = Depends(get_current_user), current_user = kwargs.pop('current_user')
**kwargs,
):
# 检查用户是否有任一所需角色 # 检查用户是否有任一所需角色
user_roles = set(current_user.roles) user_roles = set(current_user.roles)
required = set(required_roles) required = set(required_roles)
@@ -247,6 +262,8 @@ def require_role(*required_roles: str):
return await func(*args, current_user=current_user, **kwargs) return await func(*args, current_user=current_user, **kwargs)
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
wrapper.__signature__ = new_sig
return wrapper return wrapper
return decorator return decorator
+310 -8
View File
@@ -12,10 +12,13 @@
import json import json
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select, text
# 导入配置(读取环境变量) # 导入配置(读取环境变量)
from app.config import settings from app.config import settings
@@ -35,6 +38,30 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# 开发模式判定(模块级 helper,避免在 create_app 内每次重复 import)
# --------------------------------------------------------------------------
def _is_dev_mode() -> bool:
"""检查是否启用了开发模式(DEV_MODE=true)。
三个检查源(任一为 true 即启用):
1. 环境变量 DEV_MODE=true(最高优先级,Docker 注入)
2. settings.dev_mode(从 .env.dev 读)
3. DEBUG 模式 + 本地主机(最严格)
注意:此函数与 backend/app/api/dev_auth.py 内的 _dev_mode_enabled() 逻辑一致,
这里用于"是否挂载 dev_auth 路由",那里用于"端点内是否放行"
"""
import os
env_val = os.getenv("DEV_MODE", "").lower() == "true"
if env_val:
return True
if getattr(settings, "dev_mode", False):
return True
return False
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 应用生命周期管理(启动和关闭事件) # 应用生命周期管理(启动和关闭事件)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -153,6 +180,7 @@ async def _init_default_data():
3. quick_reply_templates — 快速回复模板 3. quick_reply_templates — 快速回复模板
4. approval_links — 审批流程链接 4. approval_links — 审批流程链接
5. software_downloads — 软件下载入口 5. software_downloads — 软件下载入口
6. (dev 模式)demo_conversations — 演示用会话,让前端有数据可发
只在表为空时插入,避免重复插入。 只在表为空时插入,避免重复插入。
""" """
@@ -162,6 +190,7 @@ async def _init_default_data():
from app.models.quick_reply_template import QuickReplyTemplate from app.models.quick_reply_template import QuickReplyTemplate
from app.models.approval_link import ApprovalLink from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload from app.models.software_download import SoftwareDownload
from app.config import settings
async_session_factory = _get_session_factory() async_session_factory = _get_session_factory()
async with async_session_factory() as db: async with async_session_factory() as db:
@@ -181,6 +210,11 @@ async def _init_default_data():
# 5. 初始化软件下载入口 # 5. 初始化软件下载入口
await _init_software_downloads(db, SoftwareDownload) await _init_software_downloads(db, SoftwareDownload)
# 6. (dev 模式)初始化 demo 会话,让前端有数据可发
# 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
await _init_demo_conversations(db)
await db.commit() await db.commit()
logger.info("默认数据初始化完成") logger.info("默认数据初始化完成")
@@ -189,6 +223,162 @@ async def _init_default_data():
logger.error(f"默认数据初始化失败: {e}") logger.error(f"默认数据初始化失败: {e}")
async def _init_demo_conversations(db):
"""(dev 模式专用)建 5 条 demo 会话,让前端有数据可测。
涵盖各种状态:
- ai_handling: AI 正在处理(2 条,不同员工)
- queued: 等坐席接手
- serving: 坐席服务中
- resolved: 已结单
只在 conversations 表为空时建,避免重复。
"""
from app.models.conversation import Conversation
existing = (await db.execute(select(Conversation).limit(1))).scalar_one_or_none()
if existing:
logger.info("demo 会话已存在,跳过")
return
import uuid as _uuid
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
demo_convs = [
{
"id": "conv-001",
"corp_id": "wwa8c87970b2011f41",
"employee_id": "dev-user-001",
"employee_name": "张三(普通员工)",
"department": "财务部",
"position": "会计",
"level": "P5",
"status": "ai_handling",
"is_vip": False,
"is_pinned": False,
"is_todo": False,
"urgency_score": 30,
"tags": ["财务", "IT"],
"assigned_agent_id": None,
"collaborating_agent_ids": [],
"participants": [],
"ai_substantive_reply_count": 0,
"impact_scope": 1,
"is_blocking": False,
"emotion_state": "normal",
"dify_conversation_id": None,
"last_message_at": now - timedelta(minutes=2),
"last_message_summary": "想问下 VPN 怎么连",
},
{
"id": "conv-002",
"corp_id": "wwa8c87970b2011f41",
"employee_id": "dev-user-001",
"employee_name": "张三(普通员工)",
"department": "财务部",
"position": "会计",
"level": "P5",
"status": "queued",
"is_vip": False,
"is_pinned": True,
"is_todo": True,
"urgency_score": 70,
"tags": ["紧急", "VPN"],
"assigned_agent_id": None,
"collaborating_agent_ids": [],
"participants": [],
"ai_substantive_reply_count": 2,
"impact_scope": 3,
"is_blocking": True,
"emotion_state": "worried",
"dify_conversation_id": "dify-conv-002",
"last_message_at": now - timedelta(minutes=5),
"last_message_summary": "VPN 连不上,影响工作",
},
{
"id": "conv-003",
"corp_id": "wwa8c87970b2011f41",
"employee_id": "dev-multi-001",
"employee_name": "周八(多角色测试)",
"department": "测试部",
"position": "测试工程师",
"level": "P6",
"status": "serving",
"is_vip": True,
"is_pinned": False,
"is_todo": False,
"urgency_score": 50,
"tags": ["软件安装"],
"assigned_agent_id": "dev-agent-001",
"collaborating_agent_ids": [],
"participants": [],
"ai_substantive_reply_count": 1,
"impact_scope": 1,
"is_blocking": False,
"emotion_state": "normal",
"dify_conversation_id": "dify-conv-003",
"last_message_at": now - timedelta(minutes=10),
"last_message_summary": "需要装 WPS 专业版",
},
{
"id": "conv-004",
"corp_id": "wwa8c87970b2011f41",
"employee_id": "dev-supervisor-001",
"employee_name": "王五(部门主管)",
"department": "信息技术部",
"position": "主管",
"level": "M3",
"status": "serving",
"is_vip": True,
"is_pinned": True,
"is_todo": False,
"urgency_score": 80,
"tags": ["系统升级"],
"assigned_agent_id": "dev-agent-001",
"collaborating_agent_ids": ["dev-admin-001"],
"participants": [],
"ai_substantive_reply_count": 3,
"impact_scope": 50,
"is_blocking": True,
"emotion_state": "urgent",
"dify_conversation_id": "dify-conv-004",
"last_message_at": now - timedelta(minutes=15),
"last_message_summary": "ERP 系统升级咨询",
},
{
"id": "conv-005",
"corp_id": "wwa8c87970b2011f41",
"employee_id": "dev-security-001",
"employee_name": "赵六(安全团队)",
"department": "信息安全部",
"position": "安全工程师",
"level": "P7",
"status": "resolved",
"is_vip": False,
"is_pinned": False,
"is_todo": False,
"urgency_score": 20,
"tags": ["安全"],
"assigned_agent_id": "dev-agent-001",
"collaborating_agent_ids": [],
"participants": [],
"ai_substantive_reply_count": 5,
"impact_scope": 1,
"is_blocking": False,
"emotion_state": "normal",
"dify_conversation_id": "dify-conv-005",
"last_message_at": now - timedelta(hours=2),
"last_message_summary": "已处理:密码策略咨询",
},
]
for data in demo_convs:
db.add(Conversation(**data))
logger.info(f"已初始化 {len(demo_convs)} 条 demo 会话(仅 dev 模式)")
async def _init_system_configs(db, SystemConfig): async def _init_system_configs(db, SystemConfig):
"""初始化系统配置项。""" """初始化系统配置项。"""
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -288,14 +478,29 @@ async def _init_approval_links(db, ApprovalLink):
return return
links = [ links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1), # v0.5.2:一站式运维平台真实工单链接(域名 devops.dc.servyou-it.com,已实现企微免登录)
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2), # v0.5.3 更新:去掉 "IT设备升级与硬件维修" (申请单冲突,后续移除)
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3), ApprovalLink(category="IT", title="零信任(原VPN)账号申请",
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4), 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",
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5), sort_order=1),
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6), ApprovalLink(category="IT", title="活动与会议技术支持",
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7), 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",
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8), 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) db.add_all(links)
@@ -475,6 +680,30 @@ def create_app() -> FastAPI:
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀 # 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
app.include_router(api_router) app.include_router(api_router)
# ----------------------------------------------------------------------
# 开发模式 Mock OAuth(仅 DEV_MODE=true 时挂载)
# ----------------------------------------------------------------------
# ⚠️ 生产环境严禁启用(DEV_MODE=false 或不设置)
# 挂载的端点:
# GET /api/dev/login — Mock 登录,跳过企微 OAuth 直接返回 token
# GET /api/dev/users — 列出预设 dev 用户
# GET /api/dev/health — dev 模式状态自检
# 即使挂载了,每个端点内部也会再 _dev_mode_enabled() 二次校验
# ----------------------------------------------------------------------
if _is_dev_mode():
from app.api.dev_auth import router as dev_auth_router
app.include_router(dev_auth_router)
logger.warning(
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
"🧪 DEV_MODE 已启用 - Mock OAuth 端点已挂载\n"
"🧪 仅供本地开发测试使用,生产环境必须关闭!\n"
"🧪 端点列表:\n"
"🧪 GET /api/dev/login - Mock 登录\n"
"🧪 GET /api/dev/users - 列出预设用户\n"
"🧪 GET /api/dev/health - dev 模式状态\n"
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 挂载 WebSocket 路由 # 挂载 WebSocket 路由
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -514,6 +743,79 @@ def create_app() -> FastAPI:
""" """
return {"status": "ok", "service": "wecom-it-smart-desk"} return {"status": "ok", "service": "wecom-it-smart-desk"}
@app.get("/ready", tags=["系统"])
async def readiness_check():
"""就绪检查端点。
检查服务依赖(DB + Redis),不调用企微 API(避免阻塞)。
用于 K8s readinessProbe。
"""
try:
# 检查数据库
from app.database import engine
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_status = "ok"
except Exception as e:
db_status = f"error: {str(e)}"
try:
# 检查 Redis
from app.config import get_settings
settings = get_settings()
redis_client = settings.create_redis_client()
await redis_client.ping()
redis_status = "ok"
except Exception as e:
redis_status = f"error: {str(e)}"
if db_status == "ok" and redis_status == "ok":
return {"status": "ready", "db": db_status, "redis": redis_status}
else:
return JSONResponse(
status_code=503,
content={"status": "not_ready", "db": db_status, "redis": redis_status}
)
@app.get("/metrics", tags=["系统"])
async def metrics():
"""指标端点。
返回服务运行指标,用于 Prometheus 采集。
"""
import psutil
return {
"status": "ok",
"metrics": {
"cpu_percent": psutil.cpu_percent(interval=0.1),
"memory_percent": psutil.virtual_memory().percent,
"disk_percent": psutil.disk_usage("/").percent,
}
}
@app.get("/version", tags=["系统"])
async def version():
"""版本信息端点。
返回服务版本信息。
"""
import subprocess
try:
git_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=app_root,
text=True
).strip()[:8]
except Exception:
git_hash = "unknown"
return {
"service": "wecom-it-smart-desk",
"version": "1.1.0",
"build": git_hash,
}
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 打印所有已注册的路由(调试用) # 打印所有已注册的路由(调试用)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
+149
View File
@@ -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()
+95
View File
@@ -463,6 +463,101 @@ class WecomService:
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}") logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
raise Exception(f"获取部门成员网络错误: {e}") from 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}&timestamp={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}&timestamp={timestamp}&url={url}"
# sha1 哈希
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return signature
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 上传临时素材 # 上传临时素材
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+49
View File
@@ -250,6 +250,55 @@ class ConnectionManager:
for employee_id in employee_ids: for employee_id in employee_ids:
await self.send_to_employee(employee_id, data) await self.send_to_employee(employee_id, data)
# ==========================================================================
# 消息状态广播(P1-4
# ==========================================================================
async def broadcast_message_status(
self,
conv_id: str,
msg_id: str,
status: str,
participant_ids: List[str],
extra: dict = None,
) -> int:
"""向会话所有参与方广播消息状态变更。
用于撤回/已读/删除等事件的实时推送。
Args:
conv_id: 会话ID
msg_id: 消息ID
status: 新状态 (sent / delivered / read / recalled / deleted)
participant_ids: 参与方ID列表 (agent_id + employee_id)
extra: 额外数据 (可选,如 recall_by / recall_at)
Returns:
推送到客户端数量
"""
# 构建消息
payload = {
"type": "message_status",
"conv_id": conv_id,
"msg_id": msg_id,
"status": status,
**(extra or {}),
}
# 分别推送给坐席和员工
sent_count = 0
for pid in participant_ids:
# 判断是坐席还是员工
if pid in self.active_connections:
await self.send_to_agent(pid, payload)
sent_count += 1
elif pid in self.employee_connections:
await self.send_to_employee(pid, payload)
sent_count += 1
return sent_count
# ========================================================================== # ==========================================================================
# 辅助方法 # 辅助方法
# ========================================================================== # ==========================================================================
+158
View File
@@ -0,0 +1,158 @@
# =============================================================================
# IT智能服务台 — 错误码定义
# =============================================================================
# 说明:统一管理系统错误码,便于前端解析和国际化
# 格式:E{模块}{序号}
# =============================================================================
from enum import Enum
class ErrorCode(str, Enum):
"""系统错误码枚举"""
# --------------------------------------------------------------------------
# 通用错误 (0xxx)
# --------------------------------------------------------------------------
SUCCESS = "E0000" # 成功
UNKNOWN_ERROR = "E0001" # 未知错误
INVALID_PARAMETER = "E0002" # 参数错误
MISSING_PARAMETER = "E0003" # 缺少参数
NOT_FOUND = "E0004" # 资源不存在
UNAUTHORIZED = "E0005" # 未授权
FORBIDDEN = "E0006" # 禁止访问
INTERNAL_ERROR = "E0007" # 内部错误
SERVICE_UNAVAILABLE = "E0008" # 服务不可用
TIMEOUT = "E0009" # 请求超时
# --------------------------------------------------------------------------
# 认证相关 (1xxx)
# --------------------------------------------------------------------------
AUTH_FAILED = "E1001" # 认证失败
AUTH_TOKEN_EXPIRED = "E1002" # Token过期
AUTH_TOKEN_INVALID = "E1003" # Token无效
AUTH_PASSWORD_REQUIRED = "E1012" # 登录:首次登录请先设置密码
AUTH_PASSWORD_WRONG = "E1011" # 登录:本地密码错误
AUTH_OLD_PASSWORD_REQUIRED = "E1015" # 改密:请输入旧密码(2026-06-15 WB反馈 1012 上下文冲突后拆分)
AUTH_OLD_PASSWORD_WRONG = "E1016" # 改密:旧密码错误(2026-06-15 拆分)
# --------------------------------------------------------------------------
# 企微API错误 (2xxx)
# --------------------------------------------------------------------------
WECOM_API_ERROR = "E2001" # 企微API调用失败
WECOM_API_TIMEOUT = "E2002" # 企微API超时
WECOM_TOKEN_INVALID = "E2003" # 企微token无效
WECOM_USER_NOT_FOUND = "E2004" # 企微用户不存在
# --------------------------------------------------------------------------
# 会话/消息错误 (3xxx)
# --------------------------------------------------------------------------
CONVERSATION_NOT_FOUND = "E3001" # 会话不存在
MESSAGE_NOT_FOUND = "E3002" # 消息不存在
MESSAGE_TOO_LONG = "E3003" # 消息过长
CONVERSATION_CLOSED = "E3004" # 会话已关闭
# --------------------------------------------------------------------------
# 坐席错误 (4xxx)
# --------------------------------------------------------------------------
AGENT_NOT_FOUND = "E4001" # 坐席不存在
AGENT_OFFLINE = "E4002" # 坐席不在线
AGENT_BUSY = "E4003" # 坐席忙碌
AGENT_MAX_LOAD = "E4004" # 坐席已达最大接待量
# --------------------------------------------------------------------------
# 审批错误 (5xxx)
# --------------------------------------------------------------------------
APPROVAL_TEMPLATE_NOT_FOUND = "E5001" # 审批模板不存在
APPROVAL_FAILED = "E5002" # 审批提交失败
# --------------------------------------------------------------------------
# 文件上传错误 (6xxx)
# --------------------------------------------------------------------------
FILE_TOO_LARGE = "E6001" # 文件过大
FILE_TYPE_NOT_ALLOWED = "E6002" # 文件类型不允许
FILE_UPLOAD_FAILED = "E6003" # 文件上传失败
# 错误码到 HTTP 状态码的映射
ERROR_CODE_TO_STATUS = {
ErrorCode.SUCCESS: 200,
ErrorCode.INVALID_PARAMETER: 400,
ErrorCode.MISSING_PARAMETER: 400,
ErrorCode.NOT_FOUND: 404,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.INTERNAL_ERROR: 500,
ErrorCode.SERVICE_UNAVAILABLE: 503,
# 认证
ErrorCode.AUTH_FAILED: 401,
ErrorCode.AUTH_TOKEN_EXPIRED: 401,
ErrorCode.AUTH_TOKEN_INVALID: 401,
ErrorCode.AUTH_PASSWORD_REQUIRED: 401,
ErrorCode.AUTH_PASSWORD_WRONG: 401,
ErrorCode.AUTH_OLD_PASSWORD_REQUIRED: 400,
ErrorCode.AUTH_OLD_PASSWORD_WRONG: 400,
# 企微
ErrorCode.WECOM_API_ERROR: 502,
ErrorCode.WECOM_API_TIMEOUT: 504,
ErrorCode.WECOM_TOKEN_INVALID: 401,
ErrorCode.WECOM_USER_NOT_FOUND: 404,
# 会话
ErrorCode.CONVERSATION_NOT_FOUND: 404,
ErrorCode.MESSAGE_NOT_FOUND: 404,
ErrorCode.MESSAGE_TOO_LONG: 400,
ErrorCode.CONVERSATION_CLOSED: 400,
# 坐席
ErrorCode.AGENT_NOT_FOUND: 404,
ErrorCode.AGENT_OFFLINE: 400,
ErrorCode.AGENT_BUSY: 400,
ErrorCode.AGENT_MAX_LOAD: 400,
# 审批
ErrorCode.APPROVAL_TEMPLATE_NOT_FOUND: 404,
ErrorCode.APPROVAL_FAILED: 502,
# 文件
ErrorCode.FILE_TOO_LARGE: 413,
ErrorCode.FILE_TYPE_NOT_ALLOWED: 400,
ErrorCode.FILE_UPLOAD_FAILED: 500,
}
def get_error_message(code: ErrorCode) -> str:
"""获取错误码对应的默认消息"""
messages = {
ErrorCode.SUCCESS: "操作成功",
ErrorCode.UNKNOWN_ERROR: "未知错误,请稍后重试",
ErrorCode.INVALID_PARAMETER: "参数错误",
ErrorCode.MISSING_PARAMETER: "缺少必要参数",
ErrorCode.NOT_FOUND: "资源不存在",
ErrorCode.UNAUTHORIZED: "未授权,请先登录",
ErrorCode.FORBIDDEN: "禁止访问",
ErrorCode.INTERNAL_ERROR: "服务器内部错误",
ErrorCode.SERVICE_UNAVAILABLE: "服务暂时不可用",
ErrorCode.TIMEOUT: "请求超时",
ErrorCode.AUTH_FAILED: "认证失败",
ErrorCode.AUTH_TOKEN_EXPIRED: "登录已过期,请重新登录",
ErrorCode.AUTH_TOKEN_INVALID: "无效的登录凭证",
ErrorCode.AUTH_PASSWORD_REQUIRED: "首次登录请先设置密码",
ErrorCode.AUTH_PASSWORD_WRONG: "密码错误",
ErrorCode.AUTH_OLD_PASSWORD_REQUIRED: "请输入旧密码",
ErrorCode.AUTH_OLD_PASSWORD_WRONG: "旧密码错误",
ErrorCode.WECOM_API_ERROR: "企业微信服务异常",
ErrorCode.WECOM_API_TIMEOUT: "企业微信服务响应超时",
ErrorCode.WECOM_TOKEN_INVALID: "企业微信凭证无效",
ErrorCode.WECOM_USER_NOT_FOUND: "企业微信用户不存在",
ErrorCode.CONVERSATION_NOT_FOUND: "会话不存在",
ErrorCode.MESSAGE_NOT_FOUND: "消息不存在",
ErrorCode.MESSAGE_TOO_LONG: "消息内容过长",
ErrorCode.CONVERSATION_CLOSED: "会话已结束",
ErrorCode.AGENT_NOT_FOUND: "坐席不存在",
ErrorCode.AGENT_OFFLINE: "坐席不在线",
ErrorCode.AGENT_BUSY: "坐席忙碌中",
ErrorCode.AGENT_MAX_LOAD: "坐席已达到最大接待量",
ErrorCode.APPROVAL_TEMPLATE_NOT_FOUND: "审批模板不存在",
ErrorCode.APPROVAL_FAILED: "审批提交失败",
ErrorCode.FILE_TOO_LARGE: "文件过大",
ErrorCode.FILE_TYPE_NOT_ALLOWED: "不支持的文件类型",
ErrorCode.FILE_UPLOAD_FAILED: "文件上传失败",
}
return messages.get(code, "未知错误")
+99
View File
@@ -0,0 +1,99 @@
# =============================================================================
# IT智能服务台 — 日志配置
# =============================================================================
# 说明:统一日志格式,支持 JSON 输出便于日志收集
# =============================================================================
import json
import logging
import sys
from datetime import datetime
from typing import Any
class JSONFormatter(logging.Formatter):
"""JSON 格式日志 formatter"""
def format(self, record: logging.LogRecord) -> str:
"""将日志记录格式化为 JSON"""
log_data: dict[str, Any] = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# 添加异常信息
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
# 添加额外字段
if hasattr(record, "request_id"):
log_data["request_id"] = record.request_id
if hasattr(record, "user_id"):
log_data["user_id"] = record.user_id
if hasattr(record, "extra"):
log_data.update(record.extra)
return json.dumps(log_data, ensure_ascii=False)
class PlainFormatter(logging.Formatter):
"""普通格式日志 formatter(开发环境使用)"""
def __init__(self):
super().__init__(
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def setup_logging(level: str = "INFO", json_format: bool = False) -> None:
"""配置日志系统
Args:
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
json_format: 是否使用 JSON 格式输出
"""
log_level = getattr(logging, level.upper(), logging.INFO)
# 获取 root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# 清除现有 handlers
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建 console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
# 设置 formatter
if json_format:
formatter = JSONFormatter()
else:
formatter = PlainFormatter()
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 设置第三方库日志级别
logging.getLogger("uvicorn").setLevel(logging.WARNING)
logging.getLogger("fastapi").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""获取 logger 实例
Args:
name: logger 名称,通常使用 __name__
Returns:
Logger 实例
"""
return logging.getLogger(name)
+11 -2
View File
@@ -9,11 +9,11 @@
# Web 框架 # Web 框架
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# FastAPI: 高性能异步 Web 框架,自动生成 Swagger API 文档 # FastAPI: 高性能异步 Web 框架,自动生成 Swagger API 文档
fastapi==0.111.0 fastapi==0.111.1
# Uvicorn: ASGI 服务器,支持热重载和 WebSocket # Uvicorn: ASGI 服务器,支持热重载和 WebSocket
uvicorn[standard]==0.30.1 uvicorn[standard]==0.30.1
# python-multipart: FastAPI 文件上传支持(处理 multipart/form-data 请求) # python-multipart: FastAPI 文件上传支持(处理 multipart/form-data 请求)
python-multipart==0.0.9 python-multipart==0.0.12
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 数据库 # 数据库
@@ -37,6 +37,7 @@ redis==5.0.7
# 数据验证 # 数据验证
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖 # pydantic: 数据验证和设置管理,FastAPI 的核心依赖
# 注意:必须用 2.7.4 或 2.8.0+,2.7.5 被 PyPI yank(清华源/官方源都没有)
pydantic==2.7.4 pydantic==2.7.4
# pydantic-settings: 从环境变量读取配置,支持 .env 文件 # pydantic-settings: 从环境变量读取配置,支持 .env 文件
pydantic-settings==2.3.4 pydantic-settings==2.3.4
@@ -72,7 +73,15 @@ python-dotenv==1.0.1
pyotp==2.9.0 pyotp==2.9.0
# bcrypt: 密码哈希库(用于本地密码认证) # bcrypt: 密码哈希库(用于本地密码认证)
bcrypt==4.1.2 bcrypt==4.1.2
# passlib: 密码哈希兼容库(bcrypt 前端封装)
passlib[bcrypt]==1.7.4
# qrcode: 二维码生成(用于 OTP 绑定) # qrcode: 二维码生成(用于 OTP 绑定)
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
# pillow: 图片处理(qrcode[pil] 依赖) # pillow: 图片处理(qrcode[pil] 依赖)
pillow==10.4.0 pillow==10.4.0
# --------------------------------------------------------------------------
# 监控
# --------------------------------------------------------------------------
# psutil: 系统监控(用于 /metrics 端点)
psutil==5.9.8
+98 -18
View File
@@ -33,6 +33,32 @@ from app.models.quick_reply_template import QuickReplyTemplate
from app.models.agent_note import AgentNote from app.models.agent_note import AgentNote
# =============================================================================
# 2026-06-15 修复: monkey-patch starlette.config.Config 强制 UTF-8 读 .env
# 原因: Windows pytest 默认 GBK 读 .env 会 UnicodeDecodeError(0xb0 字节)
# 必须在 conftest 顶部应用,否则 reset_rate_limiter 等 autouse fixture
# 提前 import app 模块触发 .env 读取时会失败
# =============================================================================
import starlette.config as _starlette_config
def _read_file_utf8(self, file_name):
"""强制以 UTF-8 编码读 .env,避免 Windows GBK 默认编码触发 UnicodeDecodeError。"""
result = {}
with open(file_name, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
k, v = line.split('=', 1)
result[k.strip()] = v.strip().strip('"').strip("'")
return result
_starlette_config.Config._read_file = _read_file_utf8
# ============================================================================= # =============================================================================
# SQLite 内存数据库引擎 # SQLite 内存数据库引擎
# ============================================================================= # =============================================================================
@@ -184,6 +210,70 @@ def mock_redis() -> MockRedis:
return MockRedis() return MockRedis()
# =============================================================================
# 模块级 Mock 外部服务(让子测试可覆盖其行为)
# =============================================================================
# 2026-06-15 修复: 把 WecomService / AIService mock 提升到模块级
# 原因: client fixture 内的局部 mock 无法被测试内 `with patch.object(...)` 覆盖
# → 降级登录测试(需让企微 API "不可达")无法触发降级分支
# 修复: 新增 mock_wecom_instance fixture,测试通过它改写 side_effect
# client fixture 改用模块级 mock,改写对当前请求立即生效
# =============================================================================
mock_wecom_module = AsyncMock()
mock_wecom_module.send_message.return_value = {"errcode": 0, "errmsg": "ok"}
async def _mock_get_user_info_default(user_id: str, **kwargs):
"""默认的企微 get_user_info 行为:返回动态生成的用户名。
测试可通过 mock_wecom_instance.get_user_info.side_effect = ... 改写。
"""
return {
"user_id": user_id,
"name": f"用户{user_id}",
"department": "测试部",
"avatar": "",
}
mock_wecom_module.get_user_info.side_effect = _mock_get_user_info_default
mock_wecom_module.get_department_users.return_value = []
mock_ai_module = AsyncMock()
mock_ai_module.generate_response.return_value = "这是AI的模拟回复"
@pytest.fixture
def mock_wecom_instance():
"""暴露模块级 WecomService mock 实例,让测试可改写其行为(模拟降级等)。
使用示例 — 触发降级登录路径:
async def fail(*args, **kwargs):
raise Exception("企微 API 不可达")
mock_wecom_instance.get_user_info.side_effect = fail
# ...发起请求后,用 try/finally 恢复原 side_effect
"""
return mock_wecom_module
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""每个测试前后重置 slowapi 限流器状态,避免 IP 限流干扰测试。
背景: /agents/login 限流 10/min per IP,pytest 连续跑多个测试会撞 429。
"""
from app.api.agents import limiter as agents_limiter
try:
agents_limiter._storage.reset()
except Exception:
pass
yield
try:
agents_limiter._storage.reset()
except Exception:
pass
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]: async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]:
"""提供 FastAPI 异步测试客户端。""" """提供 FastAPI 异步测试客户端。"""
@@ -194,6 +284,9 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
async def _override_get_redis(): async def _override_get_redis():
return mock_redis return mock_redis
# 注: 2026-06-15 UTF-8 monkey-patch 已提升到 conftest 模块级,见文件顶部
# 原因: reset_rate_limiter 等 autouse fixture 提前 import 触发 .env 读取
from app.main import create_app from app.main import create_app
from app.database import get_db from app.database import get_db
@@ -210,24 +303,11 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
# 为什么:测试中不应调用真实企微API/AI大模型 # 为什么:测试中不应调用真实企微API/AI大模型
# 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象 # 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象
# ------------------------------------------------------------------ # ------------------------------------------------------------------
mock_wecom = AsyncMock() # 使用模块级 mock_wecom_module / mock_ai_module2026-06-15 修复)
# 企微消息发送:默认成功 # 原因: 模块级 mock 允许测试通过 mock_wecom_instance fixture 改写行为
mock_wecom.send_message.return_value = {"errcode": 0, "errmsg": "ok"} # 例如降级登录测试改 side_effect = raise Exception("企微不可达")
# 企微通讯录查询:动态返回(根据传入的 user_id 生成对应的名称) mock_wecom = mock_wecom_module
# 为什么:坐席登录时会调用 get_user_info 获取员工姓名 mock_ai = mock_ai_module
# 如果返回固定名字,登录接口会用 mock 名字覆盖请求中的 name 参数
async def _mock_get_user_info(user_id: str, **kwargs):
return {
"user_id": user_id,
"name": f"用户{user_id}",
"department": "测试部",
"avatar": "",
}
mock_wecom.get_user_info.side_effect = _mock_get_user_info
mock_wecom.get_department_users.return_value = []
mock_ai = AsyncMock()
mock_ai.generate_response.return_value = "这是AI的模拟回复"
# Patch WecomService 类(端点函数中会新建实例) # Patch WecomService 类(端点函数中会新建实例)
# 注意:只 patch 模块中实际引用的名字 # 注意:只 patch 模块中实际引用的名字
+180
View File
@@ -0,0 +1,180 @@
# =============================================================================
# 企微智能IT支持服务台 — 坐席降级登录测试
# =============================================================================
# 覆盖 P0 修复 Fix-4: 企微 API 不可达时,已注册坐席必须验证本地密码
# 创建日期: 2026-06-15 (Claude Code 补最小测试,因 WB 提交时未含此测试)
# =============================================================================
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, patch
from app.models.agent import Agent
from app.utils.error_codes import ErrorCode
from tests.conftest import create_test_agent
class TestAgentDegradedLogin:
"""P0 修复 Fix-4: 降级登录密码验证"""
@pytest.mark.asyncio
async def test_degraded_login_wrong_password_rejected(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录用错密码 → 拒绝
验证:
- 状态码非 200(或响应 code 非 0)
- 错误码属于 AUTH_PASSWORD_WRONG 类(1011 当前,2006 改完后)
"""
# 1. 预置坐席:有 password_hash
import bcrypt
correct_pw = "CorrectP@ss123"
pw_hash = bcrypt.hashpw(correct_pw.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
agent = create_test_agent(
user_id="degraded_agent_001",
name="降级坐席",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 2. 改写 conftest 模块级 mock 行为,让企微 API 抛异常(降级场景触发)
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
# 3. 用错误密码登录
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_001",
"name": "降级坐席",
"password": "WrongPassword",
},
)
finally:
# 恢复默认 side_effect,避免污染后续测试
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 4. 断言:被拒绝
assert response.status_code in (200, 401, 403), (
f"预期被拒绝,实际 status={response.status_code}, body={response.text}"
)
body = response.json()
# 业务 code 应该非 0
assert body.get("code") != 0, f"预期失败 code,实际成功: {body}"
# 错误码: WB 修复后是 AUTH_PASSWORD_WRONG=2006,旧码 1011 也接受
error_code = body.get("code")
assert error_code in (
ErrorCode.AUTH_PASSWORD_WRONG.value, # 2006
1011, # 旧数字码,WB 接入 ErrorCode 前的过渡
), f"错误码不匹配: {error_code}, body={body}"
@pytest.mark.asyncio
async def test_degraded_login_no_password_blocked(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录不传密码 → 拒绝"""
# 1. 预置坐席
import bcrypt
pw_hash = bcrypt.hashpw(b"AnyP@ss", bcrypt.gensalt()).decode("utf-8")
agent = create_test_agent(
user_id="degraded_agent_002",
name="降级坐席2",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 2. 改写 conftest 模块级 mock,让企微 API 抛异常
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
# 3. 不传 password 登录
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_002",
"name": "降级坐席2",
},
)
finally:
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 4. 断言:被拒绝
body = response.json()
assert body.get("code") != 0, f"预期被拒绝: {body}"
error_code = body.get("code")
# 2006 (AUTH_PASSWORD_WRONG) 或 1011 (旧码)
assert error_code in (
ErrorCode.AUTH_PASSWORD_WRONG.value,
1011,
), f"错误码不匹配: {error_code}, body={body}"
@pytest.mark.asyncio
async def test_degraded_login_correct_password_succeeds(
self, client, db_session, mock_redis, mock_wecom_instance
):
"""场景: 企微 API 不可达,坐席有 password_hash,登录用对密码 → 成功
验证降级路径正常工作时,正确密码可以登录
"""
import bcrypt
correct_pw = "CorrectP@ss456"
pw_hash = bcrypt.hashpw(correct_pw.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
agent = create_test_agent(
user_id="degraded_agent_003",
name="降级坐席3",
)
agent.password_hash = pw_hash
db_session.add(agent)
await db_session.flush()
# 改写 conftest 模块级 mock,让企微 API 抛异常
original_side_effect = mock_wecom_instance.get_user_info.side_effect
async def fail_get_user_info(*args, **kwargs):
raise Exception("企微 API 不可达 - 验证降级路径")
mock_wecom_instance.get_user_info.side_effect = fail_get_user_info
try:
response = await client.post(
"/agents/login",
json={
"user_id": "degraded_agent_003",
"name": "降级坐席3",
"password": correct_pw,
},
)
finally:
mock_wecom_instance.get_user_info.side_effect = original_side_effect
# 降级 + 正确密码应能登录
body = response.json()
assert body.get("code") == 0, (
f"预期降级登录成功,实际失败: {body}"
)
assert "token" in body.get("data", {}), (
f"响应缺 token: {body}"
)
+1 -1
View File
@@ -44,7 +44,7 @@ async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncCli
app = create_app() app = create_app()
app.dependency_overrides[get_db] = _override_get_db app.dependency_overrides[get_db] = _override_get_db
with patch("app.api.h5._get_redis", return_value=mock_redis): with patch("app.api.h5._get_redis", return_value=mock_redis, create=True):
with patch("redis.asyncio.from_url", return_value=mock_redis): with patch("redis.asyncio.from_url", return_value=mock_redis):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac: async with AsyncClient(transport=transport, base_url="http://test") as ac:
+247
View File
@@ -0,0 +1,247 @@
# =============================================================================
# 企微IT智能服务台 — Message.id VARCHAR=UUID 500 错误回归测试
# =============================================================================
# 背景(2026-06-15 事故):
# messages.id 在 DB 里是 String(36)/VARCHAR(存的是 UUID 字符串),
# 但代码里有几处用 UUID 对象直接比较,导致 PostgreSQL 报
# "operator does not exist: character varying = uuid" → 500
# 涉及 endpoint:
# - h5.py:843 H5 轮询 (after_message_id)
# - messages.py:87 坐席端轮询 (before_message_id)
# - messages.py:263 坐席端轮询 (after_message_id)
# - messages.py:319 撤回消息
# - messages.py:371 编辑消息
#
# 修复方式:所有 Message.id 比较前 str() 包装
#
# 此测试文件的目的:防止以后改回 UUID 比较(回归保护)
#
# 验证策略:
# - 200 = 修复成功(没崩)
# - 500 = 500 bug 回归
# - 401/403 = 鉴权被拒(不是 500,也通过)
# - 200 但 body code != 0 = 业务错误,只要不是 500 就算过
#
# 路径前缀说明:
# h5.py: router = APIRouter() → endpoint 真实路径是 /h5/...
# messages.py: router = APIRouter() → endpoint 真实路径是 /conversations/...
# 都不带 /api 前缀(nginx 部署时再 strip)
# =============================================================================
import uuid
from datetime import datetime
import pytest
import pytest_asyncio
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.message import Message
from tests.conftest import create_test_conversation
# =============================================================================
# 共享 fixtures
# =============================================================================
@pytest_asyncio.fixture
async def conversation_in_db(db_session: AsyncSession):
"""创建一个会话 + 3 条消息(为防止 nested transaction 不可见,显式 commit)。"""
conv = create_test_conversation(employee_id="emp_500_bug", status="serving")
db_session.add(conv)
await db_session.flush()
base_time = datetime(2026, 6, 15, 10, 0, 0)
messages = []
for i in range(3):
m = Message(
id=str(uuid.uuid4()),
conversation_id=conv.id,
sender_type="agent",
sender_id=f"agent_{i}",
sender_name=f"坐席{i}",
content=f"消息{i}",
msg_type="text",
created_at=base_time,
)
db_session.add(m)
messages.append(m)
await db_session.flush()
return conv, messages
@pytest_asyncio.fixture
async def override_employee(client, conversation_in_db):
"""覆盖 _get_current_employee 依赖。
h5.py:139 _get_current_employee 是 async def,所以 dependency_overrides
接受 async 函数(会被 FastAPI await)。
"""
from app.api.h5 import _get_current_employee
conv, _ = conversation_in_db
app = client._transport.app
async def fake_employee():
return conv.employee_id
app.dependency_overrides[_get_current_employee] = fake_employee
yield conv
app.dependency_overrides.pop(_get_current_employee, None)
@pytest_asyncio.fixture
async def override_agent(client):
"""覆盖 get_current_agent 依赖,返回一个测试坐席对象。"""
from app.api.agents import get_current_agent
from app.models.agent import Agent
app = client._transport.app
agent = Agent(user_id="test_agent_500", name="测试坐席", status="online")
async def fake_agent():
return agent
app.dependency_overrides[get_current_agent] = fake_agent
yield agent
app.dependency_overrides.pop(get_current_agent, None)
def assert_not_500(response, msg=""):
"""断言不是 500(防 500 bug 回归)。
500 才是真 bug。401/403/404/422 都不是 500 bug,只是测试 fixture 不全。
"""
assert response.status_code != 500, (
f"500 bug 回归!status={response.status_code} body={response.text} {msg}"
)
# =============================================================================
# 回归测试
# =============================================================================
class TestH5MessagePoll:
"""H5 端员工轮询 — 验证 after_message_id 类型不会触发 500。
endpoint: GET /h5/conversations/current/messages/poll?after_message_id=xxx
"""
@pytest.mark.asyncio
async def test_poll_with_str_uuid(self, client, override_employee, conversation_in_db):
"""传 str 形式的 UUID(主要场景),不触发 500。"""
_, msgs = conversation_in_db
response = await client.get(
f"/h5/conversations/current/messages/poll?after_message_id={msgs[0].id}"
)
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_poll_with_uuid_object(self, client, override_employee, conversation_in_db):
"""传 UUID 对象(不是 str)— 修复前会 500,修复后 str() 包装正常。"""
from uuid import UUID as UUIDType
_, msgs = conversation_in_db
uuid_obj = UUIDType(msgs[0].id)
response = await client.get(
f"/h5/conversations/current/messages/poll?after_message_id={uuid_obj}"
)
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
@pytest.mark.asyncio
async def test_poll_with_invalid_uuid(self, client, override_employee):
"""传无效 UUID,优雅降级(不应 500)。"""
response = await client.get(
"/h5/conversations/current/messages/poll?after_message_id=invalid-uuid-format"
)
assert_not_500(response, "无效 UUID 触发 500")
@pytest.mark.asyncio
async def test_poll_without_after(self, client, override_employee):
"""不传 after_message_id,正常返回(不应 500)。"""
response = await client.get("/h5/conversations/current/messages/poll")
assert_not_500(response, "无参数触发 500")
class TestAgentMessagePoll:
"""坐席端轮询 — 验证 after_message_id 类型不会触发 500。
endpoint: GET /conversations/{id}/messages/poll?after_message_id=xxx
"""
@pytest.mark.asyncio
async def test_agent_poll_with_str_uuid(self, client, override_agent, conversation_in_db):
"""坐席端轮询 str UUID,不触发 500。"""
conv, msgs = conversation_in_db
response = await client.get(
f"/conversations/{conv.id}/messages/poll?after_message_id={msgs[0].id}"
)
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_agent_poll_with_uuid_object(self, client, override_agent, conversation_in_db):
"""坐席端轮询 UUID 对象,不触发 500(防回归)。"""
from uuid import UUID as UUIDType
conv, msgs = conversation_in_db
uuid_obj = UUIDType(msgs[0].id)
response = await client.get(
f"/conversations/{conv.id}/messages/poll?after_message_id={uuid_obj}"
)
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
class TestRecallMessage:
"""撤回消息 — message_id 类型不会触发 500。"""
@pytest.mark.asyncio
async def test_recall_with_str_uuid(self, client, override_agent, conversation_in_db):
"""撤回消息传 str UUID,不触发 500。"""
_, msgs = conversation_in_db
msgs[0].sender_id = override_agent.user_id
msgs[0].sender_type = "agent"
msgs[0].recallable_until = datetime(2099, 12, 31)
response = await client.post(f"/messages/{msgs[0].id}/recall")
assert_not_500(response, "str UUID 触发 500")
@pytest.mark.asyncio
async def test_recall_with_uuid_object(self, client, override_agent, conversation_in_db):
"""撤回消息传 UUID 对象,不触发 500(防回归)。"""
from uuid import UUID as UUIDType
_, msgs = conversation_in_db
msgs[0].sender_id = override_agent.user_id
msgs[0].sender_type = "agent"
msgs[0].recallable_until = datetime(2099, 12, 31)
uuid_obj = UUIDType(msgs[0].id)
response = await client.post(f"/messages/{uuid_obj}/recall")
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
class TestMessageIdStrRequirement:
"""单元测试:验证 Message.id 列必须是 String,以及 str 比较能工作。"""
def test_message_id_column_is_string_type(self):
"""Message.id 列类型必须是 String,不是 UUID(防止改回 UUID 类型)。"""
col_type = Message.__table__.c.id.type
assert isinstance(col_type, String), (
f"Message.id 必须是 String 类型,实际是 {type(col_type).__name__},"
"改回 UUID 类型会导致 PostgreSQL 报 'character varying = uuid'"
)
@pytest.mark.asyncio
async def test_query_with_str_id_succeeds(self, db_session: AsyncSession, conversation_in_db):
"""直接查 Message(id='uuid-string') 应成功。"""
from sqlalchemy import select
_, msgs = conversation_in_db
stmt = select(Message).where(Message.id == str(msgs[0].id))
result = await db_session.execute(stmt)
found = result.scalars().first()
assert found is not None
assert found.id == msgs[0].id
@@ -0,0 +1,109 @@
{
"name": "账号密码 / SSO 登录故障排查",
"category": "account",
"description": "员工忘记密码、账号被锁、SSO 单点登录失败、AD 域账号同步异常",
"estimated_time": 6,
"difficulty": 1,
"tags": ["账号", "密码", "SSO", "AD域", "登录"],
"root_node": {
"id": "fc-acct-1",
"type": "step",
"label": "确认员工使用哪种登录方式(域账号/企微SSO/邮箱SSO)",
"status": "pending",
"children": [
{
"id": "fc-acct-2",
"type": "decision",
"label": "是否提示账号已锁定?",
"yes_branch": {
"id": "fc-acct-3",
"type": "step",
"label": "AD 管理控制台解锁账号 + 重置临时密码",
"status": "pending",
"children": [
{
"id": "fc-acct-4",
"type": "step",
"label": "通知员工首次登录需修改密码",
"status": "pending"
},
{
"id": "fc-acct-5",
"type": "decision",
"label": "员工能正常登录?",
"yes_branch": {
"id": "fc-acct-6",
"type": "step",
"label": "回访确认 + 提醒密码保管"
},
"no_branch": {
"id": "fc-acct-7",
"type": "step",
"label": "升级二线:信息安全团队"
}
}
]
},
"no_branch": {
"id": "fc-acct-8",
"type": "step",
"label": "确认密码是否过期(>90天)",
"status": "pending",
"children": [
{
"id": "fc-acct-9",
"type": "decision",
"label": "SSO 登录页能打开?",
"yes_branch": {
"id": "fc-acct-10",
"type": "step",
"label": "引导员工走自助密码重置流程",
"status": "pending",
"children": [
{
"id": "fc-acct-11",
"type": "decision",
"label": "重置邮件是否收到?",
"yes_branch": {
"id": "fc-acct-12",
"type": "step",
"label": "按邮件链接重置 + 回访"
},
"no_branch": {
"id": "fc-acct-13",
"type": "step",
"label": "检查邮箱/反垃圾/电话二次验证"
}
}
]
},
"no_branch": {
"id": "fc-acct-14",
"type": "step",
"label": "检查浏览器代理 + 缓存 + 尝试无痕模式",
"status": "pending",
"children": [
{
"id": "fc-acct-15",
"type": "decision",
"label": "换浏览器/无痕能打开?",
"yes_branch": {
"id": "fc-acct-16",
"type": "step",
"label": "指导员工清除原浏览器缓存"
},
"no_branch": {
"id": "fc-acct-17",
"type": "step",
"label": "升级二线:检查 SSO 网关状态"
}
}
]
}
}
]
}
}
]
}
}
+142
View File
@@ -0,0 +1,142 @@
{
"name": "电脑 / Windows 系统故障排查",
"category": "system",
"description": "员工电脑蓝屏、死机、卡顿、开机黑屏、Windows 更新失败",
"estimated_time": 15,
"difficulty": 3,
"tags": ["电脑", "Windows", "蓝屏", "系统更新", "卡顿"],
"root_node": {
"id": "fc-sys-1",
"type": "step",
"label": "确认故障现象(蓝屏代码/卡顿/黑屏/无法开机)",
"status": "pending",
"children": [
{
"id": "fc-sys-2",
"type": "decision",
"label": "电脑能正常开机进入桌面?",
"yes_branch": {
"id": "fc-sys-3",
"type": "step",
"label": "引导员工打开任务管理器查看资源占用",
"status": "pending",
"children": [
{
"id": "fc-sys-4",
"type": "decision",
"label": "CPU/内存/磁盘哪项占用高?",
"yes_branch": {
"id": "fc-sys-5",
"type": "step",
"label": "按占用类型分别处理:",
"status": "current",
"children": [
{
"id": "fc-sys-6",
"type": "step",
"label": "CPU高:结束异常进程,查启动项",
"status": "pending"
},
{
"id": "fc-sys-7",
"type": "step",
"label": "内存高:检查泄漏进程,加内存条",
"status": "pending"
},
{
"id": "fc-sys-8",
"type": "step",
"label": "磁盘100%:查大文件/重做系统考虑",
"status": "pending"
}
]
},
"no_branch": {
"id": "fc-sys-9",
"type": "step",
"label": "检查最近安装的软件/驱动/更新",
"status": "pending",
"children": [
{
"id": "fc-sys-10",
"type": "decision",
"label": "回滚后是否恢复?",
"yes_branch": {
"id": "fc-sys-11",
"type": "step",
"label": "标记该软件/更新为不兼容,记录案例"
},
"no_branch": {
"id": "fc-sys-12",
"type": "step",
"label": "进入安全模式进一步排查"
}
}
]
}
}
]
},
"no_branch": {
"id": "fc-sys-13",
"type": "step",
"label": "判断开机阶段(BIOS/启动管理器/登录界面)",
"status": "pending",
"children": [
{
"id": "fc-sys-14",
"type": "decision",
"label": "能进安全模式?",
"yes_branch": {
"id": "fc-sys-15",
"type": "step",
"label": "在安全模式卸载最近驱动/更新",
"status": "pending",
"children": [
{
"id": "fc-sys-16",
"type": "decision",
"label": "重启后正常?",
"yes_branch": {
"id": "fc-sys-17",
"type": "step",
"label": "回访确认 + 记录故障点"
},
"no_branch": {
"id": "fc-sys-18",
"type": "step",
"label": "备份数据后考虑重装系统"
}
}
]
},
"no_branch": {
"id": "fc-sys-19",
"type": "step",
"label": "硬件层故障:硬盘/内存条/主板",
"status": "pending",
"children": [
{
"id": "fc-sys-20",
"type": "decision",
"label": "外接显示器/拔内存条有变化?",
"yes_branch": {
"id": "fc-sys-21",
"type": "step",
"label": "对症更换硬件(联系硬件供应商)"
},
"no_branch": {
"id": "fc-sys-22",
"type": "step",
"label": "升级二线:送修 / 申请备用机"
}
}
]
}
}
]
}
}
]
}
}
+104
View File
@@ -0,0 +1,104 @@
{
"name": "企业微信 / 协作工具故障排查",
"category": "wecom",
"description": "企微登录失败、消息发不出、群文件无法下载、视频会议卡顿、审批打不开",
"estimated_time": 8,
"difficulty": 2,
"tags": ["企微", "WeCom", "消息", "视频会议", "审批", "协作"],
"root_node": {
"id": "fc-wc-1",
"type": "step",
"label": "确认故障模块(消息/会议/审批/通讯录/文件)",
"status": "pending",
"children": [
{
"id": "fc-wc-2",
"type": "decision",
"label": "能否登录企微(手机/电脑端)?",
"no_branch": {
"id": "fc-wc-3",
"type": "step",
"label": "引导员工:重新扫码登录/更新企微版本",
"status": "pending",
"children": [
{
"id": "fc-wc-4",
"type": "decision",
"label": "重新登录成功?",
"yes_branch": {
"id": "fc-wc-5",
"type": "step",
"label": "回访确认其他功能也正常"
},
"no_branch": {
"id": "fc-wc-6",
"type": "step",
"label": "检查公司是否全员断网/账号是否离职"
}
}
]
},
"yes_branch": {
"id": "fc-wc-7",
"type": "step",
"label": "按故障模块分别处理:",
"status": "current",
"children": [
{
"id": "fc-wc-8",
"type": "step",
"label": "【消息】发不出/收不到:检查网络 + 退出重登 + 清缓存",
"status": "pending"
},
{
"id": "fc-wc-9",
"type": "step",
"label": "【视频会议】卡顿/掉线:检查带宽(>2Mbps) + 关闭其他视频",
"status": "pending"
},
{
"id": "fc-wc-10",
"type": "step",
"label": "【审批】打不开:确认审批权限 + 联系审批管理员",
"status": "pending"
},
{
"id": "fc-wc-11",
"type": "step",
"label": "【文件】下载失败:检查存储空间 + 重新下载",
"status": "pending"
},
{
"id": "fc-wc-12",
"type": "step",
"label": "【通讯录】看不到新同事:引导同步通讯录",
"status": "pending"
}
]
}
},
{
"id": "fc-wc-13",
"type": "decision",
"label": "处理后是否解决?",
"yes_branch": {
"id": "fc-wc-14",
"type": "step",
"label": "回访 + 记录案例到知识库"
},
"no_branch": {
"id": "fc-wc-15",
"type": "step",
"label": "升级二线:企微企业管理员 / 厂商支持",
"children": [
{
"id": "fc-wc-16",
"type": "step",
"label": "提供工单截图 + 故障时间 + 员工 userid"
}
]
}
}
]
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "VPN / 远程办公故障排查",
"category": "vpn",
"description": "员工无法连接公司 VPN,或连接后访问内网失败,或频繁掉线",
"estimated_time": 8,
"difficulty": 2,
"tags": ["VPN", "远程办公", "aTrust", "网络"],
"root_node": {
"id": "fc-vpn-1",
"type": "step",
"label": "确认员工当前网络环境(在家/出差/咖啡厅)",
"status": "pending",
"children": [
{
"id": "fc-vpn-2",
"type": "decision",
"label": "VPN 客户端能否打开登录页?",
"yes_branch": {
"id": "fc-vpn-3",
"type": "step",
"label": "检查账号密码 + 二次认证",
"children": [
{
"id": "fc-vpn-4",
"type": "decision",
"label": "是否连接成功?",
"yes_branch": {
"id": "fc-vpn-5",
"type": "step",
"label": "回访确认可访问内网系统"
},
"no_branch": {
"id": "fc-vpn-6",
"type": "step",
"label": "清除 DNS 缓存 + 重连 aTrust"
}
}
]
},
"no_branch": {
"id": "fc-vpn-7",
"type": "step",
"label": "升级 VPN 客户端到最新版",
"children": [
{
"id": "fc-vpn-8",
"type": "decision",
"label": "重试能否登录?",
"yes_branch": {
"id": "fc-vpn-9",
"type": "step",
"label": "回访确认"
},
"no_branch": {
"id": "fc-vpn-10",
"type": "step",
"label": "升级二线:信息安全团队(提供 userid + 时间)"
}
}
]
}
}
]
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "企业邮箱故障排查",
"category": "email",
"description": "员工邮箱登录失败、收发异常、附件打不开、签名问题",
"estimated_time": 7,
"difficulty": 2,
"tags": ["邮箱", "Outlook", "Foxmail", "登录", "附件"],
"root_node": {
"id": "fc-email-1",
"type": "step",
"label": "确认邮箱客户端(Outlook/Foxmail/网页/手机)",
"status": "pending",
"children": [
{
"id": "fc-email-2",
"type": "decision",
"label": "能否登录网页邮箱?",
"yes_branch": {
"id": "fc-email-3",
"type": "step",
"label": "说明账号本身可用,问题在客户端",
"children": [
{
"id": "fc-email-4",
"type": "decision",
"label": "是否收不到新邮件?",
"yes_branch": {
"id": "fc-email-5",
"type": "step",
"label": "检查反垃圾设置 + 邮件规则 + 邮箱配额"
},
"no_branch": {
"id": "fc-email-6",
"type": "step",
"label": "检查 Outlook 缓存 + 重建索引 + 检查 PST 文件大小"
}
}
]
},
"no_branch": {
"id": "fc-email-7",
"type": "step",
"label": "检查账号是否锁定 + 密码是否过期",
"children": [
{
"id": "fc-email-8",
"type": "decision",
"label": "重置密码后能否登录?",
"yes_branch": {
"id": "fc-email-9",
"type": "step",
"label": "回访 + 通知修改其他系统密码"
},
"no_branch": {
"id": "fc-email-10",
"type": "step",
"label": "升级二线:邮件管理员(提供 userid + 错误截图)"
}
}
]
}
}
]
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"name": "网络 / WiFi 故障排查",
"category": "network",
"description": "员工连不上公司 WiFi、有线网慢、IP 冲突、WiFi 认证失败、丢包",
"estimated_time": 10,
"difficulty": 2,
"tags": ["网络", "WiFi", "有线", "IP冲突", "丢包"],
"root_node": {
"id": "fc-net-1",
"type": "step",
"label": "确认故障范围(单个员工/同楼层/全公司)",
"status": "pending",
"children": [
{
"id": "fc-net-2",
"type": "decision",
"label": "影响范围多大?",
"yes_branch": {
"id": "fc-net-3",
"type": "step",
"label": "【全公司/楼层】立即升级二线:网络团队",
"children": [
{
"id": "fc-net-4",
"type": "step",
"label": "同时记录:故障时间 + 影响人数 + 现场照片"
}
]
},
"no_branch": {
"id": "fc-net-5",
"type": "step",
"label": "【单个员工】继续单点排查",
"children": [
{
"id": "fc-net-6",
"type": "decision",
"label": "有线网 or WiFi?",
"yes_branch": {
"id": "fc-net-7",
"type": "step",
"label": "检查网线 + 换端口 + 重新拨号",
"children": [
{
"id": "fc-net-8",
"type": "decision",
"label": "换端口能用?",
"yes_branch": {
"id": "fc-net-9",
"type": "step",
"label": "原端口硬件故障,工单报修"
},
"no_branch": {
"id": "fc-net-10",
"type": "step",
"label": "检查 IP 冲突:ipconfig /all + 释放续租"
}
}
]
},
"no_branch": {
"id": "fc-net-11",
"type": "step",
"label": "WiFi 排查:重连 + 忘记网络 + 检查 SSID",
"children": [
{
"id": "fc-net-12",
"type": "decision",
"label": "其他员工同位置能用 WiFi?",
"yes_branch": {
"id": "fc-net-13",
"type": "step",
"label": "员工设备问题:重装网卡驱动 + 升级系统"
},
"no_branch": {
"id": "fc-net-14",
"type": "step",
"label": "AP 信号弱:升级二线查 AP 部署"
}
}
]
}
}
]
}
}
]
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "打印机 / 外设故障排查",
"category": "printer",
"description": "员工打印失败、卡纸、驱动问题、扫描仪、U盘识别",
"estimated_time": 6,
"difficulty": 1,
"tags": ["打印", "扫描", "U盘", "外设", "驱动"],
"root_node": {
"id": "fc-print-1",
"type": "step",
"label": "确认外设类型(打印/扫描/U盘/其他)",
"status": "pending",
"children": [
{
"id": "fc-print-2",
"type": "decision",
"label": "打印机型号?",
"yes_branch": {
"id": "fc-print-3",
"type": "step",
"label": "【打印】按故障现象分流:",
"children": [
{
"id": "fc-print-4",
"type": "step",
"label": "卡纸:打开盖板 + 按箭头方向抽纸 + 检查纸槽"
},
{
"id": "fc-print-5",
"type": "step",
"label": "脱机:重新添加打印机 + 检查网络(IP 直连 or 服务器共享)"
},
{
"id": "fc-print-6",
"type": "step",
"label": "驱动异常:卸载重装 + 选对型号 + 重启打印服务"
},
{
"id": "fc-print-7",
"type": "decision",
"label": "其他员工同打印机能用?",
"yes_branch": {
"id": "fc-print-8",
"type": "step",
"label": "员工电脑问题:换电脑测试确认"
},
"no_branch": {
"id": "fc-print-9",
"type": "step",
"label": "升级二线:硬件供应商(联系信息见公告)"
}
}
]
},
"no_branch": {
"id": "fc-print-10",
"type": "step",
"label": "【扫描仪/其他外设】:",
"children": [
{
"id": "fc-print-11",
"type": "step",
"label": "扫描仪:检查 USB 连接 + 重新装驱动 + 测试扫描"
},
{
"id": "fc-print-12",
"type": "step",
"label": "U盘:插入其他电脑测试 + 检查文件系统(ExFAT 兼容性)"
},
{
"id": "fc-print-13",
"type": "step",
"label": "其他外设:走通用流程(查线/换口/换电脑/重装驱动)"
}
]
}
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"name": "软件 / 应用故障排查",
"category": "software",
"description": "员工软件装不上、闪退、license 过期、版本不兼容、Office/PS/财务软件等",
"estimated_time": 8,
"difficulty": 2,
"tags": ["软件", "Office", "安装", "闪退", "license", "财务"],
"root_node": {
"id": "fc-soft-1",
"type": "step",
"label": "确认软件名 + 版本(让员工截图)",
"status": "pending",
"children": [
{
"id": "fc-soft-2",
"type": "decision",
"label": "员工是否有管理员权限安装?",
"yes_branch": {
"id": "fc-soft-3",
"type": "step",
"label": "【管理员】继续自助排查:",
"children": [
{
"id": "fc-soft-4",
"type": "step",
"label": "装不上:检查系统版本兼容性 + 关杀毒软件 + 管理员运行"
},
{
"id": "fc-soft-5",
"type": "step",
"label": "闪退:看 Windows 事件日志 + 找 crash dump"
},
{
"id": "fc-soft-6",
"type": "step",
"label": "license 过期:走 IT 资产流程申请续期(申请单见知识库)"
}
]
},
"no_branch": {
"id": "fc-soft-7",
"type": "step",
"label": "【普通员工】坐席远程协助安装:",
"children": [
{
"id": "fc-soft-8",
"type": "step",
"label": "常用软件清单(从软件中心/SCCM):Office、Adobe、Foxmail、企微"
},
{
"id": "fc-soft-9",
"type": "step",
"label": "非常用软件:需走软件申请流程(部门主管审批 → IT 评估)"
},
{
"id": "fc-soft-10",
"type": "decision",
"label": "远程能否解决?",
"yes_branch": {
"id": "fc-soft-11",
"type": "step",
"label": "回访确认"
},
"no_branch": {
"id": "fc-soft-12",
"type": "step",
"label": "升级二线:对应软件负责人"
}
}
]
}
}
]
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "硬件 / 桌面设备故障排查",
"category": "hardware",
"description": "员工显示器、键盘鼠标、耳机、视频会议摄像头、笔记本电池等",
"estimated_time": 10,
"difficulty": 2,
"tags": ["硬件", "显示器", "键盘", "鼠标", "耳机", "摄像头"],
"root_node": {
"id": "fc-hw-1",
"type": "step",
"label": "确认设备类型(显示器/键鼠/耳机/摄像头/其他)",
"status": "pending",
"children": [
{
"id": "fc-hw-2",
"type": "decision",
"label": "故障设备能换一台测试吗?",
"yes_branch": {
"id": "fc-hw-3",
"type": "step",
"label": "换设备测试,确认是设备本身问题:",
"children": [
{
"id": "fc-hw-4",
"type": "step",
"label": "【显示器】:换视频线(HDMI/DP/VGA) + 检查分辨率"
},
{
"id": "fc-hw-5",
"type": "step",
"label": "【键鼠】:换 USB 口 + 换电池 + 蓝牙重新配对"
},
{
"id": "fc-hw-6",
"type": "step",
"label": "【耳机/摄像头】:检查 USB/3.5mm + 隐私盖 + 系统权限"
},
{
"id": "fc-hw-7",
"type": "decision",
"label": "换设备后正常?",
"yes_branch": {
"id": "fc-hw-8",
"type": "step",
"label": "原设备故障:走 IT 资产报废/更换流程"
},
"no_branch": {
"id": "fc-hw-9",
"type": "step",
"label": "电脑端问题:检查驱动 + 系统设置"
}
}
]
},
"no_branch": {
"id": "fc-hw-10",
"type": "step",
"label": "【笔记本内嵌设备】:屏幕/键盘/电池/CPU 风扇",
"children": [
{
"id": "fc-hw-11",
"type": "step",
"label": "走送修流程(备份数据 → IT 出具送修单 → 厂商维修)"
},
{
"id": "fc-hw-12",
"type": "step",
"label": "需要备用机:走 IT 资产借用流程(最长 2 周)"
},
{
"id": "fc-hw-13",
"type": "step",
"label": "升级二线:硬件供应商(联系信息见公告)"
}
]
}
}
]
}
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
把 9 套排查流程图 JSON 合并到一个数组,输出 00-all.json(便于一次性 import)。
用法:python build_all.py
"""
import json
import glob
import os
import sys
from pathlib import Path
HERE = Path(__file__).parent
def main():
# 1. 找 9 个单文件(排除 00-all.json 和 build_all.py)
files = sorted(HERE.glob("[0-9][0-9]-*.json"))
if not files:
print("❌ 没找到任何 0X-*.json 文件")
sys.exit(1)
print(f"📦 找到 {len(files)} 个模板文件:")
for f in files:
print(f" - {f.name}")
# 2. 逐个读 + 校验
templates = []
for f in files:
try:
with open(f, "r", encoding="utf-8") as fp:
tpl = json.load(fp)
# 简单校验
for required in ("name", "category", "root_node"):
if required not in tpl:
raise ValueError(f"缺少必要字段: {required}")
templates.append(tpl)
print(f"{f.name}: {tpl['name']} ({len(json.dumps(tpl, ensure_ascii=False))} 字符)")
except Exception as e:
print(f"{f.name}: {e}")
sys.exit(1)
# 3. 输出汇总文件
out = HERE / "00-all.json"
with open(out, "w", encoding="utf-8") as fp:
json.dump(templates, fp, ensure_ascii=False, indent=2)
print(f"\n✅ 已生成 {out.name} (共 {len(templates)} 套)")
print(f"\n💡 接下来你可以:")
print(f" 1. 打开 {out.name} 预览 9 套完整内容")
print(f" 2. 在 Admin 后台的「排查流程图」页 → 「导入 JSON」选择此文件")
print(f" 3. 或调用后端 API:")
print(f" for tpl in templates: POST /api/troubleshooting-templates")
if __name__ == "__main__":
main()
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 服务器部署指南 # 企微智能IT支持服务台 — 服务器部署指南
> 目标服务器:`10.90.5.110`Linux > 目标服务器:`10.90.5.110`Linux
> 域名:`itsupport.servyou.com.cn` > 域名:`itsupport.servyou.com.cn`
+36 -30
View File
@@ -1,6 +1,6 @@
# IT智能服务台 — 新服务器部署手册 # 智能IT支持服务台 — 新服务器部署手册
> **目标服务器**`10.80.0.136`(公司内网) > **目标服务器**`10.90.5.110`(公司内网,**2026-06-15 起替代 10.80.0.136**
> **域名**`itsupport.servyou.com.cn` > **域名**`itsupport.servyou.com.cn`
> **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`OTP 动态口令认证) > **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`OTP 动态口令认证)
> **Docker**:已安装 > **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 已安装 | ✅ 已确认 | `docker --version` |
| Docker Compose V2 | 待确认 | `docker compose version` | | Docker Compose V2 | 待确认 | `docker compose version` |
| 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` | | 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` |
@@ -29,17 +29,22 @@
### 2.2 连接方式 ### 2.2 连接方式
```bash **PuTTY 客户端(用户实际使用)**:
# 方式一:ssh -J 一步跳转(推荐) - 打开 PuTTY
# -J 指定跳板机,ssh 会自动帮你跳转 - Host Name(IP 地址):`10.212.189.210`
# 堡垒机端口 2222,需要输入 OTP 动态口令 - Port:`2222`
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 - Connection type:SSH
- Saved Sessions:起名(如 `wecom-bastion`)→ Save
- 点 Open
- 用户 `sxn` + 密码
- **堡垒机内再跳目标机**:
```bash
ssh sxn@10.90.5.110
```
# 方式二:先登录堡垒机,再手动跳转 > **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15)
ssh -p 2222 sxn@10.212.189.210
# 输入 OTP 动态口令
# 登录成功后: # 登录成功后:
ssh sxn@10.80.0.136 ssh sxn@10.90.5.110
``` ```
### 2.3 配置 SSH 快捷方式(推荐) ### 2.3 配置 SSH 快捷方式(推荐)
@@ -53,9 +58,9 @@ Host bastion
Port 2222 Port 2222
User sxn User sxn
# IT智能服务台服务器 # 智能IT支持服务台服务器
Host itdesk Host itdesk
HostName 10.80.0.136 HostName 10.90.5.110
User sxn User sxn
ProxyJump bastion ProxyJump bastion
``` ```
@@ -78,7 +83,7 @@ scp file itdesk:/opt/ # 文件传输也会自动走堡垒机
# 上传单个文件 # 上传单个文件
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
it-smart-desk-server-deploy.zip \ it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/opt/ sxn@10.90.5.110:/opt/
# 如果已配置 ~/.ssh/config # 如果已配置 ~/.ssh/config
scp it-smart-desk-server-deploy.zip itdesk:/opt/ 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 ssh -p 2222 sxn@10.212.189.210
# 步骤3:从堡垒机传到目标服务器 # 步骤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" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
it-smart-desk-server-deploy.zip \ it-smart-desk-server-deploy.zip \
sxn@10.80.0.136:/tmp/ sxn@10.90.5.110:/tmp/
``` ```
> 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限 > 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限
### 步骤 3SSH 登录服务器并解压 ### 步骤 3:登录服务器并解压
**PuTTY 登录**(见 §2.2):
- Host:`10.212.189.210`,Port:`2222`,SSH
- 堡垒机内再 `ssh sxn@10.90.5.110`
```bash ```bash
# 登录目标服务器
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
# 切换 root(普通用户对 /opt 无写权限) # 切换 root(普通用户对 /opt 无写权限)
sudo -i sudo -i
@@ -237,7 +243,7 @@ docker compose logs --tail 50 postgres
需要联系公司 IT 运维,在公司 DNS 上添加 A 记录: 需要联系公司 IT 运维,在公司 DNS 上添加 A 记录:
``` ```
itsupport.servyou.com.cn A 10.80.0.136 itsupport.servyou.com.cn A 10.90.5.110
``` ```
**DNS 未生效前**,可以通过本地 hosts 文件测试: **DNS 未生效前**,可以通过本地 hosts 文件测试:
@@ -246,7 +252,7 @@ itsupport.servyou.com.cn A 10.80.0.136
# Windows: C:\Windows\System32\drivers\etc\hosts # Windows: C:\Windows\System32\drivers\etc\hosts
# macOS/Linux: /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` 清除缓存,或用无痕窗口测试。 > 注意:修改 hosts 文件后,浏览器可能有 DNS 缓存。Chrome 可访问 `chrome://net-internals/#dns` 清除缓存,或用无痕窗口测试。
@@ -324,11 +330,11 @@ cd frontend-agent && npm run build
# 2. 上传到服务器(通过堡垒机) # 2. 上传到服务器(通过堡垒机)
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r frontend-h5/dist/ \ -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" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r frontend-agent/dist/ \ -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(不需要重启整个服务) # 3. 重载 Nginx(不需要重启整个服务)
ssh itdesk # 如果已配置 SSH 快捷方式 ssh itdesk # 如果已配置 SSH 快捷方式
@@ -344,7 +350,7 @@ docker exec wecom_it_nginx nginx -s reload
# 1. 上传新代码到服务器 # 1. 上传新代码到服务器
scp -o "ProxyJump=sxn@10.212.189.210:2222" \ scp -o "ProxyJump=sxn@10.212.189.210:2222" \
-r backend/ \ -r backend/ \
sxn@10.80.0.136:/opt/wecom-it-desk/backend/ sxn@10.90.5.110:/opt/wecom-it-desk/backend/
# 2. 重新构建并启动 # 2. 重新构建并启动
ssh itdesk 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 nslookup itsupport.servyou.com.cn
# 如果 DNS 未配置,临时用 IP 直接访问 # 如果 DNS 未配置,临时用 IP 直接访问
curl http://10.80.0.136/itdesk/ curl http://10.90.5.110/itdesk/
curl http://10.80.0.136/api/health curl http://10.90.5.110/api/health
``` ```
### Mock 登录返回 401 ### Mock 登录返回 401
@@ -428,7 +434,7 @@ curl -X POST http://localhost/api/h5/mock-login \
### 方式一:公司统一 SSL 终端(推荐) ### 方式一:公司统一 SSL 终端(推荐)
``` ```
客户端 → HTTPS → 公司SSL终端(F5/网关) → HTTP → 10.80.0.136:80 客户端 → HTTPS → 公司SSL终端(F5/网关,公网 115.236.188.3) → HTTP → 10.90.5.110:80
``` ```
不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。 不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。
@@ -441,7 +447,7 @@ curl -X POST http://localhost/api/h5/mock-login \
## 十一、与 NAS 部署的差异 ## 十一、与 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 | | 容器数量 | 5个(含 cloudflared | 4个(无 cloudflared |
| 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 | | 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 |
+2 -2
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 打包 + 构建后端镜像 + 部署脚本 # 企微智能IT支持服务台 — 打包 + 构建后端镜像 + 部署脚本
# ============================================================================= # =============================================================================
# 功能: # 功能:
# 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env # 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -51,7 +51,7 @@ function Write-Error {
Write-Host "" Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署自动化" -ForegroundColor Cyan Write-Host " 企微智能IT支持服务台 — 打包部署自动化" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 模式:$Mode" -ForegroundColor White Write-Host " 模式:$Mode" -ForegroundColor White
Write-Host "" Write-Host ""
+2 -2
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 打包部署脚本 # 企微智能IT支持服务台 — 打包部署脚本
# ============================================================================= # =============================================================================
# 功能:将所有部署所需文件打包成一个 zip 文件 # 功能:将所有部署所需文件打包成一个 zip 文件
# 用法:在 PowerShell 中运行此脚本 # 用法:在 PowerShell 中运行此脚本
@@ -19,7 +19,7 @@ $packageDir = "$deployDir\_package"
$zipFile = "$deployDir\it-smart-desk-server-deploy.zip" $zipFile = "$deployDir\it-smart-desk-server-deploy.zip"
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 企微IT智能服务台 — 打包部署文件" -ForegroundColor Cyan Write-Host " 企微智能IT支持服务台 — 打包部署文件" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# IT智能服务台 — RAGFlow 集成部署脚本 # 智能IT支持服务台 — RAGFlow 集成部署脚本
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 部署路径:/opt/wecom-it-desk # 部署路径:/opt/wecom-it-desk
# ============================================================================= # =============================================================================
@@ -11,7 +11,7 @@ DEPLOY_DIR="/opt/wecom-it-desk"
BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)"
echo "==========================================" echo "=========================================="
echo "IT智能服务台 — RAGFlow 集成部署" echo "智能IT支持服务台 — RAGFlow 集成部署"
echo "时间: $(date)" echo "时间: $(date)"
echo "==========================================" echo "=========================================="
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# IT智能服务台 — 生产部署脚本 # 智能IT支持服务台 — 生产部署脚本
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 部署路径:/opt/wecom-it-desk # 部署路径:/opt/wecom-it-desk
# ============================================================================= # =============================================================================
@@ -11,7 +11,7 @@ DEPLOY_DIR="/opt/wecom-it-desk"
BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="/opt/wecom-it-desk-backup-$(date +%Y%m%d_%H%M%S)"
echo "==========================================" echo "=========================================="
echo "IT智能服务台 生产部署" echo "智能IT支持服务台 生产部署"
echo "时间: $(date)" echo "时间: $(date)"
echo "==========================================" echo "=========================================="
+1 -1
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Docker Compose(公司内网服务器版) # 企微智能IT支持服务台 — Docker Compose(公司内网服务器版)
# ============================================================================= # =============================================================================
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn # 域名:itsupport.servyou.com.cn
+18 -1
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版 + HTTPS) # 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版 + HTTPS)
# ============================================================================= # =============================================================================
# 目标服务器:10.90.5.110 # 目标服务器:10.90.5.110
# 域名:itsupport.servyou.com.cn # 域名:itsupport.servyou.com.cn
@@ -47,6 +47,23 @@ http {
application/javascript application/xml+rss application/javascript application/xml+rss
application/json application/ld+json; application/json application/ld+json;
# ------------------------------------------------------------------
# 安全响应头
# ------------------------------------------------------------------
# 隐藏 nginx 版本号
server_tokens off;
# 基础安全头(应用到所有响应)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# API 路径特殊处理(不加 CSP,只加基础安全头)
# 前端路径的 CSP 在各前端 index.html 中单独配置
# ================================================================= # =================================================================
# 上游服务定义(Docker 内部网络) # 上游服务定义(Docker 内部网络)
# ================================================================= # =================================================================
+62 -24
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版) # 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版)
# ============================================================================= # =============================================================================
# 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析 # 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析
# 与 NAS 版的区别: # 与 NAS 版的区别:
@@ -27,6 +27,21 @@ http {
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn; 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,20 +75,61 @@ http {
# 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80 # 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径 # 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
# ================================================================= # =================================================================
# HTTP — 80 端口强制 301 跳 HTTPS
# =================================================================
server { server {
listen 80; listen 80;
server_name itsupport.servyou.com.cn; 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-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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;
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 健康检查端点 # 健康检查端点
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -138,7 +194,7 @@ http {
allow 10.212.0.0/16; allow 10.212.0.0/16;
deny all; deny all;
proxy_pass http://backend_api/; proxy_pass http://backend_api;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -165,6 +221,7 @@ http {
# WebSocket — /ws/(坐席端实时通信) # WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
location /ws/ { location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
proxy_pass http://backend_api; proxy_pass http://backend_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -182,29 +239,10 @@ http {
# 此路径已包含在 /api/ 的代理规则中,无需单独配置 # 此路径已包含在 /api/ 的代理规则中,无需单独配置
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 默认路径 — 重定向到 H5 员工端 # 默认路径 — 重定向到统一入口
# ------------------------------------------------------------------ # ------------------------------------------------------------------
location = / { 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 相同
# ...
# }
} }
+2 -2
View File
@@ -1,11 +1,11 @@
@echo off @echo off
REM ============================================================================= REM =============================================================================
REM IT智能服务台 — 打包部署脚本(Windows) REM 智能IT支持服务台 — 打包部署脚本(Windows)
REM 目标:生成部署包,通过堡垒机上传到服务器 REM 目标:生成部署包,通过堡垒机上传到服务器
REM ============================================================================= REM =============================================================================
echo ========================================== echo ==========================================
echo IT智能服务台 部署包打包 echo 智能IT支持服务台 部署包打包
echo 时间: %date% %time% echo 时间: %date% %time%
echo ========================================== echo ==========================================
+40 -9
View File
@@ -1,5 +1,5 @@
""" """
企微IT智能服务台 — 部署包生成脚本(Windows 兼容版) 企微智能IT支持服务台 — 部署包生成脚本(Windows 兼容版)
======================================================= =======================================================
功能: 功能:
1. 构建前端(H5 + 坐席端) 1. 构建前端(H5 + 坐席端)
@@ -40,6 +40,8 @@ INCLUDE_MAP = {
"deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf", "deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf",
"frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist", "frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist",
"frontend-agent/dist": f"{PACKAGE_PREFIX}/frontend-agent/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", "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: def should_exclude(path: Path) -> bool:
"""判断文件/目录是否应排除""" """判断文件/目录是否应排除"""
# 路径中任何一段是 uploads 目录就排除(隐私 P0:真实用户上传文件不进部署包)
if "uploads" in path.parts:
return True
name = path.name name = path.name
if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}: if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}:
return True return True
@@ -121,6 +126,32 @@ def build_frontends():
sys.exit(1) sys.exit(1)
print(" ✅ 坐席工作台构建完成") 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(): def create_package():
"""创建部署包 zip""" """创建部署包 zip"""
@@ -163,7 +194,7 @@ def create_package():
def main(): def main():
print("=" * 50) print("=" * 50)
print(" IT智能服务台 — 部署包生成") print(" 智能IT支持服务台 — 部署包生成")
print("=" * 50) print("=" * 50)
# 检查是否跳过构建 # 检查是否跳过构建
@@ -181,13 +212,13 @@ def main():
print(" 后续步骤:") print(" 后续步骤:")
print("=" * 50) print("=" * 50)
print(f""" print(f"""
1. 上传部署包到服务器(通过堡垒机): 1. 上传部署包到服务器(通过堡垒机 / PuTTY PSCP):
scp -o "ProxyJump=sxn@10.212.189.210:2222" \\ pscp -load wecom-bastion {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
{ZIP_FILENAME} \\ # 或堡垒机内 scp:
sxn@10.80.0.136:/tmp/ # scp {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
2. SSH 登录服务器(通过堡垒机) 2. PuTTY 登录服务器:
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136 - Host 10.212.189.210 Port 2222 → 用户 sxn → 堡垒机内 ssh sxn@10.90.5.110
3. 在服务器上执行: 3. 在服务器上执行:
sudo cp /tmp/{ZIP_FILENAME} /opt/ sudo cp /tmp/{ZIP_FILENAME} /opt/
@@ -201,7 +232,7 @@ def main():
./deploy.sh ./deploy.sh
4. 配置 DNS(联系 IT 运维): 4. 配置 DNS(联系 IT 运维):
itsupport.servyou.com.cn → 10.80.0.136 itsupport.servyou.com.cn → 10.90.5.110
5. 浏览器验证: 5. 浏览器验证:
http://itsupport.servyou.com.cn/itdesk/ http://itsupport.servyou.com.cn/itdesk/
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# 企微IT智能服务台 — 部署包生成脚本(在开发机上运行) # 企微智能IT支持服务台 — 部署包生成脚本(在开发机上运行)
# ============================================================================= # =============================================================================
# 功能: # 功能:
# 1. 构建前端(H5 + 坐席端) # 1. 构建前端(H5 + 坐席端)
@@ -28,7 +28,7 @@ PACKAGE_NAME="it-smart-desk-server-deploy"
BUILD_DIR="/tmp/$PACKAGE_NAME" BUILD_DIR="/tmp/$PACKAGE_NAME"
echo -e "${GREEN}============================================${NC}" echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} IT智能服务台 — 部署包生成${NC}" echo -e "${GREEN} 智能IT支持服务台 — 部署包生成${NC}"
echo -e "${GREEN}============================================${NC}" echo -e "${GREEN}============================================${NC}"
# --- 1. 构建前端 --- # --- 1. 构建前端 ---
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off @echo off
REM ============================================================================= REM =============================================================================
REM 企微IT智能服务台 — 打包部署一键执行 REM 企微智能IT支持服务台 — 打包部署一键执行
REM ============================================================================= REM =============================================================================
REM 功能: REM 功能:
REM 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env REM 1. 打包前端构建产物 + nginx配置 + docker-compose.yml + .env
@@ -20,7 +20,7 @@ if "%MODE%"=="" set MODE=local
echo. echo.
echo ======================================== echo ========================================
echo 企微IT智能服务台 — 打包部署 echo 企微智能IT支持服务台 — 打包部署
echo ======================================== echo ========================================
echo 模式: %MODE% echo 模式: %MODE%
echo. echo.
+1
View File
@@ -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==
+84
View File
@@ -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
+106
View File
@@ -0,0 +1,106 @@
# =============================================================================
# 企微IT智能服务台 — 本地开发环境 Docker Compose
# =============================================================================
# 目标:本地电脑(Windows + Docker Desktop)
# 用途:开发 + 测试,不依赖企微 OAuth,代码 volume mount 自动 reload
# 用法:
# 1. cp .env.example .env.dev (编辑填值,或直接用 .env.dev 模板)
# 2. docker compose -f docker-compose.dev.yml up -d
# 3. 前端 4 端各跑 pnpm dev(Vite proxy /api → backend:8000)
# 启动后:
# - Backend: http://localhost:8000 (Swagger: /docs)
# - Postgres: localhost:5432
# - Redis: localhost:6379
# =============================================================================
services:
# --------------------------------------------------------------------------
# PostgreSQL 16 — 开发数据库
# --------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: dev_wecom_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-wecom}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wecom_dev}
POSTGRES_DB: ${POSTGRES_DB:-wecom_it_desk_dev}
ports:
- "5432:5432" # 暴露到宿主机,方便用 Navicat/psql 连
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wecom}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- dev-net
# --------------------------------------------------------------------------
# Redis 7 — 开发缓存
# --------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: dev_wecom_redis
restart: unless-stopped
command: redis-server --appendonly yes --save 900 1 --save 300 10
ports:
- "6379:6379"
volumes:
- redis_dev_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- dev-net
# --------------------------------------------------------------------------
# Backend — 开发模式(代码 volume mount + uvicorn --reload)
# --------------------------------------------------------------------------
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev # dev 版(无需 apt 装 gcc,快)
image: wecom-it-desk-backend:dev
container_name: dev_wecom_backend
restart: unless-stopped
env_file:
- .env.dev
environment:
# 容器内用 service name(host 是 localhost,容器内是 postgres/redis)
- DATABASE_URL=postgresql://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD:-wecom_dev}@postgres:5432/${POSTGRES_DB:-wecom_it_desk_dev}
- REDIS_URL=redis://redis:6379/0
- DEV_MODE=true # 开启 Mock 企微 OAuth
- CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
# PYTHONPATH 必须含 /app,否则 alembic upgrade head 跑 env.py 时
# `from app.config import settings` 会 ModuleNotFoundError
# (alembic 1.13+ 不再默认 prepend cwd 到 sys.path)
- PYTHONPATH=/app
ports:
- "8000:8000" # 暴露到宿主机
volumes:
# 关键:volume mount 源码,改代码自动 reload
- ./backend/app:/app/app
- ./backend/alembic:/app/alembic
- ./backend/scripts:/app/scripts
command: >
sh -c "alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- dev-net
volumes:
postgres_dev_data:
redis_dev_data:
networks:
dev-net:
driver: bridge
+10 -4
View File
@@ -100,6 +100,10 @@ services:
# 服务配置 # 服务配置
- BACKEND_HOST=0.0.0.0 - BACKEND_HOST=0.0.0.0
- BACKEND_PORT=8000 - BACKEND_PORT=8000
# 上传文件目录(持久化)
- UPLOAD_DIR=/app/uploads
volumes:
- backend-uploads:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -115,11 +119,11 @@ services:
networks: networks:
- it-desk-internal - it-desk-internal
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"]
interval: 15s interval: 30s
timeout: 5s timeout: 10s
retries: 3 retries: 3
start_period: 30s start_period: 40s
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -173,3 +177,5 @@ volumes:
name: wecom_it_postgres_data name: wecom_it_postgres_data
redis_data: redis_data:
name: wecom_it_redis_data name: wecom_it_redis_data
backend-uploads:
name: wecom_it_backend_uploads
+3 -3
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 项目总览与部署手册 # 企微智能IT支持服务台 — 项目总览与部署手册
> **版本**: v2.1 | **日期**: 2026-06-03 | **编制**: 宋献(IT支持组组长) > **版本**: v2.1 | **日期**: 2026-06-03 | **编制**: 宋献(IT支持组组长)
> **目标读者**: **管理者 / 架构师 / 运维** — 了解项目全貌、架构决策、部署与运维操作 > **目标读者**: **管理者 / 架构师 / 运维** — 了解项目全貌、架构决策、部署与运维操作
@@ -570,7 +570,7 @@ docker compose down # 停止新系统所有容器
### TL;DR ### TL;DR
企微IT智能服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。 企微智能IT支持服务台第一步(消息接管 + 极简坐席台)全部代码已完成并通过测试,共 **110+ 文件****116/116 测试全部通过**,覆盖后端 API、坐席工作台、用户端 H5 三个子系统。
### 交付状态 ### 交付状态
@@ -641,7 +641,7 @@ wecom_it_smart_desk/
├── ARCHITECTURE.md # 系统架构设计(合并版) ├── ARCHITECTURE.md # 系统架构设计(合并版)
├── 01-项目总览与部署手册.md # 管理者视角部署手册 ├── 01-项目总览与部署手册.md # 管理者视角部署手册
├── 开发交付概览.md # 开发交付状态总览 ├── 开发交付概览.md # 开发交付状态总览
├── IT智能服务台-项目迁移文档.md # 工作区迁移记录 ├── 智能IT支持服务台-项目迁移文档.md # 工作区迁移记录
├── testing/ # 测试报告目录 ├── testing/ # 测试报告目录
│ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告 │ └── QA_COMPREHENSIVE_REPORT.md # 综合 QA 报告
├── diagrams/ # Mermaid 图表 ├── diagrams/ # Mermaid 图表
@@ -0,0 +1,61 @@
# ADR-001: Gitea 自托管 + Tailscale Funnel 暴露
**状态**: ✅ 已采纳
**日期**: 2026-06-14
**决策者**: 宋献 + Claude 评审
**关联**: [[Gitea部署指南]] / [[风险跟踪表]] 第十二节
---
## 1. 背景
项目主仓 `D:\资料\03-项目开发\wecom_it_smart_desk\` 需要:
- 跨设备协作(simon's 电脑 + workbuddy 沙箱)
- 推送评审 + 分支保护
- 异地可访问(workbuddy 沙箱无 Tailscale 内网)
## 2. 评估方案
| 方案 | 优势 | 劣势 | 结论 |
|---|---|---|---|
| **A. GitHub 私有仓** | 零运维 + 全球 CDN + 完善 Actions | 代码在境外(企业合规风险)+ 付费 | ❌ 否决 |
| **B. GitLab.com 私有仓** | 免费私有 + 完善 CI | 代码在境外 + workbuddy 沙箱访问延迟 | ❌ 否决 |
| **C. Gitea 自托管(NAS)+ Tailscale Funnel** | 数据本地 + workbuddy 可访问 + 免费 | NAS 单点故障 + Funnel 稳定性依赖 Tailscale | ✅ **采纳** |
| **D. Gitea 自托管 + 公网 IP 暴露** | 不依赖 Tailscale | 需配 SSL + DDOS 风险 + 国内带宽限制 | ❌ 否决 |
## 3. 决策
**采纳 C 方案**: Gitea 套件(DS923+ NAS)+ Tailscale Funnel 暴露公网。
## 4. 关键参数
| 项 | 值 | 备注 |
|---|---|---|
| Gitea 版本 | 1.22+ | 套件中心固定 |
| 端口 | 8418 (HTTP) | 避开被占端口 |
| 数据库 | SQLite3 | 单机够用,简化部署 |
| Tailscale 私网 | `tail58d872.ts.net` | DSM 已配 |
| Funnel 域名 | `https://ds923plus.tail58d872.ts.net` | 沙箱访问 |
| 备份 | `scripts/backup-gitea.sh` cron 3 点 | 见 [[Gitea部署指南]] §6 |
| 异地备份 | OSS / COS 推 | M-1 风险项待解决 |
## 5. 风险与缓解
| 风险 | 等级 | 缓解 |
|---|---|---|
| NAS 硬盘故障 | 🟠 高 | 异地 OSS 备份(待配) |
| Tailscale Funnel 稳定性 | 🟡 中 | Funnel 故障时降级 LAN(`http://100.85.152.112:8418`) |
| 卸载误操作数据丢失 | 🟡 中 | 备份脚本 + 卸载前 checklist |
| token 泄露 | 🟠 高 | token 不入文件,走 wincred |
## 6. 决策影响
- ✅ 团队协作无需 VPN(workbuddy 沙箱直连 Funnel)
- ✅ 推送评审 + 分支保护(PR + 1 reviewer)
- ⚠️ NAS 单点是隐患,需异地备份
- ⚠️ 卸载/迁移需严格按 [[Gitea部署指南]] §8 走
## 7. 后续评审
- 3 个月后(2026-09-14)评审:Funnel 稳定性 + 备份完整度
- 6 个月后(2026-12-14)评审:是否切到企业 GitLab(如果合规要求)
@@ -0,0 +1,80 @@
# ADR-002: WebSocket Token 鉴权(走 Sec-WebSocket-Protocol)
**状态**: ✅ 已采纳
**日期**: 2026-06-14
**决策者**: 宋献 + Claude 评审
**关联**: [[风险跟踪表]] 第十节 / 评审报告 `workbuddy-2026-06-14-P0安全.md`
---
## 1. 背景
WebSocket 鉴权原方案:`ws://server/ws/?token=<JWT>` —— **token 在 URL 里**:
- ❌ 被 nginx access_log 记录
- ❌ 被 CDN / 反代记录
- ❌ 被浏览器历史记录
**P0 漏洞**(H-11 风险项),已修复。
## 2. 评估方案
| 方案 | 浏览器支持 | token 泄露 | 实施难度 | 结论 |
|---|---|---|---|---|
| **A. Authorization: Bearer header** | ❌ 浏览器 WS API 不支持自定义 header | ✅ 不泄 | 中 | ❌ 否决(浏览器限制) |
| **B. Sec-WebSocket-Protocol: bearer.<token>** | ✅ 现代浏览器都支持 | ✅ 不在 URL | 低 | ✅ **采纳** |
| **C. 第一条消息传 token** | ✅ 全支持 | ⚠️ 需先开 WS 接受任意连接(无法鉴权) | 低 | ❌ 否决 |
| **D. Cookie 自动带** | ✅ 全支持 | ⚠️ CSRF 风险 | 中 | ❌ 否决 |
## 3. 决策
**采纳 B 方案**: `Sec-WebSocket-Protocol: bearer.<token>`
服务端协商 subprotocol,客户端用第二个 subprotocol 传 token(浏览器 API `new WebSocket(url, [subprotocols])`)。
## 4. 实现
### 4.1 前端
```ts
// frontend-agent/src/composables/useWebSocket.ts
const ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
```
### 4.2 后端
```python
# backend/app/api/ws.py
subprotocol = request.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:]
else:
# 降级:Authorization header
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
else:
# 降级:query param(已废,只用于兼容旧前端)
token = request.query_params.get("token", "")
```
## 5. 降级路径
| 优先级 | 来源 | 用途 |
|---|---|---|
| 1 | Sec-WebSocket-Protocol | 标准(主) |
| 2 | Authorization: Bearer | Postman / 测试工具 |
| 3 | query `?token=` | 已废(留兼容) |
## 6. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 浏览器 API 不支持 subprotocol | 现代浏览器(2020+)都支持,无问题 |
| 旧客户端不更新 | query param 降级仍可用,但提示更新 |
| nginx 仍记录 subprotocol | `location /ws/ { access_log off; }` 配合 |
## 7. 决策影响
- ✅ WS 鉴权修复,token 不再泄
- ✅ nginx access_log 关闭,旧 token 不留痕
- ⚠️ 旧客户端需更新(发版通知)
+106
View File
@@ -0,0 +1,106 @@
# ADR-003: nginx 敏感路径 access_log 关闭
**状态**: ✅ 已采纳
**日期**: 2026-06-14
**决策者**: 宋献 + Claude 评审
**关联**: [[风险跟踪表]] 第十节 / 评审报告 `workbuddy-2026-06-14-P0安全.md`
---
## 1. 背景
nginx `access_log` 默认记录所有请求,含敏感信息:
- `Authorization: Bearer <token>`
- `?token=<JWT>`
- `Cookie: session=<sid>`
敏感路径必须关闭 access_log,避免 token 永久落盘。
## 2. 决策
**敏感路径一律 `access_log off`**,具体见下表。
## 3. 关闭清单
| 路径 | 原因 | access_log |
|---|---|---|
| `/ws/` | WebSocket token 鉴权 | `off` |
| `/api/v1/auth/login` | 密码登录 | `off` |
| `/api/v1/auth/refresh` | token 刷新 | `off` |
| `/api/v1/h5/oauth/callback` | OAuth2 回调 | `off` |
| `/api/v1/wecom/callback` | 企微回调(验证 URL 含 echostr) | `off` |
| `/api/v1/agents/login` | 坐席登录 | `off` |
| `/api/v1/upload*` | 文件上传(可能含敏感文件名) | `off` |
| `/health` `/healthz` `/readyz` | 健康检查(高频) | `off` |
## 4. 实现
```nginx
server {
# 全局
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# WS(敏感)
location /ws/ {
access_log off;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 登录(敏感)
location ~ ^/api/v1/(auth|agents)/login$ {
access_log off;
proxy_pass http://backend;
}
# 健康检查(高频)
location ~ ^/(health|healthz|readyz)$ {
access_log off;
proxy_pass http://backend;
}
# 其它
location / {
proxy_pass http://backend;
}
}
```
## 5. error_log 仍开启
⚠️ **error_log 仍开** —— 4xx/5xx 错误需要留痕(token 在 error log 里出现频率低,且 error log 有 TTL 自动切割)。
## 6. 日志清理脚本
`/etc/logrotate.d/nginx` 配:
```
/var/log/nginx/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
sharedscripts
prerotate
if [ -d /etc/logrotate.d/httpd-prerotate ]; then \
run-parts /etc/logrotate.d/httpd-prerotate; \
fi
endscript
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1
endscript
}
```
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 漏关某个敏感路径 | 定期审计(任务 W-5,workbuddy 跑) |
| 调试时无 access_log 难定位 | debug 时临时开 `access_log /tmp/debug.log;` |
| 攻击者利用关闭日志 | error_log 仍开,异常请求有记录 |
@@ -0,0 +1,101 @@
# ADR-004: Token 不入文件,走 wincred 缓存
**状态**: ✅ 已采纳
**日期**: 2026-06-14
**决策者**: 宋献 + Claude 评审
**关联**: [[风险跟踪表]] 第十二节 12.6 / 推送约定
---
## 1. 背景
之前 Gitea 推送 token 直接嵌入 `.git/config``origin.url`:
```
url = https://ae236991c3d5...@ds923plus.tail58d872.ts.net/...
```
**风险**:
- ❌ token 明文落盘
- ❌ token 失效后难更新(URL 整体换)
- ❌ 误 `git add .git/` 可能入仓(虽然 .git/config 本身不入仓,但 .git/ 目录其他文件可能)
- ❌ auto-classifier 拒绝重写 URL(防误操作)
**事故**: 2026-06-14 workbuddy-claude token 失效后,`origin.url` 残留死凭据。
## 2. 决策
**`.git/config``origin.url` 只写用户名,token 走 git credential helper(wincred)缓存**。
## 3. 实现
### 3.1 配 remote URL(无 token)
```bash
git remote add origin https://simon@ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk.git
# 或修复现有:
git remote set-url origin https://simon@ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk.git
```
### 3.2 配 credential helper
`.git/config`:
```ini
[credential]
helper = manager # Windows = wincred / Linux = git-credential-manager
```
### 3.3 首次推(输一次 token)
```bash
git push -u origin main
# 弹窗 → username 留空,password = token
# wincred 自动缓存
```
### 3.4 换 token(必走)
```bash
# 清旧缓存
printf "protocol=https\nhost=ds923plus.tail58d872.ts.net\nusername=simon\n" | git credential reject
# 存新缓存(一次性,token 在 heredoc 不入文件)
printf "protocol=https\nhost=ds923plus.tail58d872.ts.net\nusername=simon\npassword=NEW_TOKEN\n" | git credential approve
# 验证
git push origin main
# 应不弹窗
```
## 4. workbuddy 推送同理
`.workbuddy/config.json` 是 workbuddy 自己的凭据存储(类比 .git/config),**入仓** ❌。
**正确做法**:
- `.workbuddy/config.json` 写用户名/URL/其他配置,**不写 token**
- workbuddy 启动时读 `gitea.token` 字段(从环境变量 / 启动参数传入)
- 或者 workbuddy 自己也用 git credential helper
**已加 .gitignore**:
```gitignore
.workbuddy/config.json
.workbuddy/config.local.json
.workbuddy/*.token
.workbuddy/credentials*
.workbuddy/.env*
```
## 5. 优势
- ✅ token 不入文件(只入 wincred 系统密钥环)
- ✅ 换 token 简单(`credential reject` + `approve`)
- ✅ 不会误入仓
- ✅ auto-classifier 不拒绝(无 token 写文件)
## 6. 风险与缓解
| 风险 | 缓解 |
|---|---|
| wincred 缓存被读(本机攻击) | 操作系统级防护 + 强密码 + BitLocker |
| 跨设备不能用 wincred | Linux 用 `git-credential-manager`,Mac 用 `git-credential-osxkeychain` |
| 换电脑忘缓存 | `git credential approve` 一次性配置 |
| token 在环境变量 | 仍比文件安全 + CI 用 secret store |
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台架构设计文档 # 智能IT支持服务台 — 管理后台架构设计文档
> **文档版本**: v1.0 > **文档版本**: v1.0
> **架构师**: 高见远 (Bob) > **架构师**: 高见远 (Bob)
+2 -2
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 系统架构设计文档 # 企微智能IT支持服务台 — 系统架构设计文档
> **文档版本**: v0.11 > **文档版本**: v0.11
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
@@ -2877,4 +2877,4 @@ alembic upgrade head
--- ---
> **文档结束** — 本架构设计文档涵盖企微IT智能服务台第一步(消息接管+极简坐席)的完整技术方案,作为工程师编写代码的基准文档。 > **文档结束** — 本架构设计文档涵盖企微智能IT支持服务台第一步(消息接管+极简坐席)的完整技术方案,作为工程师编写代码的基准文档。
+1 -1
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 远程服务器部署指南(预生产) # 企微智能IT支持服务台 — 远程服务器部署指南(预生产)
> **预生产环境**:本系统与 IT 数据查询平台部署在**不同主机**。正式环境将迁移到 K8s。 > **预生产环境**:本系统与 IT 数据查询平台部署在**不同主机**。正式环境将迁移到 K8s。
+1 -1
View File
@@ -1,6 +1,6 @@
# ExternalSystemAdapter 抽象层设计文档 # ExternalSystemAdapter 抽象层设计文档
> 版本:V1.0 | 日期:2026-06-11 | 作者:IT智能服务台项目组 > 版本:V1.0 | 日期:2026-06-11 | 作者:智能IT支持服务台项目组
--- ---
+391
View File
@@ -0,0 +1,391 @@
# Gitea 部署指南
**适用范围**: 企微 IT 智能服务台项目
**维护人**: 宋献 + Claude 协作
**最后更新**: 2026-06-14(卸载清空事件后)
**关联**: [[风险跟踪表]] 第十二节 / [[CONTRIBUTING]] / [[scripts/backup-gitea.sh]]
---
## 📌 1. 部署环境
### 1.1 推荐方案:Synology 套件版
| 项 | 值 | 备注 |
|---|---|---|
| NAS | Synology DS923+ | DSM 7.2+ |
| 套件 | Gitea 1.22+ | 套件中心搜 "Gitea" |
| 端口 | 8418 (HTTP) | 避开 3000(被占) |
| 数据库 | SQLite3 | 单机够用,避免 MySQL 配置坑 |
| Tailscale | tail58d872.ts.net | Funnel 暴露给 workbuddy 沙箱 |
| 备份 | `scripts/backup-gitea.sh` | 每天 cron 3 点 |
### 1.2 备选方案:Docker 容器版
```bash
sudo mkdir -p /volume1/docker/gitea/{data,config,repos}
sudo chown -R 1000:1000 /volume1/docker/gitea
docker run -d \
--name gitea \
--restart always \
-p 8418:3000 \
-p 2222:22 \
-v /volume1/docker/gitea/data:/data \
-v /volume1/docker/gitea/config:/etc/gitea \
-v /volume1/docker/gitea/repos:/data/git/repositories \
gitea/gitea:1.22
```
**优势**:升级/迁移灵活
**劣势**:需要 Container Manager 知识,SSL/反向代理需手配
---
## 📌 2. 初始化(首次安装)
### 2.1 创管理员
1. 浏览器 `http://<NAS_IP>:8418/`
2. 看到 **"首次安装,请注册管理员账号"**
3. 填:
- **管理员用户名**: `simon`(项目负责人)
- **邮箱**: 你常用邮箱
- **密码**: 强密码(≥16 位,大小写+数字+特殊)
4. **立即登录**
### 2.2 创仓
1. 右上角 `+` → **创建新的仓库**
2. 填:
- **所有者**: `simon`
- **仓库名**: `wecom_it_smart_desk`
- **可见性**: 私有(Private)
- **⚠️ 不勾** "使用 README 初始化"
- **⚠️ 不勾** "使用 .gitignore"
- **⚠️ 不勾** "使用 License"
3. **创建仓库**
### 2.3 创 Access Token(workbuddy-claude 协作)
1. 创 **workbuddy-claude** 普通用户(站点管理 → 用户)
2. 用 workbuddy-claude 登录
3. 头像 → **设置****应用** → **管理 Access Token**
4. 创:
- 令牌名: `claude-push`
- 权限: ✅ `repository``issue``user`
---
## 📌 3. 本地仓接入
### 3.1 配 remote(走 wincred,不嵌 token)
```bash
cd D:\资料\03-项目开发\wecom_it_smart_desk
git remote add origin https://simon@ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk.git
```
### 3.2 首次推 main
```bash
# 在 PowerShell 跑(会弹窗输 token)
git push -u origin main
```
**弹窗时**:
- **Username**: 留空
- **Password**: 粘 access token
**wincred 自动缓存**,后续 push 不弹窗。
**换 token**:
```bash
# 清旧缓存
printf "protocol=https\nhost=ds923plus.tail58d872.ts.net\nusername=simon\n" | git credential reject
# 存新缓存(在 bash 跑,token 在 heredoc)
printf "protocol=https\nhost=ds923plus.tail58d872.ts.net\nusername=simon\npassword=NEW_TOKEN\n" | git credential approve
```
---
## 📌 4. Tailscale Funnel 暴露(给 workbuddy 沙箱)
### 4.1 配 Funnel
```bash
ssh simon@100.85.152.112
sudo tailscale funnel --bg 8418
```
### 4.2 验证
- 浏览器(任何网络)打开 `https://ds923plus.tail58d872.ts.net/`
- 应看到 Gitea 登录页
### 4.3 故障排查
| 现象 | 原因 | 解决 |
|---|---|---|
| Funnel 域名打不开 | tailscaled 停 | `sudo systemctl restart tailscaled` |
| 8418 connection refused | Gitea 套件停 | 套件中心 → Gitea → 启动 |
| TLS 证书报错 | Funnel HTTPS 证书未自动签 | 等 30 秒自动签,或 `sudo tailscale funnel reset` |
---
## 📌 5. 分支保护(main 必须保护)
### 5.1 用 simon admin token 跑 API
```bash
TOKEN="simon的admin token"
# 删旧保护
curl -X DELETE \
-H "Authorization: token $TOKEN" \
"http://100.85.152.112:8418/api/v1/repos/simon/wecom_it_smart_desk/branch_protections/main"
# 重建保护
curl -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"branch_name": "main",
"enable_push": false,
"enable_pull_request": true,
"required_approvals": 1,
"dismiss_stale_approvals": true,
"block_admin_merge": false
}' \
"http://100.85.152.112:8418/api/v1/repos/simon/wecom_it_smart_desk/branch_protections"
```
### 5.2 关键参数解释
| 参数 | 值 | 说明 |
|---|---|---|
| `enable_push` | `false` | 禁止直推 main |
| `enable_pull_request` | `true` | 需走 PR |
| `required_approvals` | `1` | 需 1 个 reviewer |
| `block_admin_merge` | `false` | simon 可强推(临时,等 workbuddy 接入改 true) |
### 5.3 升级路径(workbuddy 接入后改严)
- 创 `workbuddy-claude` 普通 user ✅
- 创 workbuddy-claude token ✅
- `block_admin_merge``true`(评审员有 ≥2 个 user)
- 加 `enable_status_check: true` + 配 Gitea Actions CI
---
## 📌 6. 备份策略(必做!)
### 6.1 首次部署备份脚本
```bash
# 本地仓 → NAS
scp scripts/backup-gitea.sh simon@100.85.152.112:/volume1/docker/wecom-it-desk/scripts/
# NAS 跑首次备份
ssh simon@100.85.152.112
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh
```
### 6.2 配 cron(每天 3 点)
```bash
# 编辑 crontab
sudo crontab -e
# 加一行
0 3 * * * /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh >> /var/log/gitea-backup.log 2>&1
```
### 6.3 验证 cron
```bash
# 列出当前 cron
sudo crontab -l
# 看下次执行时间
systemctl list-timers | grep cron
```
### 6.4 备份内容
- ✅ 配置文件 `app.ini`
- ✅ SQLite 数据库(热备,`sqlite3 .backup`)
- ✅ 仓裸仓库 `repos/`(tar.gz 压缩)
- ✅ LFS 数据
- ✅ 头像 / 附件
- ✅ 元信息 `backup.meta`
保留 **7 天**(`--keep 30` 改 30 天)
### 6.5 异地备份(强烈建议)
3 个方案:
| 方案 | 成本 | 复杂度 | 推荐度 |
|---|---|---|---|
| A. 本机 D 盘 | 0 | 低 | ⭐⭐ |
| B. 阿里云 OSS / 腾讯云 COS | ~5 元/月 | 中 | ⭐⭐⭐ |
| C. NAS2 + OSS 双备 | ~10 元/月 | 高 | ⭐⭐⭐⭐ |
**推荐 B**:在 NAS 装 `rclone`,cron 推 OSS。
---
## 📌 7. 恢复流程
### 7.1 列出可用备份
```bash
ls -lh /volume1/backups/gitea/
# gitea-backup-20260614-180000.tar.gz
# gitea-backup-20260613-180000.tar.gz
# ...
```
### 7.2 恢复到 latest
```bash
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh --restore latest
```
⚠️ **会自动停 Gitea → 覆盖数据 → 启动 Gitea**
### 7.3 恢复到指定时间
```bash
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh --restore 20260614-180000
```
### 7.4 验证恢复
1. 浏览器 `http://<NAS_IP>:8418/` → 应看到 Gitea 登录页
2. 登录 simon → 应看到所有仓
3. 选仓 → 应看到 commit 历史
---
## 📌 8. 卸载注意事项(血泪教训!)
### 🛑 8.1 **卸载前必做**
1. ✅ 跑 `scripts/backup-gitea.sh` 取最新备份
2. ✅ scp 备份到本地 + OSS 异地
3. ✅ 记下当前 Gitea 版本(套件中心看)
4. ✅ 记下当前端口(默认 3000 / 容器 8418 / 套件 8418)
### 🛑 8.2 卸载"清空" vs "保留"
| 选项 | 清什么 | 不清什么 | 适用 |
|---|---|---|---|
| **仅卸载** | 套件 app | app.ini / DB / repos / LFS | 临时停用 |
| **卸载并清空** ⚠️ | 套件 app + app.ini + DB | ⚠️ **可能**保留 repos / LFS(套件机制) | 永久删除 |
### 🛑 8.3 卸载"清空"后仓还在硬盘
**2026-06-14 实战**:卸载清空后,`/volume1/@appdata/gitea/gitea/repos/` 仓裸仓库**还在**。
**恢复路径**:
1. 重装 Gitea 套件
2. 初始化(simon's admin)
3. **不要** Web 创仓(会报"已存在文件")
4. SSH 删残留仓目录:
```bash
sudo rm -rf /volume1/@appdata/gitea/gitea/repos/simon/wecom_it_smart_desk.git
sudo rm -rf /volume1/@appdata/gitea/gitea/lfs/simon/wecom_it_smart_desk.git
```
5. Web 创仓(空)
6. 本地推 main(本地仓有完整 11 commit)
### 🛑 8.4 卸载"清空"后仓真的没了
**走备份恢复**:
```bash
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh --restore latest
```
---
## 📌 9. 故障排查
### 9.1 套件启动失败
```bash
# 看套件日志
sudo cat /var/log/packages/Gitea.log
# 重启套件
sudo synopkg restart Gitea
```
### 9.2 端口 8418 被占
```bash
# 查谁占
sudo lsof -i :8418
# 改 Gitea 端口(套件不支持改,需 Docker 版)
```
### 9.3 Funnel 不通
```bash
# 看 tailscale 状态
sudo tailscale status
# 看 Funnel 配置
sudo tailscale funnel status
# 重置 Funnel
sudo tailscale funnel reset
sudo tailscale funnel --bg 8418
```
### 9.4 推 Gitea 401/403
```bash
# 清旧 wincred 缓存
printf "protocol=https\nhost=ds923plus.tail58d872.ts.net\nusername=USER\n" | git credential reject
# 重试 push,弹窗输新 token
git push -u origin main
```
### 9.5 推 Gitea 404
- 仓不存在 → 创仓
- remote URL 错 → `git remote -v` 检查
- workbuddy user 没访问权限 → 站点管理 → 用户 → 改权限
---
## 📌 10. 关联资源
- **风险跟踪表**: `docs/风险跟踪表.md` 第十二节(Gitea 重建复盘)
- **贡献指南**: `CONTRIBUTING.md`(commit 规范 + PR 流程)
- **备份脚本**: `scripts/backup-gitea.sh`
- **预检脚本**: `scripts/pre-commit-check.sh`
- **workbuddy 任务清单**: `.workbuddy/memory/2026-06-14-批量任务.md`
- **workbuddy 满载任务**: `.workbuddy/memory/2026-06-14-今夜-满载任务.md`
- **Tailscale 私网**: `tail58d872.ts.net`
- **Funnel 域名**: `https://ds923plus.tail58d872.ts.net`
---
## 📌 11. 紧急联系
| 场景 | 联系人 |
|---|---|
| Gitea 套件问题 | 群晖技术支持(synology.com/support) |
| Tailscale Funnel | Tailscale 文档(tailscale.com/kb) |
| Token / 推送问题 | 项目负责人 宋献 |
| 仓数据丢失 | 走备份恢复(第 7 节) |
---
*本指南是 2026-06-14 卸载清空事件的产物,目的是不再让类似事件发生*
@@ -14,7 +14,7 @@
### 1. 符合系统定位——"AI驱动" ### 1. 符合系统定位——"AI驱动"
系统全名是"IT智能服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。 系统全名是"智能IT支持服务台 — AI驱动",但当前右侧栏本质是传统信息架构(标签页+列表),AI只在左侧会话区参与。动态推送让右侧也变成AI能力的延伸,整个产品才能名副其实。
### 2. 降低用户认知负荷 ### 2. 降低用户认知负荷
@@ -1,4 +1,4 @@
# IT智能服务台 - 部署修复记录 # 智能IT支持服务台 - 部署修复记录
**日期**2026-06-13 **日期**2026-06-13
**负责人**:宋献 **负责人**:宋献
+1 -1
View File
@@ -252,7 +252,7 @@ docker compose -f docker-compose.nas.yml up -d --build
1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame) 1. 登录 [企微管理后台](https://work.weixin.qq.com/wework_admin/frame)
2. **应用管理****自建** → **创建应用** 2. **应用管理****自建** → **创建应用**
3. 填写: 3. 填写:
- 应用名称:`IT智能服务台` - 应用名称:`智能IT支持服务台`
- 应用logo:上传一个图标 - 应用logo:上传一个图标
- 可见范围:选择测试部门/人员 - 可见范围:选择测试部门/人员
+2 -2
View File
@@ -1,4 +1,4 @@
# IT智能服务台 — 管理后台增量 PRD # 智能IT支持服务台 — 管理后台增量 PRD
> **文档版本**: v1.0 > **文档版本**: v1.0
> **创建日期**: 2026-06-16 > **创建日期**: 2026-06-16
@@ -28,7 +28,7 @@
| 字段 | 值 | | 字段 | 值 |
|------|------| |------|------|
| 产品名称 | IT智能服务台 — 管理后台 | | 产品名称 | 智能IT支持服务台 — 管理后台 |
| 项目代号 | `wecom_it_smart_desk`(第三端:admin | | 项目代号 | `wecom_it_smart_desk`(第三端:admin |
| 编程语言 | 前端: Vue 3 + TypeScript + Element Plus + Pinia · 后端: FastAPI + SQLAlchemy + PostgreSQL + Redis | | 编程语言 | 前端: Vue 3 + TypeScript + Element Plus + Pinia · 后端: FastAPI + SQLAlchemy + PostgreSQL + Redis |
| 部署路径 | `/itadmin/`(与 H5 `/itdesk/`、坐席 `/itagent/` 并列) | | 部署路径 | `/itadmin/`(与 H5 `/itdesk/`、坐席 `/itagent/` 并列) |
@@ -54,7 +54,7 @@
``` ```
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
IT智能服务台 [🔔 人工] │ ← 启用状态(橙色) │ 智能IT支持服务台 [🔔 人工] │ ← 启用状态(橙色)
│ [▓▓ 人工] │ ← 禁用状态(灰色) │ [▓▓ 人工] │ ← 禁用状态(灰色)
└────────────────────────────────────┘ └────────────────────────────────────┘
``` ```
+5 -5
View File
@@ -1,4 +1,4 @@
# 企微IT智能服务台 — 产品需求文档 (PRD) # 企微智能IT支持服务台 — 产品需求文档 (PRD)
> **文档版本**: v1.0 > **文档版本**: v1.0
> **创建日期**: 2025-07-11 > **创建日期**: 2025-07-11
@@ -1318,7 +1318,7 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "IT智能服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) | | **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "智能IT支持服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) |
| **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) | | **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) |
--- ---
@@ -1451,7 +1451,7 @@
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │ │ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤ ├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │ │ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │ │ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │
@@ -1765,7 +1765,7 @@ class TroubleshootingTemplate(Base):
| 系统 | 职责 | 部署位置 | 当前集成度 | | 系统 | 职责 | 部署位置 | 当前集成度 |
|------|------|---------|-----------| |------|------|---------|-----------|
| **IT智能服务台** | 员工端H5 + 坐席工作台 + 管理后台 | NAS Docker (Cloudflare Tunnel) | — | | **智能IT支持服务台** | 员工端H5 + 坐席工作台 + 管理后台 | NAS Docker (Cloudflare Tunnel) | — |
| **Dify** | AI对话引擎(Agent1 员工自助 + Agent2 坐席辅助) | 公司内网 | 100%dify2openai 集成) | | **Dify** | AI对话引擎(Agent1 员工自助 + Agent2 坐席辅助) | 公司内网 | 100%dify2openai 集成) |
| **RAGFlow** | 知识库检索(Dify 通过 RAGFlow 获取知识) | 公司内网 | 0%(Dify 间接调用) | | **RAGFlow** | 知识库检索(Dify 通过 RAGFlow 获取知识) | 公司内网 | 0%(Dify 间接调用) |
| **智能IT助手数据处理平台** | 会话数据分析、报表、运营指标 | 公司内网 | 0%(物理隔离) | | **智能IT助手数据处理平台** | 会话数据分析、报表、运营指标 | 公司内网 | 0%(物理隔离) |
@@ -1941,7 +1941,7 @@ class TroubleshootingTemplate(Base):
--- ---
> **文档结束** — 本PRD涵盖企微IT智能服务台全部已确认设计决策和约束,作为后续架构设计和开发实施的基准文档。v1.0 新增管理后台远景规划、系统生态与集成规划、阶段细化与并行推进策略。 > **文档结束** — 本PRD涵盖企微智能IT支持服务台全部已确认设计决策和约束,作为后续架构设计和开发实施的基准文档。v1.0 新增管理后台远景规划、系统生态与集成规划、阶段细化与并行推进策略。
--- ---
+155
View File
@@ -0,0 +1,155 @@
# Release Notes — v0.5.0-beta(内测版)
**发布日期**: 2026-06-15 下午
**目标**: 内测(2-3 个内部用户),生产仍用 v0.4.x
**类型**: 🟡 **beta** — 部分 P0 已修,部分 P0 仍缺
**负责人**: Simon
**对接 workbuddy brief**: `.workbuddy/memory/2026-06-15-合并任务部署说明.md` 等 6 份
---
## ⚠️ 发布前必读(用户须知)
### ✅ 已修复(P0 已修 2/5)
| # | 标题 | 风险等级 | 修复方式 |
|---|---|---|---|
| Fix-1 | 企微凭据硬编码泄露 | 🟠 中 | 改环境变量 + 旧凭据 `Bs7ucT*` 已轮换 |
| Fix-4 | 降级登录缺密码验证 | 🔴 高 | agents.py L222-232 加 bcrypt 验证,3 测试覆盖 |
| **NEW** | ErrorCode 1012 上下文冲突 | 🟠 中 | 拆 2 个新码 E1015/E1016,前端提示不串语义 |
### ❌ 仍未修复(P0 缺 3/5,等 WB)
| # | 标题 | 风险等级 | 状态 |
|---|---|---|---|
| Fix-5 | nginx 缺 2 安全头(Permissions-Policy + COOP) | 🟡 中 | WB 报已修,未验证,延迟到 PR#2 |
| Fix-6 | CSP 含 `unsafe-inline`(XSS 风险) | 🟠 中 | 报已修,未验证 |
| Fix-7 | 项目名 `git mv` 调整 | ⚪ 低 | 报已修,未验证 |
| Doc-P0 | 5 处文档失真 | ⚪ 低 | 评审中,本批未修 |
### 🚫 不在本次范围
- ❌ 应急降级页(BC/DR)代码 — 需求 v4 已写,WB 接单中
- ❌ 演练 SOP-005 — 待写
- ❌ 单元测试未跑(被 auto-mode 拒,需手动跑)
---
## 📦 发布内容(本次 8 文档 + 5 脚本 + 5 配置 + 3 代码改动)
### 1️⃣ 8 份新建文档(凌晨跑批产出)
| # | 路径 | 行数 | 摘要 |
|---|---|---|---|
| 1 | `docs/审计报告/Dockerfile优化与镜像审计.md` | #44 | Docker 镜像优化建议 |
| 2 | `docs/数据库ER图与环境变量清点.md` | #45 | 16 表 ER + 17 env |
| 3 | `docs/审计报告/依赖漏洞扫描与Lockfile审计.md` | #46 | 5 CVE 识别 |
| 4 | `docs/审计报告/健康检查+错误码+日志结构化.md` | #47 | 40+ 错误码 + JSON 日志 |
| 5 | `docs/审计报告/CORS-CSP-安全Header全套.md` | #48 | 8 安全头配置 |
| 6 | `docs/惊喜报告/🎁惊喜1-项目健康度仪表盘.md` | #49 | 仪表盘说明 |
| 7 | `docs/惊喜报告/🎁惊喜2-README徽章+CHANGELOG+模板.md` | #50 | 文档增强 |
| 8 | `docs/需求-发布预演页面.md`(v4 刚升) | 226 | 应急降级页需求 |
| 附 | `docs/dashboard.html` | - | 健康度仪表盘网页(8KB) |
### 2️⃣ 5 个脚本(凌晨跑批产出)
| # | 路径 | 用途 |
|---|---|---|
| 1 | `scripts/dashboard.py` | 生成健康度 HTML |
| 2 | `scripts/oneclick-deploy.sh` | 一键部署(灰度) |
| 3 | `scripts/pre-commit-check.sh` | 提交前自检 |
| 4 | `scripts/backup-gitea.sh` | Gitea 备份 |
| 5 | `scripts/security-audit.sh` | 安全审计 |
### 3️⃣ 5 份配置(凌晨跑批产出)
| # | 路径 | 用途 |
|---|---|---|
| 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 模板 |
附: `CHANGELOG.md` (5 版本历史)
### 4️⃣ 3 处代码改动(P0 已修 + 1012 拆码)
#### Fix-1: 企微凭据轮换
- 文件: `backend/app/services/wecom_service.py` + `.env`
- 改动: 硬编码 `Bs7ucT*` 改为 `${WECOM_CORP_SECRET}` 环境变量
- 旧凭据: 已在企微后台轮换,新值仅在 `.env`
#### Fix-4: 降级登录密码验证
- 文件: `backend/app/api/agents.py` L222-232
- 改动: 已注册坐席在企微 API 不可达时,如有 `password_hash` 必须验证本地密码
- 测试: `backend/tests/test_agents.py` 3 测试(已写,待跑)
#### 1012 拆码(NEW)
- 文件: `backend/app/utils/error_codes.py` + `backend/app/api/agents.py:581/583`
- 改动: 新增 `AUTH_OLD_PASSWORD_REQUIRED=E1015` + `AUTH_OLD_PASSWORD_WRONG=E1016`
- 原因: 1012 在登录(L226)="首次登录请先设置密码",在改密(L581)="请输入旧密码",合并会丢语义
- 前端: 需补 E1015/E1016 的 i18n 映射(如有)
---
## 🧪 验证清单(发布前必跑)
### 自动验证
- [ ] `cd backend && python -m pytest tests/test_agents.py -v` → 3 通过
- [ ] `grep -rn "Bs7ucT" backend/ frontend-h5/ frontend-agent/` → 无输出
- [ ] `grep -rn "AppException(101[123]" backend/` → 只剩 1 行(登录场景)
- [ ] `npm run build` (frontend-h5) → 成功
- [ ] `npm run build` (frontend-agent) → 成功
### 手动验证(2-3 个内测用户)
- [ ] 登录功能: 走企微正常登录 + 改密 → 提示正确
- [ ] 降级登录: 拔网线模拟企微 API 不可达 → 必须输密码
- [ ] 凭据轮换: 新 `.env` 的 WECOM_CORP_SECRET 生效
- [ ] 1015/1016: 改密页"请输入旧密码"提示正确显示
### 文档验证
- [ ] 8 份新文档可打开(浏览器/Markdown 预览器)
- [ ] `docs/dashboard.html` 用浏览器打开看效果
- [ ] `CHANGELOG.md` 5 版本历史完整
---
## 🚦 发布决策
| 角色 | 动作 |
|---|---|
| **Simon** | 合并 `feature/t-1-t4-merge` → main,tag `v0.5.0-beta` |
| **workbuddy** | 等 Fix-5/6/7 真正验证完,提 PR#2(本批无此 PR) |
| **内测用户** | 用 v0.5.0-beta 跑 1 周,收集问题 |
| **下次发布** | v0.6.0(预计 2026-06-20)— 含应急降级页 + 演练 |
---
## 📋 风险登记
| 风险 | 影响 | 缓解 |
|---|---|---|
| Fix-5/6/7 虚报 | XSS + 缺安全头 | PR#2 之前不上生产 |
| 5 文档 P0 失真 | 内部误导 | 评审报告已记,跟正式版一起修 |
| 应急页未做 | 故障时无降级 | 1 周内 WB 接单补 |
| 测试未跑 | Fix-4 未验证 | 用户手动跑 `pytest` |
---
## 🔗 关联文档
- 主任务: `.workbuddy/memory/2026-06-15-合并任务部署说明.md`
- 补 4 项: `.workbuddy/memory/2026-06-15-补-4项+测试.md`
- 命名+错误码: `.workbuddy/memory/2026-06-15-补充-命名+错误码.md`
- 1012 拆码: `.workbuddy/memory/2026-06-15-ErrorCode-1012拆码.md` ← **NEW**
- 应急降级页: `.workbuddy/memory/2026-06-15-发布预演页.md`
- 评审报告: `docs/评审报告/2026-06-14-workbuddy-消息评审.md`
- 凌晨跑批汇总: `~/.claude/memory/overnight-batch-2026-06-15.md`
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
+96
View File
@@ -0,0 +1,96 @@
# SOP-001: Gitea 部署标准作业流程
**适用**: 新机器 / NAS 迁移 / Gitea 重建
**耗时**: 30-45 分钟
**关联**: [[Gitea部署指南]] / [[ADR-001]]
---
## 1. 前置检查
```bash
# 1.1 NAS 可达
ping 100.85.152.112
# 1.2 SSH 通
ssh simon@100.85.152.112
# 1.3 Tailscale 状态
sudo tailscale status
# 1.4 端口 8418 未占
sudo lsof -i :8418
```
## 2. 装 Gitea 套件
1. DSM → 套件中心
2. 搜 `Gitea` → 安装
3. 装好跳 `http://100.85.152.112:8418/`
## 3. 初始化
1. 创管理员:
- 用户名: `simon`
- 邮箱: 你的
- 密码: 强密码(≥16 位)
2. 数据库: 选 **SQLite3**
3. 站点名: `企微 IT 智能服务台 Git`
4. 立即登录
## 4. 创仓 + token
1. 创仓 `wecom_it_smart_desk`(不勾 README 初始化)
2. 创 simon access token(`simon-admin`)
3. 创 workbuddy-claude user + token(`claude-push`)
## 5. 配 Tailscale Funnel
```bash
sudo tailscale funnel --bg 8418
# 验证
curl -I https://ds923plus.tail58d872.ts.net/
```
## 6. 配分支保护
见 [[ADR-001]] §5 + `scripts/branch-protection.sh`(待写)
## 7. 部署备份
```bash
# 推备份脚本
scp scripts/backup-gitea.sh simon@100.85.152.112:/volume1/docker/wecom-it-desk/scripts/
# 配 cron
ssh simon@100.85.152.112
sudo crontab -e
# 加: 0 3 * * * /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh
```
## 8. 本地仓接入
```bash
cd D:\资料\03-项目开发\wecom_it_smart_desk
git remote add origin https://simon@ds923plus.tail58d872.ts.net/simon/wecom_it_smart_desk.git
git push -u origin main # 弹窗输 token
```
## 9. 验证清单
- [ ] Gitea Web UI 正常
- [ ] Funnel 域名正常
- [ ] 创仓 + token 完成
- [ ] 分支保护已配
- [ ] 备份 cron 已配
- [ ] 本地 push 成功
- [ ] workbuddy-claude user 已创 + token 已配
## 10. 出错回滚
| 现象 | 解决 |
|---|---|
| 8418 端口冲突 | Docker 版用 3000 端口 |
| SQLite 写失败 | 检查 `/volume1/@appdata/gitea` 权限 |
| Funnel 域名不通 | `sudo tailscale funnel --bg 8418` 重试 |
| 推 Gitea 401 | 清 wincred,重输 token |
+97
View File
@@ -0,0 +1,97 @@
# SOP-002: Gitea 备份恢复标准作业流程
**适用**: 数据丢失应急 / 误操作回滚 / 异地迁移
**耗时**: 5-15 分钟
**关联**: [[Gitea部署指南]] §6/§7
---
## 1. 备份策略
| 项 | 值 | 备注 |
|---|---|---|
| 频率 | 每天 3 点 | cron |
| 保留 | 7 天 | 默认 |
| 路径 | `/volume1/backups/gitea/` | NAS 本地 |
| 异地 | OSS / COS 推 | M-1 风险,待解决 |
| 工具 | `scripts/backup-gitea.sh` | 已写 |
## 2. 手动备份(应急)
```bash
ssh simon@100.85.152.112
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh
```
输出:
```
[INFO] === Gitea 备份开始 ===
[OK] 备份配置 app.ini
[OK] SQLite 热备完成
[OK] 仓库 tar 完成
[INFO] === 备份完成 ===
[OK] 最终备份: gitea-backup-20260615-030000.tar.gz
```
## 3. 列出可用备份
```bash
ls -lh /volume1/backups/gitea/
# gitea-backup-20260614-180000.tar.gz 500M
# gitea-backup-20260613-180000.tar.gz 495M
# gitea-backup-20260612-180000.tar.gz 490M
```
## 4. 恢复到 latest
```bash
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh --restore latest
```
**会做**:
1. 停 Gitea 套件
2. 解压备份
3. 覆盖 app.ini / SQLite / repos
4. 启动 Gitea 套件
⚠️ 5 秒倒计时,Ctrl+C 取消
## 5. 恢复到指定时间
```bash
# 看时间戳
ls /volume1/backups/gitea/ | grep gitea-backup
# gitea-backup-20260614-180000.tar.gz
# 恢复
sudo bash /volume1/docker/wecom-it-desk/scripts/backup-gitea.sh --restore 20260614-180000
```
## 6. 验证恢复
1. `http://100.85.152.112:8418/` → 登录 simon
2. 选仓 → 看 commit 历史
3. 验证仓裸仓库大小(`du -sh /volume1/@appdata/gitea/gitea/repos/`)
4. 验证 LFS 数据
## 7. 异地推 OSS(待配)
```bash
# NAS 装 rclone
sudo apt install rclone # 或 synology 套件版
# 配 OSS
rclone config
# 选 aliyun OSS / 腾讯云 COS
# 加 cron
0 4 * * * rclone copy /volume1/backups/gitea/ remote:gitea-backup/ --include "gitea-backup-*.tar.gz"
```
## 8. 故障排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 备份文件大小 0 | SQLite .backup 失败 | 改用文件复制模式(脚本已支持) |
| 恢复后启动失败 | 数据不一致 | 试更早的备份 |
| LFS 数据丢 | 备份脚本漏 LFS | 升级脚本(已修) |
+134
View File
@@ -0,0 +1,134 @@
# SOP-003: 推送评审标准作业流程
**适用**: 任何 commit 推 Gitea / PR 评审 / workbuddy 推送
**耗时**: 5-15 分钟
**关联**: [[CONTRIBUTING]] / [[scripts/pre-commit-check.sh]] / [[风险跟踪表]] 第九/十/十一节
---
## 1. 推送前自检(4 件套)
```bash
cd D:\资料\03-项目开发\wecom_it_smart_desk
# 必跑
bash scripts/pre-commit-check.sh --branch
# 严格模式(任何 warn 失败)
bash scripts/pre-commit-check.sh --branch --strict
```
**通过标准**:
- ✅ PASS ≥ 检查项数
- ⚠️ WARN 看是否影响评审
- ❌ FAIL 必修
## 2. Commit 规范
格式: `<type>(<scope>): <subject>`
| type | 用途 |
|---|---|
| `feat` | 新功能 |
| `fix` | Bug 修复 |
| `refactor` | 重构(无新功能 / 无 Bug 修复) |
| `docs` | 文档 |
| `chore` | 构建/工具/依赖 |
| `security` | 安全 |
| `perf` | 性能 |
| `test` | 测试 |
**subject**: 中文,祈使句,≤50 字
**body**: 详细说明,每行 ≤72 字
**footer**: 关联 Issue / workbuddy 任务
## 3. 推送流程
### 3.1 workbuddy 推送
1. workbuddy 客户端启动 → 读 `config.json` + `memory/`
2. 接任务(W-1 / W-2 / ...)
3. 写代码 → 本地 commit
4. 推 `feature/xxx` 分支(不走 main,需 PR)
5. 通知 Claude 评审
### 3.2 simon 推送(自己改)
1. 本地改 + commit
2. 推 `feature/xxx` 分支
3. Gitea Web 开 PR
4. 自己 approve + merge(因 `block_admin_merge: false`)
## 4. 评审流程
### 4.1 Claude 评审(主)
1. 收到 workbuddy 推送通知
2. Read 文件 + diff
3. 检查 4 件套
4. 写评审报告 `docs/评审报告/workbuddy-{date}-{topic}.md`
5. 评级:
- 🟢 通过 → 通知合并
- 🟡 留 P1/P2 修 → 评审报告列遗留
- 🔴 拒绝 → 评审报告列阻断
### 4.2 simon 合并
1. 评审通过 → Gitea Web 合并 PR
2. 触发 Gitea Actions CI(待配)
3. CI 绿 → 删 feature 分支
## 5. 评审失败处理
| 评级 | 处理 |
|---|---|
| 🟢 通过 | 合并 + 部署 |
| 🟡 留 P1 | 合并 + 写遗留表 + workbuddy 下一轮修 |
| 🔴 拒绝 | workbuddy 修 → 重新评审 |
## 6. 评审报告格式
`docs/评审报告/workbuddy-{YYYY-MM-DD}-{topic}.md`:
```markdown
# 评审: {topic}
**推送日期**: {date}
**评审日期**: {date}
**评审人**: Claude
**关联 PR**: feature/xxx → main
**关联 commit**: N 个
## ⭐ 一句话结论
...
## 📊 评审结果
| # | 项 | 评级 | 备注 |
|---|---|---|---|
## ✅ 已正确完成
...
## 🟡 半成品(留 P2 优化)
...
## ❌ 错误
...
## 📁 变更清单(N commit)
...
## 🔄 下一轮任务清单
...
## 🔗 推 Gitea 状态
- 远端分支: feature/xxx (HEAD = xxx)
- 评审: ✅ 通过 / 🟡 通过 + 留 / 🔴 拒绝
```
## 7. 不允许
- ❌ 跳过评审直推 main
- ❌ 评审失败强行合并
- ❌ 评审未消化前叠加新功能
- ❌ 改评审报告原文(只加节)
+208
View File
@@ -0,0 +1,208 @@
# SOP-004: 应急响应标准作业流程
**适用**: P0 漏洞 / 数据丢失 / 服务中断 / 安全事件
**响应时间**: 5 分钟响应 + 30 分钟止血 + 24 小时根因
**关联**: [[风险跟踪表]] / [[CONTRIBUTING]] §紧急修复
---
## 1. 事件分级
| 等级 | 场景 | 响应时间 |
|---|---|---|
| 🔴 **P0 紧急** | P0 鉴权漏洞 + 数据泄露 + 服务全停 | 5 min |
| 🟠 **P1 高** | P1 功能故障 + 单服务降级 | 30 min |
| 🟡 **P2 中** | P2 性能 / UI 问题 | 4 h |
| 🟢 **P3 低** | 体验优化 | 1 周 |
## 2. P0 应急流程(5 min 响应)
### 2.1 立即止血
1. **服务降级**:
- 关闭外网访问:`sudo iptables -A INPUT -p tcp --dport 8418 -j DROP`
- 或:套件中心停 Gitea
- 或:Nginx `deny all;`
2. **停可疑服务**:
- 停后端:`docker compose stop backend`
- 停 WebSocket:`docker compose stop nginx`(整体停)
3. **保留现场**:
- 不删任何文件
- 复制 log 到 `/tmp/incident-{timestamp}/`
- 截图
### 2.2 通知
1. 微信 / 电话通知项目负责人 宋献
2. 邮件群发:`wecom-it-desk-incident@servyou-it.com`
3. 建应急群
### 2.3 临时回滚
```bash
# 1. 找上一个稳定版本
git tag -l # 看 release tag
git log --oneline -20 # 看 commit 历史
# 2. 回滚到上一个 commit
git revert HEAD # 生成新 commit 撤销
# 或
git reset --hard HEAD~1 # 强回滚(慎用)
# 3. 强推(临时,需 admin 权限)
git push -f origin main
```
## 3. 根因分析(24h 内)
### 3.1 收集证据
```bash
# 后端日志
docker logs backend --tail 1000 > /tmp/incident/backend.log
# nginx 错误日志
sudo cat /var/log/nginx/error.log > /tmp/incident/nginx-error.log
# Gitea 日志
sudo synopkg log Gitea > /tmp/incident/gitea.log
```
### 3.2 5 Why 分析
```markdown
# 5 Why 分析
**事件**: 坐席登录无鉴权
**Why 1**: agents.py login() 函数没用 Depends(get_current_*)
**Why 2**: workbuddy 加新端点时没跑 pre-commit-check
**Why 3**: pre-commit-check 不在 git commit hook 里
**Why 4**: 没用 pre-commit 框架(只是脚本)
**Why 5**: 流程规范没强制(评审可跳)
**根因**: 流程规范未自动化
**对策**: 加 pre-commit + Gitea Actions 强制
```
### 3.3 写事故报告
`docs/事故报告/incident-{date}-{topic}.md`:
```markdown
# 事故报告: {topic}
**日期**: {date}
**等级**: 🔴 P0
**响应人**: {name}
**持续**: X 分钟
## 1. 时序
| 时刻 | 事件 |
|---|---|
## 2. 影响范围
- 用户: X 人受影响
- 数据: 是否泄露
- 服务: 停 X 分钟
## 3. 5 Why 根因
...
## 4. 修复 commit
- {commit-hash}
- {commit-message}
## 5. 防止再发
- [ ] 加 pre-commit hook
- [ ] 加 Gitea Actions 强制
- [ ] 更新风险跟踪表
- [ ] 评审 SOP 更新
```
## 4. P1 应急流程(30 min 响应)
### 4.1 评估
- 是否影响生产用户?
- 是否有降级方案?
### 4.2 止血
- 单服务降级(关问题服务,其它继续)
- 临时禁用相关端点(nginx `location /api/v1/xxx { return 503; }`)
### 4.3 修复
- hotfix 分支(从 main 拉)
- PR + 评审 + 合并 + 部署
## 5. 数据丢失应急
### 5.1 Gitea 数据丢失
1. **别再操作** Gitea(避免覆盖)
2. 跑 `scripts/backup-gitea.sh --restore latest`
3. 验证:仓 commit 数 / token 列表
4. 不行:试更早备份
### 5.2 生产数据库丢失
1. 立即停所有服务(避免写入)
2. 看 PostgreSQL 数据目录:`/var/lib/postgresql/data`
3. 走 PITR(Point In Time Recovery)
4. 启用只读模式 + 通知用户
## 6. 安全事件
### 6.1 Token 泄露
1. **立即撤销** token:
```bash
curl -X DELETE -H "Authorization: token $ADMIN_TOKEN" \
"http://100.85.152.112:8418/api/v1/users/{username}/tokens"
```
2. 清 wincred 缓存
3. 创新 token + 配新凭据
4. 改所有引用旧 token 的脚本/配置
5. 评审日志:谁访问过 / 推过什么
### 6.2 入侵检测
1. 看 `auth.log` / `nginx-access.log` / `backend.log`
2. 找异常 IP / 时间 / 路径
3. 封 IP:`sudo iptables -A INPUT -s {ip} -j DROP`
4. 改所有密码 / 凭据
5. 走事件调查流程
## 7. 通讯模板
### 7.1 启动应急
```
【应急启动】{事件简述}
等级: 🔴 P0
影响: {用户/数据/服务}
已开始止血:{动作}
请相关人:{人名} 立即响应
群: {微信群名}
```
### 7.2 解决通知
```
【已解决】{事件简述}
持续: X 分钟
修复: {commit-hash}
根因: {5 Why 结论}
防止再发: {动作}
报告: docs/事故报告/{file}.md
```
## 8. 联系
| 角色 | 联系人 |
|---|---|
| 项目负责人 | 宋献(企业微信 / 手机) |
| 运维 | IT 支持组 |
| NAS / Gitea | 群晖技术支持 |
| Tailscale | tailscale.com/support |
+776
View File
@@ -0,0 +1,776 @@
# AI Wingman 设计文档
**版本**: 1.0
**生成日期**: 2026-06-15
**作者**: Claude(满载跑批产出)
**状态**: 设计阶段,待评审
**关联**: [[阶段2-3-任务]] §3 / [[阶段4-5-规划]] §4.1.2
---
## 📌 1. 概述
### 1.1 什么是 Wingman
**Wingman**(僚机) = 坐席工作台的 AI 辅助系统,在坐席处理会话时**实时**提供:
- 草稿回复(坐席打字 → AI 实时给草稿)
- 自动摘要(会话结束 → AI 200 字摘要)
- 知识推荐(对话中识别关键字 → 推 FAQ)
- 排查步骤(员工描述问题 → AI 给 step-by-step)
**目标**: 减少坐席重复劳动 50%,提升响应速度 30%。
### 1.2 与现有 AI 的区别
| 维度 | 现有 AI(企微机器人) | Wingman |
|---|---|---|
| 用户 | 员工 | 坐席 |
| 触发 | 员工提问 | 坐席打字 / 结束会话 / 关键字 |
| 输出 | 完整回复给员工 | 草稿 / 摘要 / 步骤 给坐席审 |
| 集成 | 企微 1 对 1 | 坐席工作台 右侧栏 |
| 作用 | 自助解决 | 辅助坐席 |
### 1.3 关键指标
| 指标 | 目标 |
|---|---|
| 草稿采纳率 | ≥ 50% |
| 摘要准确率 | ≥ 80% |
| 知识命中率 | ≥ 30% |
| 排查步骤有效率 | ≥ 60% |
| 响应延迟 P95 | ≤ 1.5 秒 |
---
## 📌 2. 架构
### 2.1 系统架构
```
┌─────────────────────────────────────────────────────────┐
│ 坐席浏览器 (frontend-agent) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 对话区 │ │ 右侧栏 │ │ 标注面板 │ │
│ │ (主) │ │ (Wingman) │ │ (阶段4) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
└─────────┼─────────────────┼─────────────────┼───────────┘
│ HTTP/WS │ WS(实时推送) │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ FastAPI 后端 │
│ ┌──────────────────────────────────────────────┐ │
│ │ WingmanService (ai_wingman.py) │ │
│ │ ├── draft_reply() 草稿回复 │ │
│ │ ├── summarize() 自动摘要 │ │
│ │ ├── recommend_knowledge() 知识推荐 │ │
│ │ └── troubleshoot() 排查步骤 │ │
│ └────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────▼─────────────────────────────┐ │
│ │ DifyClient (dify_client.py) │ │
│ │ - 流式 / 阻塞 / 异步 统一封装 │ │
│ └────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────▼─────────────────────────────┐ │
│ │ Redis 缓存 + 限流 + 配额 │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────┐
│ Dify 平台 (企微 AI 机器人已在用) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 工作流 A │ │ 工作流 B │ │ 工作流 C │ │
│ │ 草稿回复 │ │ 摘要生成 │ │ 排查步骤 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 数据流
#### 2.2.1 草稿回复(实时)
```
[坐席打字]
→ debounce 300ms
→ 调 WingmanService.draft_reply(conv_id, last_employee_msg, context)
→ Dify 流式生成
→ 推 WS 推前端
→ 右侧栏显示 3 条草稿
→ 坐席点"采用" → 草稿填入输入框
```
#### 2.2.2 自动摘要(异步)
```
[坐席点"结单"]
→ 后端结单逻辑
→ 触发 WingmanService.summarize(conv_id)
→ 异步任务(BackgroundTasks)
→ 调 Dify 摘要工作流
→ 存 `conversations.summary`
→ 推 WS 通知"摘要已生成"
→ 坐席可编辑确认
```
#### 2.2.3 知识推荐(被动)
```
[员工发消息]
→ 消息路由服务(message_router)
→ 检测关键字(VPN / 密码 / Outlook 等)
→ 命中 → 调 WingmanService.recommend_knowledge(keyword)
→ 推 WS 推 5 条 FAQ
→ 坐席右侧栏显示
```
#### 2.2.4 排查步骤(主动)
```
[坐席点"AI 排查"]
→ 弹窗选问题分类
→ 输入员工描述
→ 调 WingmanService.troubleshoot(category, description)
→ 调 Dify 排查工作流
→ 推 5-7 步 step-by-step
→ 坐席可发给员工(企微应用消息)
```
### 2.3 部署架构
- **Dify**: 已有(企微 AI 机器人用)
- **Wingman 工作流**: 新建(3 个工作流)
- **Redis 缓存**: 已有,加 `wingman:draft:{conv_id}:{msg_hash}` key
- **WS**: 已有 `ws_manager`,扩展推送 `wingman_event` 类型
---
## 📌 3. 后端实现
### 3.1 数据模型
```python
# backend/app/models/wingman.py
from sqlalchemy import Column, String, Integer, DateTime, JSON, ForeignKey
from .base import Base
class WingmanDraft(Base):
"""草稿回复(短期存储,坐席可重生成)"""
__tablename__ = "wingman_drafts"
id = Column(Integer, primary_key=True)
conv_id = Column(String, ForeignKey("conversations.id"))
agent_id = Column(String, ForeignKey("agents.id"))
# 触发消息(员工最后一条)
trigger_message_id = Column(String, ForeignKey("messages.id"))
# 草稿内容(3 条)
drafts = Column(JSON) # ["draft1", "draft2", "draft3"]
# 上下文
context_messages = Column(JSON) # 最近 10 条消息
# 状态
accepted_index = Column(Integer, nullable=True) # 坐席点了第几条(0/1/2)
created_at = Column(DateTime)
expires_at = Column(DateTime) # 5 分钟后过期
class WingmanSummary(Base):
"""会话摘要"""
__tablename__ = "wingman_summaries"
id = Column(Integer, primary_key=True)
conv_id = Column(String, ForeignKey("conversations.id"), unique=True)
summary = Column(String(2000)) # AI 生成
edited_summary = Column(String(2000)) # 坐席改后
final_summary = Column(String(2000)) # 最终(edited or summary)
agent_id = Column(String, ForeignKey("agents.id"))
model = Column(String) # 用的模型
created_at = Column(DateTime)
updated_at = Column(DateTime)
class WingmanKnowledgeHit(Base):
"""知识库命中记录(供阶段 4 分析)"""
__tablename__ = "wingman_knowledge_hits"
id = Column(Integer, primary_key=True)
conv_id = Column(String)
message_id = Column(String)
knowledge_id = Column(Integer)
agent_id = Column(String)
helpful = Column(Integer, nullable=True) # 坐席反馈
created_at = Column(DateTime)
```
### 3.2 Alembic 迁移
```python
# 013_add_wingman.py
def upgrade():
op.create_table("wingman_drafts", ...)
op.create_table("wingman_summaries", ...)
op.create_table("wingman_knowledge_hits", ...)
op.create_index("idx_wingman_drafts_conv", "wingman_drafts", ["conv_id"])
op.create_index("idx_wingman_summaries_conv", "wingman_summaries", ["conv_id"])
op.create_index("idx_wingman_hits_conv", "wingman_knowledge_hits", ["conv_id"])
```
### 3.3 Dify 客户端
```python
# backend/app/services/dify_client.py
import httpx
from typing import AsyncIterator
from app.config import settings
class DifyClient:
def __init__(self):
self.base_url = settings.DIFY_BASE_URL # https://dify.servyou-it.com/v1
self.api_key = settings.DIFY_API_KEY
self.timeout = settings.DIFY_TIMEOUT # 默认 30s
async def chat_messages(
self,
workflow_id: str,
query: str,
user: str,
inputs: dict = None,
stream: bool = True,
) -> AsyncIterator[dict] | dict:
"""流式 / 阻塞 调 Dify 工作流"""
url = f"{self.base_url}/workflows/run"
headers = {"Authorization": f"Bearer {self.api_key}"}
body = {
"inputs": inputs or {},
"query": query,
"user": user,
"response_mode": "streaming" if stream else "blocking",
}
if stream:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with client.stream("POST", url, json=body, headers=headers) as resp:
async for chunk in resp.aiter_lines():
if chunk.startswith("data:"):
yield json.loads(chunk[5:].strip())
else:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=body, headers=headers)
return resp.json()
```
### 3.4 Wingman 服务
```python
# backend/app/services/wingman.py
import asyncio
import hashlib
import json
from datetime import datetime, timedelta
from typing import List, Optional
from app.services.dify_client import DifyClient
from app.services.ws_manager import ws_manager
from app.config import settings
class WingmanService:
def __init__(self):
self.dify = DifyClient()
self.redis = get_redis()
self.cache_ttl = 300 # 5 分钟
async def draft_reply(
self,
conv_id: str,
agent_id: str,
last_employee_msg: str,
context: List[dict],
) -> List[str]:
"""生成 3 条草稿回复"""
# 缓存 key(同输入同输出)
ctx_hash = hashlib.md5(json.dumps(context, sort_keys=True).encode()).hexdigest()[:8]
cache_key = f"wingman:draft:{conv_id}:{ctx_hash}"
cached = self.redis.get(cache_key)
if cached:
return json.loads(cached)
# 调 Dify 工作流
drafts = []
async for chunk in self.dify.chat_messages(
workflow_id=settings.DIFY_DRAFT_WORKFLOW_ID,
query=last_employee_msg,
user=agent_id,
inputs={
"context": context[-10:], # 最近 10 条
"tone": "professional_friendly",
"n_drafts": 3,
},
stream=False, # 草稿不需要流式
):
if chunk.get("event") == "workflow_finished":
drafts = chunk["data"]["outputs"].get("drafts", [])
break
if not drafts:
drafts = ["(AI 暂未生成草稿,请手动回复)"]
# 缓存
self.redis.setex(cache_key, self.cache_ttl, json.dumps(drafts))
# 推 WS 给坐席
await ws_manager.send_to_agent(agent_id, {
"type": "wingman_draft",
"conv_id": conv_id,
"drafts": drafts,
})
return drafts
async def summarize(self, conv_id: str, agent_id: str) -> str:
"""生成会话摘要(异步)"""
# 取会话所有消息
messages = await self._get_conv_messages(conv_id)
# 调 Dify 摘要工作流
summary = ""
async for chunk in self.dify.chat_messages(
workflow_id=settings.DIFY_SUMMARY_WORKFLOW_ID,
query=f"会话 ID: {conv_id}",
user=agent_id,
inputs={
"messages": messages,
"max_words": 200,
"focus": ["problem", "solution", "key_info"],
},
stream=False,
):
if chunk.get("event") == "workflow_finished":
summary = chunk["data"]["outputs"].get("summary", "")
break
# 存库
if summary:
await self._save_summary(conv_id, agent_id, summary)
# 推 WS
await ws_manager.send_to_agent(agent_id, {
"type": "wingman_summary",
"conv_id": conv_id,
"summary": summary,
})
return summary
async def recommend_knowledge(
self,
keyword: str,
conv_id: str,
agent_id: str,
top_k: int = 5,
) -> List[dict]:
"""知识推荐(基于关键字 + 向量检索)"""
# 向量检索(用 Dify 知识库 API)
results = []
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.DIFY_BASE_URL}/datasets/{settings.DIFY_DATASET_ID}/retrieve",
headers={"Authorization": f"Bearer {self.dify.api_key}"},
json={
"query": keyword,
"top_k": top_k,
"retrieval_model": {
"search_method": "hybrid", # 向量 + 关键字
},
},
)
results = resp.json().get("records", [])
# 记录命中
for r in results:
await self._record_knowledge_hit(conv_id, agent_id, r["id"])
# 推 WS
await ws_manager.send_to_agent(agent_id, {
"type": "wingman_knowledge",
"conv_id": conv_id,
"knowledge": results,
})
return results
async def troubleshoot(
self,
category: str,
description: str,
agent_id: str,
) -> List[dict]:
"""排查步骤生成"""
steps = []
async for chunk in self.dify.chat_messages(
workflow_id=settings.DIFY_TROUBLESHOOT_WORKFLOW_ID,
query=description,
user=agent_id,
inputs={
"category": category,
"max_steps": 7,
"format": "markdown",
},
stream=False,
):
if chunk.get("event") == "workflow_finished":
steps = chunk["data"]["outputs"].get("steps", [])
break
return steps
```
### 3.5 API 端点
```python
# backend/app/api/ai_wingman.py
from fastapi import APIRouter, Depends, BackgroundTasks
from app.services.wingman import wingman
from app.dependencies import get_current_agent
router = APIRouter(prefix="/api/v1/ai", tags=["AI Wingman"])
@router.post("/draft")
async def gen_draft(
body: DraftRequest,
agent: Agent = Depends(get_current_agent),
):
"""生成草稿"""
drafts = await wingman.draft_reply(
conv_id=body.conv_id,
agent_id=agent.id,
last_employee_msg=body.last_message,
context=body.context,
)
return {"drafts": drafts}
@router.post("/summary/{conv_id}")
async def gen_summary(
conv_id: str,
background: BackgroundTasks,
agent: Agent = Depends(get_current_agent),
):
"""生成摘要(异步)"""
background.add_task(wingman.summarize, conv_id, agent.id)
return {"status": "queued"}
@router.post("/knowledge")
async def recommend(
body: KnowledgeRequest,
agent: Agent = Depends(get_current_agent),
):
"""知识推荐"""
results = await wingman.recommend_knowledge(
keyword=body.keyword,
conv_id=body.conv_id,
agent_id=agent.id,
)
return {"knowledge": results}
@router.post("/troubleshoot")
async def troubleshoot(
body: TroubleshootRequest,
agent: Agent = Depends(get_current_agent),
):
"""排查步骤"""
steps = await wingman.troubleshoot(
category=body.category,
description=body.description,
agent_id=agent.id,
)
return {"steps": steps}
@router.post("/draft/{draft_id}/accept")
async def accept_draft(
draft_id: int,
index: int, # 0/1/2
agent: Agent = Depends(get_current_agent),
):
"""采纳草稿"""
await wingman.accept_draft(draft_id, index, agent.id)
return {"status": "accepted"}
```
---
## 📌 4. 前端设计
### 4.1 右侧栏布局
```
┌─────────────────────────────────────┐
│ 对话区 (主) │ Wingman 栏 │
│ ┌─────────────┐ │ ┌────────┐ │
│ │ 员工消息 │ │ │ 草稿 │ │
│ │ 坐席消息 │ │ │ 摘要 │ │
│ │ ... │ │ │ 知识 │ │
│ └─────────────┘ │ │ 步骤 │ │
│ [输入框 + 草稿采用] │ └────────┘ │
└─────────────────────────────────────┘
```
### 4.2 组件
#### 4.2.1 `WingmanPanel.vue` (主容器)
```vue
<template>
<div class="wingman-panel">
<el-tabs v-model="activeTab">
<el-tab-pane label="草稿" name="draft">
<DraftList :conv-id="convId" />
</el-tab-pane>
<el-tab-pane label="摘要" name="summary">
<SummaryView :conv-id="convId" />
</el-tab-pane>
<el-tab-pane label="知识" name="knowledge">
<KnowledgeList :conv-id="convId" />
</el-tab-pane>
<el-tab-pane label="步骤" name="troubleshoot">
<TroubleshootTool :conv-id="convId" />
</el-tab-pane>
</el-tabs>
</div>
</template>
```
#### 4.2.2 `DraftList.vue`
```vue
<template>
<div class="draft-list">
<div v-if="loading" class="loading">
<el-skeleton :rows="3" animated />
</div>
<div v-else>
<el-card v-for="(draft, idx) in drafts" :key="idx" class="draft-card">
<div class="draft-content">{{ draft }}</div>
<div class="draft-actions">
<el-button size="small" @click="accept(idx)">采用</el-button>
<el-button size="small" @click="regenerate(idx)">重生成</el-button>
<el-button size="small" type="text" @click="mark(idx, 'helpful')">👍</el-button>
<el-button size="small" type="text" @click="mark(idx, 'not_helpful')">👎</el-button>
</div>
</el-card>
<el-button v-if="!loading" @click="generate" type="primary" plain>重新生成</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useWingman } from '@/composables/useWingman'
import { useConversationStore } from '@/stores/conversation'
const props = defineProps<{ convId: string }>()
const drafts = ref<string[]>([])
const loading = ref(false)
const { genDraft, acceptDraft, markDraft } = useWingman()
const convStore = useConversationStore()
// 监听最后员工消息变化 → 自动生成草稿
watch(
() => convStore.lastEmployeeMessage,
async (msg) => {
if (!msg) return
loading.value = true
drafts.value = await genDraft(props.convId, msg, convStore.context)
loading.value = false
},
{ debounce: 300 } // 防抖 300ms
)
const accept = async (idx: number) => {
convStore.setDraftInput(drafts.value[idx])
await acceptDraft(props.convId, idx)
}
const regenerate = async (idx: number) => {
// 单条重生成
}
const mark = async (idx: number, type: 'helpful' | 'not_helpful') => {
await markDraft(props.convId, idx, type)
}
</script>
```
#### 4.2.3 `useWingman.ts` composable
```ts
import { ref } from 'vue'
import { aiApi } from '@/api/ai'
import { useAgentStore } from '@/stores/agent'
export function useWingman() {
const agentStore = useAgentStore()
const genDraft = async (
convId: string,
lastMessage: string,
context: any[],
): Promise<string[]> => {
const resp = await aiApi.draft({
conv_id: convId,
last_message: lastMessage,
context,
})
return resp.data.drafts
}
const acceptDraft = async (convId: string, index: number) => {
await aiApi.acceptDraft(convId, index)
}
const markDraft = async (convId: string, index: number, type: string) => {
await aiApi.markDraft(convId, index, type)
}
const genSummary = async (convId: string) => {
return aiApi.summary(convId)
}
const recommendKnowledge = async (convId: string, keyword: string) => {
return aiApi.knowledge({ conv_id: convId, keyword })
}
const troubleshoot = async (category: string, description: string) => {
return aiApi.troubleshoot({ category, description })
}
return {
genDraft,
acceptDraft,
markDraft,
genSummary,
recommendKnowledge,
troubleshoot,
}
}
```
---
## 📌 5. 性能与限流
### 5.1 限流
| 端点 | 限制 |
|---|---|
| `/ai/draft` | 20 次/分钟/坐席(打字频率) |
| `/ai/summary` | 5 次/分钟/坐席 |
| `/ai/knowledge` | 30 次/分钟/坐席 |
| `/ai/troubleshoot` | 10 次/分钟/坐席 |
**实现**: `backend/app/middleware/rate_limit.py` (slowapi)
### 5.2 缓存
| 项 | 策略 |
|---|---|
| 草稿 | 同 conv + 同 context hash → 缓存 5 分钟 |
| 知识 | 向量检索结果 → 缓存 10 分钟 |
| 摘要 | 不缓存(每次新生成) |
### 5.3 超时与降级
- Dify 超时(> 10s)→ 返回"AI 暂不可用,请手动回复"
- 配额耗尽(企业级 Dify 限额)→ 走备用 Dify 实例 或 降级到"无 AI"
- 错误重试 1 次,二次失败 fallback
---
## 📌 6. 验收标准
### 6.1 功能验收
- [ ] 坐席打字 → 右侧栏 1.5 秒内出现 3 条草稿
- [ ] 草稿采用率 ≥ 50%(统计)
- [ ] 会话结束 → 5 秒内生成 200 字摘要
- [ ] 关键字命中 → 5 条 FAQ 推右侧栏
- [ ] 排查步骤 → 5-7 步 markdown 格式
### 6.2 性能验收
- [ ] 草稿响应 P95 ≤ 1.5 秒
- [ ] 摘要响应 P95 ≤ 5 秒
- [ ] 知识推荐 P95 ≤ 2 秒
- [ ] 排查步骤 P95 ≤ 8 秒
### 6.3 集成验收
- [ ] Dify 工作流 3 个跑通
- [ ] 标注数据可查(阶段 4 用)
- [ ] WS 推送实时(≤ 1 秒延迟)
---
## 📌 7. 风险与缓解
| 风险 | 等级 | 缓解 |
|---|---|---|
| Dify API 限流 | 🟠 高 | 多实例 + 限流 + 缓存 |
| 草稿质量差(不专业) | 🟡 中 | Prompt 工程 + 反馈迭代 |
| 知识库召回率低 | 🟡 中 | 阶段 4 闭环优化 |
| 坐席不信任 AI | 🟡 中 | 培训 + 反馈机制 |
| 隐私泄露(敏感信息) | 🟠 高 | 输入脱敏 + 输出审查 |
---
## 📌 8. 实施路径
### 8.1 阶段 A:基础设施(2 周)
1. Dify 客户端 + 限流中间件
2. 4 个 API 端点(草稿/摘要/知识/排查)
3. 数据模型 + Alembic 013
4. 前端右侧栏骨架
### 8.2 阶段 B:Dify 工作流(2 周)
1. 草稿工作流(Prompt + 测试)
2. 摘要工作流
3. 排查工作流
4. 知识库导入现有 FAQ
### 8.3 阶段 C:联调(2 周)
1. 前后端联调
2. 性能调优
3. 错误降级
4. Beta 试用(内部 5 个坐席)
### 8.4 阶段 D:上线(1 周)
1. 全量上线
2. 监控指标
3. 收集反馈
4. 迭代优化
---
## 📌 9. 监控指标
| 指标 | 来源 | 看板 |
|---|---|---|
| 草稿生成数 / 采纳数 | DB `wingman_drafts` | 阶段 4 看板 |
| 摘要生成数 / 编辑数 | DB `wingman_summaries` | 阶段 4 看板 |
| 知识命中数 / 反馈 | DB `wingman_knowledge_hits` | 阶段 4 看板 |
| 草稿响应 P95 | 应用日志 | Prometheus |
| Dify 调用失败率 | 应用日志 | Prometheus |
| 坐席使用率 | DB 统计 | 阶段 4 看板 |
---
## 📌 10. 关联文档
- [[阶段2-3-任务]] §3: 阶段 3 任务拆解
- [[阶段4-5-规划]] §4.1.2: 知识库迭代
- [[外部系统集成]]: Dify 集成细节
- [[风险跟踪表]]: M-1 / M-7 / H-9 相关
---
*本设计是 2026-06-15 Claude 满载跑批产出,待评审*
+1 -1
View File
@@ -438,7 +438,7 @@ aTrust判断终端是否已存在的规则:
``` ```
┌─────────────────┐ ┌─────────────────┐
IT智能服务台 │ │ 智能IT支持服务台 │
│ employee_id │ │ employee_id │
└────────┬────────┘ └────────┬────────┘
+1 -1
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量架构设计 # 智能IT支持服务台 · 坐席工作台 v5.3 增量架构设计
> **版本**: v5.3-incremental > **版本**: v5.3-incremental
> **日期**: 2026-06-06 > **日期**: 2026-06-06
+3 -3
View File
@@ -1,4 +1,4 @@
# IT智能服务台 · 坐席工作台 v5.3 增量 PRD # 智能IT支持服务台 · 坐席工作台 v5.3 增量 PRD
> **版本**: v5.3 增量迭代 > **版本**: v5.3 增量迭代
> **日期**: 2026-06-06 > **日期**: 2026-06-06
@@ -181,7 +181,7 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "IT智能服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) | | **顶部栏** | 左侧:logo 方块 "IT"(渐变紫蓝 26×26px+ "智能IT支持服务台"(渐变文字)+ "· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理"(10px 灰色副标题,max-width 280px 溢出省略) |
| **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) | | **变更范围** | `TopBar.vue`(从 `Workspace.vue` 顶部栏独立) |
--- ---
@@ -314,7 +314,7 @@
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ [IT] IT智能服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │ │ [IT] 智能IT支持服务台 · 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理 │ ☀️/🌙 │ 坐席: 陈思远 │
├──────────┬──────────────────────────────────┬───────────────────────┤ ├──────────┬──────────────────────────────────┬───────────────────────┤
│ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │ │ │ 👤 张伟 · 研发一部 🥇黄金 │ 🤖 AI 智能推荐 │
│ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │ │ 🔍 搜索 │ 😟焦虑 ⏱8分32秒 💬6轮 🔁重复 │ ┌─────────────────┐ │

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