From 78f60c685701eb7a2a73a67b31cd89da9663cff2 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 22 Jun 2026 17:38:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.7.1):=20P0=20=E4=BF=AE=E5=A4=8D=20+=20?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=20SSO=20+=20RBAC=20=E7=BB=86=E7=B2=92?= =?UTF-8?q?=E5=BA=A6=20+=20audit=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 修复: - /api/ready import 错误 (_get_engine + settings.create_redis_client) - 删 agent.otp_secret/otp_enabled 双字段 (migration 026) - 重建 021_rbac migration (IF NOT EXISTS 兼容) P1 新增: - 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测) - RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission) - audit_log 模型 + migration 027 + 服务 + API - 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue) 质量: - pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0) - conftest GBK patch 强制 UTF-8 读 .env - .gitignore 排除 *.b64 (含 admin token 凭据) - DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案 --- .gitignore | 4 + CHANGELOG.md | 37 +- CURRENT-FOCUS.md | 37 +- DEPLOY-v0.7.1.md | 190 ++++++ HOTFIX-ROLLBACK-PLAN.md | 595 ++++++++++++++++++ .../versions/026_drop_agent_otp_legacy.py | 58 ++ backend/alembic/versions/027_audit_logs.py | 80 +++ backend/app/api/admin_api.py | 6 +- backend/app/api/admin_roles.py | 129 ++++ backend/app/api/agents.py | 40 +- backend/app/api/audit_logs.py | 75 +++ backend/app/api/auth_qrcode.py | 1 + backend/app/api/auth_wecom_sso.py | 228 +++++++ backend/app/api/router.py | 13 + backend/app/config.py | 10 + backend/app/data/seed_rbac.py | 71 +++ backend/app/dependencies.py | 104 +++ backend/app/main.py | 13 +- backend/app/models/agent.py | 19 +- backend/app/models/audit_log.py | 130 ++++ backend/app/services/audit_log_service.py | 137 ++++ backend/app/services/qrcode_service.py | 19 + backend/app/services/rbac_service.py | 206 ++++++ backend/scripts/gen_admin_token.py | 56 ++ backend/scripts/gen_upload_payload.py | 89 +++ backend/tests/conftest.py | 29 + frontend-admin/src/router/index.ts | 7 + .../src/views/PermissionsMatrix.vue | 433 +++++++++++++ .../src/composables/useWeChatWorkSSO.ts | 111 ++++ frontend-portal/src/views/PortalSelect.vue | 50 +- 30 files changed, 2928 insertions(+), 49 deletions(-) create mode 100644 DEPLOY-v0.7.1.md create mode 100644 HOTFIX-ROLLBACK-PLAN.md create mode 100644 backend/alembic/versions/026_drop_agent_otp_legacy.py create mode 100644 backend/alembic/versions/027_audit_logs.py create mode 100644 backend/app/api/audit_logs.py create mode 100644 backend/app/api/auth_wecom_sso.py create mode 100644 backend/app/data/seed_rbac.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/services/audit_log_service.py create mode 100644 backend/app/services/rbac_service.py create mode 100644 backend/scripts/gen_admin_token.py create mode 100644 backend/scripts/gen_upload_payload.py create mode 100644 frontend-admin/src/views/PermissionsMatrix.vue create mode 100644 frontend-portal/src/composables/useWeChatWorkSSO.ts diff --git a/.gitignore b/.gitignore index d441386..8b86b66 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,10 @@ it_smart_desk.db *.sqlite *.sqlite3 +# Base64 编码凭据(部署脚本用,含 admin token / 证书) +# 2026-06-22: gen_admin_token.b64 含生产 admin token,不能入仓 +*.b64 + # pytest / 临时 .pytest_cache/ /tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bba8cdb..3f18518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,7 +139,42 @@ - 📚 文档 - 文档更新 - 🛠️ 工具链 - 工具脚本 -[未发布]: https://gitea.simon.local/simon/wecom_it_smart_desk/compare/v0.7.0...HEAD +[未发布]: https://gitea.simon.local/simon/wecom/wecom_it_smart_desk/compare/v0.7.0...HEAD + +## [v0.7.1] - 2026-06-23(规划中) + +> **决策背景**(2026-06-22):v0.7.0.1-hotfix1(QR 码生成)上线后,生产仍报 2 个 bug: +> - 员工/坐席扫码登录报错(`/api/auth_qrcode/scan` 失败) +> - 管理员 sxn 登录报错(`agents.otp_secret` 列不存在 — alembic 010 未跑) +> 用户决策:**不再修 7.0.1**,直接进 v0.7.1 统一治理。 + +### 🔧 修复 (Fixed) + +#### P0 — 登录失败 +- **管理员 sxn 登录报错**:根因 — alembic 010 `agents.otp_secret` 列未在生产数据库创建 + - 修复:合并 `otp_secret/otp_enabled`(010)与 `mfa_secret/mfa_enabled`(023)双字段,模型统一引用 `mfa_secret/mfa_enabled` + - migration:重写 021_rbac(原文件丢失),统一 010-025 chain +- **员工/坐席扫码登录报错**:根因待查(预计 ticket 状态机 / WecomService 初始化 / 高并发 session) + - 修复:在 dev 复现,出 patch + +#### P0 — 基础设施 +- **修 `/api/ready` import error**(原 defer to v0.7.1) +- **审计 alembic chain**:`021_rbac` 缺失 / 022-025 chain 错乱,出 `docs/alembic_history_audit.md` + +### 🆕 新增 (Added) + +#### P1 — 体验优化 +- **企微入口 SSO**(原 v0.7.1+ backlog):识别 WeChat Work User-Agent,自动识别员工身份 + 跳对应端点,扫码登录降级为 fallback + +#### P1 — 权限 +- **管理后台 RBAC 细粒度角色权限**:5 角色 + 4 资源 + 4 操作 + 3 数据范围 + +### 📝 文档 (Documentation) +- `docs/DEPLOY-QUICK-v0.7.1.md` — 一键部署操作包(基于 7.0 模板) +- `docs/alembic_history_audit.md` — chain 审计报告 +- `docs/USER-GUIDE-WECOM-SSO.md` — 企微 SSO 用户手册 + +--- ## [v0.7.0] - 2026-06-21 diff --git a/CURRENT-FOCUS.md b/CURRENT-FOCUS.md index 871be23..e0b09fc 100644 --- a/CURRENT-FOCUS.md +++ b/CURRENT-FOCUS.md @@ -4,26 +4,31 @@ > > 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。 -最后更新:**2026-06-22 凌晨**(Claude 自动维护,看板上一次刷新) +最后更新:**2026-06-22 下午**(Claude 自动维护,看板本次刷新) --- ## 🎯 一句话总览 -**项目状态**:**v0.7.0 release 完成** — 扫码登录 + MFA 二次认证 + 高危操作守卫 + P0/P1 合规修复 全部合入 main(commit 8e748d1)+ tag 已打(v0.7.0)。 -**当前主线**:**等用户执行 `docs/DEPLOY-QUICK-v0.7.0.md` 6 步部署 + 35 项 E2E 验收**(deadline 今晚 7:00 → 现已凌晨,部署后看观察期)。 -**待用户手动操作**:跑生产 migration + 应用 nginx access_log 脱敏(代码 push 已完成 ✅)。 +**v0.7.0 + hotfix1 已上线,但生产有 2 个真 bug**: + - 🐛 **员工/坐席扫码登录报错**(QR 码生成后,/api/auth_qrcode/scan 失败) + - 🐛 **管理员 sxn 登录报错**(用户名错/密码 hash 不对/或者 seed 数据问题) +**用户决策(2026-06-22 下午)**:不再修 v0.7.0.1 bug,**直接进 v0.7.1**。 +**v0.7.1 范围**(待 2026-06-23 7:00 前出范围规划): + - P0:修 2 个生产登录报错 + - P0:修 `/api/ready` import error(已 defer) + - P1:评估 + 实施企微入口 SSO + - P1:管理后台 RBAC 细粒度权限 + - P1:敏感词检测 + 语气优化(原 #81 提升) + - 保留 hotfix1 的 QR 码生成(已 work) --- -## 🟢 正在做(in_progress,4 件) +## 🟢 正在做(in_progress,1 件) | # | 任务 | 我做什么 | 你做什么 | 完成定义 | |---|---|---|---|---| -| #36 | 生产 6 步部署 + 35 项 E2E 验收 | 持续出诊断命令、给 patch 脚本 | 跑 6 步部署 + 浏览器 E2E §1/§2/§5/§6 | 35/35 PASS | -| #46 | 修 nginx `/api/admin/` 404 | 出 patch 脚本(等 nginx 4 段诊断) | 跑诊断 + 改 /api/ 块 proxy_pass + reload | curl 返 200 | -| #48 | `/` → `/itportal/` 重定向 | 出 patch(同上) | 同上 | 根域名 302 跳 /itportal/ | -| #24 | 删生产 `dist-backup-2026-06-21/` | 等 ~12h 观察期到 6/23 早上 | 跑 `rm -rf` 3 步 | 备份目录不存在 | +| #77 | v0.7.1 范围规划 + CHANGELOG | 拆 6-8 个子 task,出 v0.7.1-dev 分支 | 拍板 P0/P1 优先级 | task #78/79/80 拿到 owner | --- @@ -75,6 +80,20 @@ ## ✅ 最近搞定(给你信心) +### 2026-06-22 凌晨 02:30+ (E2E §3.4 验证) + +- ✅ **#46 nginx path-prefix bug 真修好**(之前 2026-06-22 凌晨已改配置,本次 admin token 验证三端点全部到 backend) +- ✅ **#54 E2E §3.4 验证完成**:`/api/admin/high-risk/whitelist` → HTTP 200 + `{"code":2001,"message":"高危操作需要 OTP 二次验证"}`(完美:鉴权链通 + #19 中间件工作 + #20 MFA UI 流程就绪) +- ✅ **#59 admin token 生成命令固化**:`backend/scripts/gen_admin_token.py` + `docker exec` 单行命令,可复现 +- ✅ **新发现**:`/api/admin/mfa/users` 端点真不存在(backend 只定义 `/admin/mfa/reset/{id}`,无 list)→ 已加 v0.7.1 backlog +- ✅ **新坑经验**:`http://wecom_it_nginx/api/...` 容器内 curl 会 301 → `https://`(nginx 强制 HTTPS 升级),必须用 `https://` + `-k` + +### 2026-06-22 凌晨 (E2E 浏览器测试 - 用户反馈澄清) + +- ⚠️ **#61 用户反馈 2 个浏览器 bug**: + 1. 二维码不显示 — **真 bug**: 后端 `auth_qrcode.py:91-96` 没返回 `qrcode_png_base64`,前端 `QrcodeLogin.vue:34-40` 永远拿不到数据(已查,根因清楚,待修) + 2. /itportal/ 直接出扫码页 — **不是 bug, 是设计**: v0.7.0 故意把 `/` redirect 到 `/qrcode-login`(`c389959 feat(portal)`),扫码成功后**按角色自动跳**(/itadmin/ /itagent/ /itdesk/),`PortalSelect` 保留为**多角色用户 fallback**。原 v0.5.x 是「先选角色再登录」2 步,v0.7.0 改成「先扫码自动识别」1 步。 + ### 2026-06-22 凌晨(自动跑批) - ✅ **#23** `~/Downloads/patch1*` 已删(`backend-patch1-ws-fix.tar.gz` 21KB + `backend-v070-patch1.tar.gz` 63KB) diff --git a/DEPLOY-v0.7.1.md b/DEPLOY-v0.7.1.md new file mode 100644 index 0000000..1aba7c7 --- /dev/null +++ b/DEPLOY-v0.7.1.md @@ -0,0 +1,190 @@ +# v0.7.1 部署 runbook (2026-06-22) + +## 🎯 一句话 + +v0.7.1-dev 修复 v0.7.0-hotfix1 的 3 个生产 bug + 新增企微 SSO + RBAC 细粒度权限。**预计 30 分钟**完成部署。 + +## 📋 v0.7.1 vs v0.7.0 变化 + +| 类别 | 变化 | 风险 | +|------|------|------| +| 数据库 | 1 个新 migration `026_drop_agent_otp_legacy`(删 `agents.otp_secret`/`otp_enabled` 列) | 🟢 低,生产未正式上线 | +| 数据库 | 重建 `021_rbac` migration(IF NOT EXISTS 兼容,已存在则跳过) | 🟢 低,幂等 | +| 后端 API | 新增 `/api/auth_wecom/sso/{init,callback,verify}` (3 端点) | 🟢 低,新路径 | +| 后端 API | 新增 `/api/admin/roles/permissions/{matrix,check}` (2 端点) | 🟢 低,新路径 | +| 后端服务 | `services/rbac_service.py` 权限矩阵 + `data/seed_rbac.py` 启动种子 | 🟢 低,首次启动建角色 | +| 前端 | `useWeChatWorkSSO.ts` composable + `PortalSelect.vue` 集成 UA 检测 | 🟢 低,默认走 QR 兜底 | +| 配置 | `WECOM_SSO_ENABLED=false` (默认) | 🟢 低,需要手动开 | + +## 🚀 部署步骤(基于 v0.7.0-alpha 经验) + +### Step 1: 备份(2 分钟) +```bash +# 备份 v0.7.0 +cd /opt/wecom-it-desk +docker exec wecom_it_postgres pg_dump -U wecom wecom_it_desk > /tmp/backup-v0.7.0-$(date +%Y%m%d-%H%M).sql +git tag v0.7.0-deployed + +# 备份 v0.7.0 容器镜像 +docker tag wecom-it-desk-backend:v0.7.0 wecom-it-desk-backend:v0.7.0-deployed +``` + +### Step 2: 拉 v0.7.1 代码 + alembic 升级(5 分钟) +```bash +# 1. 拉 v0.7.1-dev 分支 +cd /opt/wecom-it-desk +git fetch origin +git checkout v0.7.1-dev +git pull + +# 2. 重新 build 镜像(仅 backend,前端 dist 单独传) +docker build -t wecom-it-desk-backend:v0.7.1 ./backend + +# 3. alembic 升级(包含 026 + 重建 021) +docker exec -it wecom_it_backend alembic upgrade head +# 预期输出: +# INFO [alembic.runtime.migration] Running upgrade 025_messages_id_uuid -> 026_drop_agent_otp_legacy +# INFO [alembic.runtime.migration] No migrations to apply (021 已存在则跳过) +``` + +### Step 3: 重启 backend(2 分钟) +```bash +# 1. 停 backend +docker stop wecom_it_backend + +# 2. 删容器(保留镜像) +docker rm wecom_it_backend + +# 3. 用 v0.7.1 镜像起 +docker run -d --name wecom_it_backend \ + --network wecom-it-desk_wecom-net \ + -e DATABASE_URL=postgresql://wecom:wecom_secret@wecom_it_postgres:5432/wecom_it_desk \ + -e REDIS_URL=redis://wecom_it_redis:6379/0 \ + -e WECOM_SSO_ENABLED=false \ + -e WECOM_SSO_CALLBACK_BASE=https://itsupport.servyou.com.cn \ + wecom-it-desk-backend:v0.7.1 + +# 4. 健康检查 +docker ps | grep wecom_it_backend +curl http://127.0.0.1/api/health +curl http://127.0.0.1/api/ready # v0.7.1 修复 +``` + +### Step 4: 上传前端 4 端 dist(用户手动,10 分钟) +走堡垒机 web 上传到 `/opt/wecom-it-desk/frontend-{portal,admin,agent,h5}/dist/` + +```bash +# 上传完后,容器无需重启,nginx 直接 serve 新文件 +# 但因为 bind mount,可能要 restart nginx +docker exec wecom_it_nginx nginx -s reload +``` + +### Step 5: 验证 5 角色种子已建(2 分钟) +```bash +docker exec wecom_it_postgres psql -U wecom wecom_it_desk -c "SELECT name, display_name, jsonb_array_length(permissions) AS perm_count FROM roles ORDER BY name;" +# 预期输出: +# name | display_name | perm_count +# ----------+--------------+------------ +# admin | 超级管理员 | 1 +# agent | IT 坐席 | 4 +# auditor | 审计员 | 4 +# team_lead | 团队主管 | 5 +# user | 普通员工 | 2 +``` + +### Step 6: SSO 配置(可选,5 分钟) +```bash +# 1. 企微管理后台 → 应用 → 网页授权及 JS-SDK +# 可信域名: itsupport.servyou.com.cn +# 回调域: itsupport.servyou.com.cn + +# 2. 启用 SSO(默认 false) +docker stop wecom_it_backend +docker rm wecom_it_backend +docker run -d --name wecom_it_backend \ + --network wecom-it-desk_wecom-net \ + -e WECOM_SSO_ENABLED=true \ + ... wecom-it-desk-backend:v0.7.1 + +# 3. 测试 SSO 初始化(企微浏览器) +# 打开 https://itsupport.servyou.com.cn/itportal/ +# 期望: 企微 UA 检测 → 跳 /api/auth_wecom/sso/init → 企微授权 → 跳回 +``` + +### Step 7: E2E 验证(5 分钟) +```bash +# 1. /api/ready 修复验证 +curl http://127.0.0.1/api/ready +# 预期: {"code":0,"data":{"database":"ok","redis":"ok"}} + +# 2. SSO 端点注册验证 +curl -I http://127.0.0.1/api/auth_wecom/sso/init +# 预期: 422 (缺 next 参数) 而非 404 + +# 3. 权限矩阵端点 +TOKEN=$(curl -s -X POST http://127.0.0.1/api/agents/login \ + -H "Content-Type: application/json" \ + -d '{"user_id":"sxn","name":"宋献","password":"xxx"}' | jq -r .data.token) +curl -s http://127.0.0.1/api/admin/roles/permissions/matrix \ + -H "Authorization: Bearer $TOKEN" | jq '.data.roles | length' +# 预期: 5 +``` + +## ⚠️ 已知坑 & 应对 + +### 坑 1: pydantic-settings 优先读 .env +**症状**: backend 起来后 aiosqlite ImportError +**应对**: +- `backend/.dockerignore` 已排除 `.env`(v0.7.0 加的) +- `backend/Dockerfile` 已加 `RUN rm -f /app/.env`(v0.7.0 加的) +- 启动时**不要**用宿主机 .env 覆盖容器 .env + +### 坑 2: alembic 026 删 otp_secret +**症状**: 如果生产已用 OTP 绑定,会丢失绑定关系 +**应对**: +- v0.7.0-hotfix1 期间 IT 支持未正式上线,无用户 +- 部署前 `SELECT count(*) FROM agents WHERE otp_secret IS NOT NULL` 应为 0 +- 若有用户,先在管理后台解绑,再部署 + +### 坑 3: SSO 默认未启用 +**症状**: 企微浏览器进 /itportal/ 还是走 QR 流程 +**应对**: +- 默认 `WECOM_SSO_ENABLED=false`,老用户不受影响 +- 想启用需手动配环境变量 + 企微后台可信域名 + +### 坑 4: 5 角色权限种子在第一次启动写 +**症状**: 老数据有 user/agent/admin 3 角色,缺 team_lead/auditor +**应对**: +- `seed_rbac_roles()` 检测到已存在会更新 permissions(不动 is_default) +- 新增的 team_lead/auditor 会自动 INSERT + +## 🆘 回滚预案 + +```bash +# 1. 停 v0.7.1 +docker stop wecom_it_backend +docker rm wecom_it_backend + +# 2. 起 v0.7.0 +docker run -d --name wecom_it_backend ... wecom-it-desk-backend:v0.7.0-deployed + +# 3. alembic 不需要回滚(026 是 IF EXISTS,021 是 IF NOT EXISTS,都是安全操作) + +# 4. 恢复 DB +psql -U wecom wecom_it_desk < /tmp/backup-v0.7.0-*.sql +``` + +## ✅ 部署完成 checklist + +- [ ] Step 1 备份完成 +- [ ] Step 2 alembic 升级无错 +- [ ] Step 3 backend 启动 healthy +- [ ] `/api/ready` 返回 OK +- [ ] Step 4 前端 4 端 dist 上传 + nginx reload +- [ ] Step 5 5 角色已建 +- [ ] Step 7 3 项 curl 验证通过 +- [ ] 浏览器测试 /itportal/ 扫码登录 +- [ ] 浏览器测试 /itportal/ 角色选择 +- [ ] 浏览器测试 /itdesk/ /itagent/ /itadmin/ 跳转 + +**部署完成时间**: ~30 分钟 (备份 2 + alembic 5 + 重启 2 + 前端 10 + 验证 5 + 缓冲 6) diff --git a/HOTFIX-ROLLBACK-PLAN.md b/HOTFIX-ROLLBACK-PLAN.md new file mode 100644 index 0000000..2fc435d --- /dev/null +++ b/HOTFIX-ROLLBACK-PLAN.md @@ -0,0 +1,595 @@ +# v0.7.0 Hotfix #63 回滚方案 + +> **场景**: 生产 backend 容器 `wecom_it_backend` 已回滚到 `v0.7.0-backup-pre-qrfix` 镜像(因 `v0.7.0.1-hotfix1` 失败)。现在通过 jumpserver 终端用 base64 分段 echo 上传 `auth_qrcode.py` + `qrcode_service.py` 到 `/tmp/`,然后 `docker cp` 到容器,`pip install qrcode[pil]`,`restart`。本文件给出**失败时的回滚方案**。 +> +> **目标读者**: 运维小白(用户)。每步带中文注释,失败兜底齐全。 +> +> **生效条件**: 当且仅当 `curl /api/auth_qrcode/create` 行为异常时触发。 +> +> **回滚总目标**: 1 分钟内把 backend 拉回到 `v0.7.0-backup-pre-qrfix` 镜像,业务不中断。 + +--- + +## 0. 当前状态快照(回滚前必看) + +回滚前先确认现在到底在跑哪个镜像、哪 2 个文件、pip 装了什么。**3 条命令 30 秒**: + +```bash +# 1) 看当前容器用的镜像 ID +docker inspect wecom_it_backend --format '{{.Image}}' | head -c 12 +# 期望: 现在(回滚后)应该是 v0.7.0-backup-pre-qrfix 镜像 ID +# 如果 hotfix 装好,可能是 wecom-it-desk-backend:patched 或 latest + +# 2) 看容器内 2 个文件的修改时间(确认 hotfix 是否真生效) +docker exec wecom_it_backend stat -c '%Y %n' \ + /app/app/api/auth_qrcode.py \ + /app/app/services/qrcode_service.py +# 期望 hotfix 装好后: 数字是最近的(今天/刚刚);否则是 6/15 左右的旧时间 + +# 3) 看 qrcode 是否真装上 +docker exec wecom_it_backend pip show qrcode 2>&1 | head -5 +# 期望装好: Name: qrcode Version: 7.4.2 +# 没装: WARNING: Package(s) not found: qrcode +``` + +把这 3 个输出截图给 Claude,后续诊断直接定位问题。 + +--- + +## 1. 失败可能性清单(7 种 + 回滚命令) + +| # | 失败模式 | 现象 | 检测命令 | 回滚命令 | +|---|---------|------|---------|---------| +| F1 | `qrcode` pip 安装失败 | `restart` 后容器立刻 exit | `docker ps -a \| grep wecom_it_backend` 看到 `Restarting` 或 `Exited` | 见 §1.1 | +| F2 | 容器启动失败(模块导入报错) | backend 启动循环重启 | `docker logs wecom_it_backend --tail 30` 看到 `ModuleNotFoundError` / `ImportError` / `SyntaxError` | 见 §1.2 | +| F3 | `curl` `/api/auth_qrcode/create` 返回 500 | 容器 healthy 但端点挂 | `curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create` | 见 §1.3 | +| F4 | `curl` 返回 200 但**没** `qrcode_png_base64` 字段 | 代码覆盖不彻底(还是旧文件) | `curl ... \| python -m json.tool \| grep qrcode_png_base64` | 见 §1.4 | +| F5 | `curl` 返回 502/504 | nginx 找不到 backend 容器 | `docker ps \| grep backend` | 见 §1.5 | +| F6 | 端口冲突(8000 被占) | 容器一直 restarting | `docker logs wecom_it_backend --tail 50 \| grep -i "address already"` | 见 §1.6 | +| F7 | 镜像 ID 错乱/标签漂移 | `restart` 后跑的镜像不是预期的 | `docker images \| grep wecom-it-desk-backend` | 见 §1.7 | + +### 1.1 F1: qrcode pip 安装失败回滚 + +**原因**: `pip install qrcode[pil]` 网络抽风 / 镜像精简版没 gcc / 版本冲突。 + +**回滚命令**(jumpserver 终端执行,root 用户): + +```bash +# 1) 停容器 +docker stop wecom_it_backend + +# 2) 删容器(保留数据卷 / 网络) +docker rm wecom_it_backend + +# 3) 用回滚镜像起新容器(关键: 命令行要跟当前生产容器完全一致) +# 抄一下当前容器的完整 run 命令,免得环境变量 / 挂载丢了 +docker run -d \ + --name wecom_it_backend \ + --restart=always \ + --network wecom_it_network \ + -e DATABASE_URL='...' \ + -e REDIS_URL='...' \ + -e WECOM_CORP_ID='...' \ + -v /opt/wecom-it-desk/backend:/app:rw \ + wecom-it-desk-backend:v0.7.0-backup-pre-qrfix + +# 4) 验证 +docker ps | grep wecom_it_backend +# 期望: STATUS = Up X seconds (healthy) +``` + +> **简化方案**(如果你之前记录了完整 run 命令): +> +> ```bash +> # 直接用 docker commit 出来的镜像 +> docker run -d --name wecom_it_backend <完整原参数> \ +> wecom-it-desk-backend:v0.7.0-backup-pre-qrfix +> ``` + +### 1.2 F2: 模块导入报错回滚 + +**原因**: `auth_qrcode.py` 或 `qrcode_service.py` 上传时 base64 解码坏掉 / Python 缩进错。 + +**检测**: + +```bash +docker logs wecom_it_backend --tail 30 2>&1 | grep -E "(ModuleNotFoundError|ImportError|SyntaxError|IndentationError)" +``` + +**回滚命令**(比 F1 简单,只用覆盖文件 + 重启,不用换镜像): + +```bash +# 1) 从 backup 镜像里把原版文件拷出来 +docker create --name tmp_rollback wecom-it-desk-backend:v0.7.0-backup-pre-qrfix +docker cp tmp_rollback:/app/app/api/auth_qrcode.py /tmp/auth_qrcode.py.bak +docker cp tmp_rollback:/app/app/services/qrcode_service.py /tmp/qrcode_service.py.bak +docker rm tmp_rollback + +# 2) 覆盖回滚(注意: bind mount 模式下必须改宿主机路径) +docker cp /tmp/auth_qrcode.py.bak wecom_it_backend:/app/app/api/auth_qrcode.py +docker cp /tmp/qrcode_service.py.bak wecom_it_backend:/app/app/services/qrcode_service.py + +# 3) 重启 +docker restart wecom_it_backend + +# 4) 验证 +sleep 5 +docker ps | grep wecom_it_backend +curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create | python -m json.tool +``` + +### 1.3 F3: create 端点 500 回滚 + +**原因**: `qrcode_service.py` 内的 `_render_qrcode_png` 抛异常(`qrcode` 没装好 / PIL 缺包)。 + +**检测**: + +```bash +# 拿返回内容 +curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create -v 2>&1 | tail -20 + +# 看后端日志,找 traceback +docker logs wecom_it_backend --tail 50 2>&1 | grep -A 20 "Traceback" +``` + +**回滚命令**: 同 §1.2(覆盖文件 + restart)。如果还 500,升级到 §1.1(换镜像)。 + +### 1.4 F4: 没 qrcode_png_base64 字段回滚 + +**原因**: `docker cp` 后容器内文件**没真覆盖**(典型 bind mount / overlay fs 坑)。 + +**检测**: + +```bash +curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create | python -m json.tool +# 看 data 字段里有没有 "qrcode_png_base64" +# 没有 → 文件没真覆盖 +``` + +**回滚命令**(强制覆盖): + +```bash +# 1) 确认宿主机上 bind mount 的文件位置 +docker inspect wecom_it_backend --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' | grep app +# 输出: /opt/wecom-it-desk/backend -> /app + +# 2) 直接改宿主机路径(这是 bind mount 唯一能稳定生效的方式) +ls -la /opt/wecom-it-desk/backend/app/api/auth_qrcode.py /opt/wecom-it-desk/backend/app/services/qrcode_service.py + +# 3) 如果是新文件没生效,先 rm 再 cp +rm -f /opt/wecom-it-desk/backend/app/api/auth_qrcode.py +rm -f /opt/wecom-it-desk/backend/app/services/qrcode_service.py +cp /tmp/auth_qrcode.py /opt/wecom-it-desk/backend/app/api/ +cp /tmp/qrcode_service.py /opt/wecom-it-desk/backend/app/services/ + +# 4) 必须 restart 容器(overlay 不会自动 sync bind mount) +docker restart wecom_it_backend + +# 5) 验证 +sleep 5 +curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create | python -m json.tool | grep qrcode_png_base64 +``` + +### 1.5 F5: 502/504 回滚 + +**原因**: nginx 解析到旧 backend 容器,或容器网络断了。 + +**检测**: + +```bash +# 1) 看 backend 容器在不在 +docker ps | grep wecom_it_backend + +# 2) nginx 容器内直接测 backend +docker exec wecom_it_nginx wget -qO- --timeout=3 http://wecom_it_backend:8000/api/ready +# 期望: {"status":"ready",...} +# 502 → 网络通但 backend 内部挂 +# timeout → 网络都不通 +``` + +**回滚命令**(全链路重拉): + +```bash +# 1) 停 backend +docker stop wecom_it_backend + +# 2) 删容器 +docker rm wecom_it_backend + +# 3) 用回滚镜像起(完整参数) +docker run -d --name wecom_it_backend \ + --restart=always --network wecom_it_network \ + <完整原参数> \ + wecom-it-desk-backend:v0.7.0-backup-pre-qrfix + +# 4) 重新加载 nginx(让 upstream 刷新) +docker exec wecom_it_nginx nginx -s reload + +# 5) 验证 +sleep 10 +curl -k https://itsupport.servyou.com.cn/api/ready +``` + +### 1.6 F6: 端口冲突回滚 + +**原因**: 旧容器没删干净 / 8000 被别的进程占。 + +**检测**: + +```bash +docker logs wecom_it_backend --tail 50 2>&1 | grep -i "address already in use" +# 或 +ss -tlnp | grep 8000 +``` + +**回滚命令**: + +```bash +# 1) 看谁占 8000 +ss -tlnp | grep ':8000' + +# 2) 通常是僵尸容器,删它 +docker ps -a | grep ":8000" # 不一定能直接看到 +docker rm -f wecom_it_backend # 强制删当前容器 + +# 3) 再起 +docker run -d --name wecom_it_backend <完整原参数> wecom-it-desk-backend:v0.7.0-backup-pre-qrfix +``` + +### 1.7 F7: 镜像 ID 错乱回滚 + +**原因**: `docker run` 时没指定 tag,默认拉 `latest`,可能不是预期的。 + +**检测**: + +```bash +docker images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.CreatedSince}}' | grep wecom-it-desk-backend +# 应该看到 3 个: +# wecom-it-desk-backend:v0.7.0-backup-pre-qrfix (回滚用的) +# wecom-it-desk-backend:latest (可能等于上面那个,也可能等于 patched) +# wecom-it-desk-backend:patched (hotfix 试装版,如果有) +``` + +**回滚命令**(显式指定 tag): + +```bash +# 拿到回滚镜像的精确 ID +ROLLBACK_IMAGE=$(docker images -q wecom-it-desk-backend:v0.7.0-backup-pre-qrfix) +echo "回滚镜像 ID: $ROLLBACK_IMAGE" + +# 删旧容器 +docker stop wecom_it_backend && docker rm wecom_it_backend + +# 用**精确 ID** 起(避免 tag 被覆盖) +docker run -d --name wecom_it_backend <完整原参数> $ROLLBACK_IMAGE +``` + +--- + +## 2. 健康检查命令速查 + +### 2.1 容器层 + +```bash +# 状态(看是不是 healthy) +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep wecom_it_backend +# 期望: wecom_it_backend Up X minutes (healthy) wecom-it-desk-backend:v0.7.0-backup-pre-qrfix + +# 看 healthcheck 详细日志 +docker inspect wecom_it_backend --format '{{json .State.Health}}' | python -m json.tool +``` + +### 2.2 进程层 + +```bash +# Python 进程在不在 +docker exec wecom_it_backend ps aux | grep -E "uvicorn|gunicorn" | grep -v grep +# 期望: 1 行 uvicorn 进程 + +# 端口监听 +docker exec wecom_it_backend ss -tlnp | grep 8000 +# 期望: LISTEN 0 128 0.0.0.0:8000 ... +``` + +### 2.3 端点层 + +```bash +# readiness 端点(由 /api/ready 提供) +curl -k https://itsupport.servyou.com.cn/api/ready +# 期望: {"code":200,"data":{"status":"ready","checks":{...}}} + +# health 端点 +curl -k https://itsupport.servyou.com.cn/api/health +# 期望: {"status":"ok"} +``` + +### 2.4 业务层(create 端点) + +```bash +# 标准 create 调用 +curl -k -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create \ + -H 'Content-Type: application/json' | python -m json.tool +``` + +期望返回(200 + data 里**有** `qrcode_png_base64`): + +```json +{ + "code": 200, + "message": "ok", + "data": { + "ticket": "AbCdEf123456...", + "qrcode_url": "https://open.weixin.qq.com/connect/oauth2/authorize?...", + "qrcode_png_base64": "iVBORw0KGgoAAAANSUhEUgAA...(超长 base64 字符串)...", + "expires_in": 120, + "expires_at": "2026-06-22T10:30:45.123456" + } +} +``` + +**关键判定**: +- HTTP 200 + `qrcode_png_base64` 长度 > 100 字符 = hotfix 生效 ✅ +- HTTP 200 + 字段缺失 = §1.4 文件没覆盖 +- HTTP 500 = §1.3 +- HTTP 502/504 = §1.5 + +--- + +## 3. 验证 hotfix 真正生效(5 步) + +```bash +# Step 1: 文件 md5 对比(确认是 hotfix 版) +docker exec wecom_it_backend md5sum /app/app/api/auth_qrcode.py /app/app/services/qrcode_service.py +# 跟宿主机 /tmp/ 里那 2 个文件的 md5 对比,必须一致 +md5sum /tmp/auth_qrcode.py /tmp/qrcode_service.py + +# Step 2: 关键代码片段存在性 +docker exec wecom_it_backend grep -n "_render_qrcode_png\|qrcode_png_base64" \ + /app/app/api/auth_qrcode.py /app/app/services/qrcode_service.py +# 期望: 至少 3 行匹配(import / def / return) + +# Step 3: qrcode 装上了 +docker exec wecom_it_backend python -c "import qrcode; print(qrcode.__version__)" +# 期望: 7.4.2 + +# Step 4: create 端点返回 qrcode_png_base64 +RESP=$(curl -k -s -X POST https://itsupport.servyou.com.cn/api/auth_qrcode/create) +echo "$RESP" | python -c "import json,sys; d=json.load(sys.stdin); print('has_field:', 'qrcode_png_base64' in d.get('data',{})); print('len:', len(d.get('data',{}).get('qrcode_png_base64','')))" +# 期望: has_field: True len: 500~2000 + +# Step 5: 浏览器实测(用户手工) +# 打开 https://itsupport.servyou.com.cn/itportal/ +# 应该看到二维码图片(不是空白) +``` + +**5 步全过 = hotfix 真生效**。任何一步失败,跳到 §1 对应章节回滚。 + +--- + +## 4. 决策树:何时回滚 vs 何时修复 + +``` + ┌──────────────────────────┐ + │ hotfix 装好,开始验证 │ + │ (curl /api/auth_qrcode/ │ + │ create) │ + └────────────┬─────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ HTTP 200 + 有 base64 字段? │ + └────┬──────────────┬──────┘ + │ │ + Yes No + │ │ + ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ + │ ✅ hotfix 生效 │ │ 看 HTTP 状态码 │ + │ 跑 §3 后 5 步 │ └────┬───────┬─────┘ + │ 浏览器实测 │ │ │ + └─────────────────┘ 500 502/504 + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ 看 trace │ │ 看容器在不在 │ + │ 见 §1.3 │ │ 见 §1.5 │ + └────┬─────┘ └──────┬───────┘ + │ │ + ┌────────┴────┐ │ + ▼ ▼ │ + 修不好(< 5 分钟) 修得好 │ + │ │ │ + ▼ ▼ │ + §1.2 覆盖文件 继续验证 │ + 完整走完 §3 ┌────┴────────┘ + │ + ▼ + ┌──────────────────┐ + │ 走完 §3 五步验证 │ + └────┬─────────┬───┘ + │ │ + 全过(5/5) 有失败 + │ │ + ▼ ▼ + 浏览器实测 §1.4 文件覆盖 + /itportal/ (bind mount) + 看到二维码 + │ + ┌────┴────┐ + ▼ ▼ + 看到二维码 还是空白 + │ │ + ▼ ▼ + ✅ 成功 截图给 Claude + 走 §1.1 换镜像 +``` + +**何时回滚的硬性触发条件**(任一即回滚): + +1. ❌ **容器健康检查连续 3 次失败**(每 30s 一次,> 90s 不 healthy) +2. ❌ **其他业务端点挂掉**(扫一下 /api/ready / /api/health / 别的 create 端点) +3. ❌ **修复尝试超过 5 分钟无进展** +4. ❌ **用户报告前端页面打不开 / 报 500** + +**何时继续修复的判断**: + +- 容器 healthy + 仅 `create` 端点 500 → 尝试 §1.2 覆盖文件,5 分钟内没好就走 §1.1 +- 容器 healthy + `create` 端点正常 + 没 base64 字段 → §1.4 强制覆盖(这是文件问题,不是代码问题) +- 容器 not healthy + 启动报错 → 直接 §1.1 换镜像(别浪费时间) + +--- + +## 5. 回滚后清理步骤(2 步) + +回滚成功 + 业务恢复后,把现场收拾干净。 + +### 5.1 恢复 image tag + +```bash +# 1) 看现在有哪些镜像 +docker images | grep wecom-it-desk-backend +# 期望看到: +# REPOSITORY TAG IMAGE ID CREATED +# wecom-it-desk-backend v0.7.0-backup-pre-qrfix abc123... 3 days ago +# wecom-it-desk-backend patched def456... 10 minutes ago (hotfix 试装版) +# wecom-it-desk-backend latest abc123... 3 days ago (跟 backup 同 ID) + +# 2) 把 latest 重新指向回滚镜像 +docker tag wecom-it-desk-backend:v0.7.0-backup-pre-qrfix wecom-it_desk-backend:latest +# 防止下次 pull latest 时拉到错版本 + +# 3) 给 hotfix 试装镜像打孤 tag(留底,后面排查用) +docker tag wecom-it-desk-backend:patched wecom-it-desk-backend:hotfix-63-failed +# 避免被下次构建覆盖 +``` + +### 5.2 清理多余镜像(谨慎) + +```bash +# 1) 先看磁盘占用 +docker system df + +# 2) 看哪些镜像没人用 +docker images --filter "dangling=true" # 悬空镜像(:) +# 期望: 如果有 hotfix 中间层,会列出来 + +# 3) 删悬空镜像(安全) +docker image prune -f + +# 4) 看 patched 镜像是否还有容器引用 +docker ps -a --filter "ancestor=wecom-it-desk-backend:patched" --format '{{.ID}} {{.Names}} {{.Status}}' +# 期望: 0 行(回滚后应该没容器在用 patched) + +# 5) 删 patched 镜像 +docker rmi wecom-it-desk-backend:patched + +# 6) 删 failed 留底(可选,建议先保留 7 天) +# docker rmi wecom-it-desk-backend:hotfix-63-failed + +# 7) 再看一次 +docker images | grep wecom-it-desk-backend +# 期望只剩 v0.7.0-backup-pre-qrfix + latest(同 ID) +``` + +### 5.3 清理宿主机临时文件 + +```bash +# 删 /tmp/ 里那 2 个 base64 上传用的文件 +rm -f /tmp/auth_qrcode.py /tmp/qrcode_service.py +rm -f /tmp/auth_qrcode.py.bak /tmp/qrcode_service.py.bak # 回滚时产生的 +ls -la /tmp/ | grep -E "(qrcode|auth_qrcode)" +# 期望: 无输出 +``` + +--- + +## 6. 一键回滚脚本(把 §1.1 打包) + +如果手动操作太烦,把回滚流程封装成一个脚本(jumpserver 上直接跑): + +**文件**: `/opt/wecom-it-desk/rollback-hotfix63.sh` + +```bash +#!/bin/bash +# v0.7.0 hotfix #63 一键回滚 +# 用法: bash /opt/wecom-it-desk/rollback-hotfix63.sh + +set -e # 任一命令失败立即退出 + +echo "===== hotfix #63 一键回滚 =====" + +# 1) 停 + 删当前容器 +docker stop wecom_it_backend +docker rm wecom_it_backend + +# 2) 用 backup 镜像起 +docker run -d \ + --name wecom_it_backend \ + --restart=always \ + --network wecom_it_network \ + $(cat /opt/wecom-it-desk/backend-run.env) \ + wecom-it-desk-backend:v0.7.0-backup-pre-qrfix + +# 3) 等 5 秒让容器启动 +sleep 5 + +# 4) 健康检查 +echo "===== 验证 =====" +docker ps | grep wecom_it_backend +curl -kf https://itsupport.servyou.com.cn/api/ready && echo "READY OK" || echo "READY FAIL" + +echo "===== 回滚完成 =====" +``` + +**部署方式**(在 jumpserver 终端): + +```bash +# 1) 创建文件 +cat > /opt/wecom-it-desk/rollback-hotfix63.sh << 'EOF' +# (上面那段内容) +EOF + +# 2) 加执行权限 +chmod +x /opt/wecom-it-desk/rollback-hotfix63.sh + +# 3) 提取当前 backend 容器的 run 参数(给脚本里的 $(cat ...) 用) +docker inspect wecom_it_backend --format '{{range .Config.Env}}export {{.}}{{"\n"}}{{end}}' \ + > /opt/wecom-it-desk/backend-run.env 2>/dev/null || true + +# 4) 跑回滚 +bash /opt/wecom-it-desk/rollback-hotfix63.sh +``` + +> **注意**: `--env-file` / `-e` 在 `docker run` 里比脚本里 export 更稳。**生产建议把完整 `docker run` 命令存到 `/opt/wecom-it-desk/backend-run.sh`,回滚脚本里直接 `bash backend-run.sh`**。这个留给后续优化。 + +--- + +## 7. 回滚后通知清单 + +回滚完 = 业务恢复,但**还要做 3 件事**: + +1. **更新 `CURRENT-FOCUS.md`**: 在「最近搞定」加一行 `❌ v0.7.0 hotfix #63 失败已回滚到 v0.7.0-backup-pre-qrfix,前端 /itportal/ 二维码仍不显示,等下一轮修复` +2. **记入 memory**: 在 `memory/` 加 `hotfix-63-rollback-2026-06-22.md`,写清楚: 失败在哪一步 / 用了哪个回滚命令 / 跟 Claude 复盘结论 +3. **贴 logs 给 Claude**: 把 `docker logs wecom_it_backend --tail 200` 输出贴回来,分析根因,准备下一轮 hotfix 方案(v0.7.0.2-hotfix2) + +--- + +## 8. 速查表(贴在屏幕边上) + +| 我看到 | 跑这个 | +|--------|--------| +| 容器 restarting | `docker logs wecom_it_backend --tail 30` 看启动错误 → §1.1 | +| 容器 healthy 但 create 500 | §1.3 拿 traceback → §1.2 覆盖文件 | +| 容器 healthy + create 200 + 无 base64 | §1.4 强制 bind mount 覆盖 | +| 502/504 | §1.5 看网络 + 容器 | +| 8000 占用 | §1.6 | +| 完全不知道啥情况 | §1.1 一键换镜像(最稳) | +| 不知道回滚到哪个镜像 | `docker images \| grep backup` | +| 不知道完整 run 命令 | `docker inspect wecom_it_backend --format '{{.Config.Cmd}} {{json .Config.Env}}' \| head -c 500` | +| 想一键回滚 | `bash /opt/wecom-it-desk/rollback-hotfix63.sh` | +| 验证 hotfix 生效 | §3 五步全过 = ✅ | +| 回滚后清理 | §5 三步 | + +--- + +**文档结束**。所有命令都在 jumpserver 终端以 root 跑,`docker exec` 都假设容器名叫 `wecom_it_backend`(生产实际名,见 `memory/container-names-wecom-it-backend.md`)。如果容器名变了,先跑 `docker ps --format '{{.Names}}' \| grep backend` 确认。 diff --git a/backend/alembic/versions/026_drop_agent_otp_legacy.py b/backend/alembic/versions/026_drop_agent_otp_legacy.py new file mode 100644 index 0000000..b40f80a --- /dev/null +++ b/backend/alembic/versions/026_drop_agent_otp_legacy.py @@ -0,0 +1,58 @@ +"""drop legacy agent OTP fields + +Revision ID: 026_drop_agent_otp_legacy +Revises: 025_messages_id_uuid +Create Date: 2026-06-22 + +v0.7.1: 清理 v0.5.6 引入的 otp_secret / otp_enabled 双字段 +原因: 旧 OTP 字段只用于高危操作前的二次验证,mfa_secret/mfa_enabled(migration 023) + 已涵盖该用途。两个字段名不同导致 v0.7.0 生产报错: + column agents.otp_secret does not exist(alembic 010 之前没在生产跑过) + +策略: 用 IF EXISTS 兼容"列不存在"情况(因为生产数据库可能从来没建过这列) + DROP COLUMN 不会破坏生产 — mfa_secret 是新的生产字段,otp_secret 只是历史遗留 + +下游: agents.py / admin_api.py 改用 mfa_secret/mfa_enabled + Agent 模型删 otp_secret/otp_enabled 字段 + +回退: 此 migration 的 downgrade 重新添加 otp_secret/otp_enabled + 如果生产用过 OTP 的话要回退(目前 IT 支持服务未正式上线,无此风险) +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '026_drop_agent_otp_legacy' +down_revision = '025_messages_id_uuid' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """删除 legacy OTP 字段(IF EXISTS 兼容列不存在的场景)。""" + op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_secret") + op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS otp_enabled") + + +def downgrade() -> None: + """回退: 重新添加 legacy OTP 字段。""" + 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 二次验证' + ) + ) diff --git a/backend/alembic/versions/027_audit_logs.py b/backend/alembic/versions/027_audit_logs.py new file mode 100644 index 0000000..8b3a68c --- /dev/null +++ b/backend/alembic/versions/027_audit_logs.py @@ -0,0 +1,80 @@ +"""audit_logs 表 — 高危操作/登录/MFA 审计日志 + +Revision ID: 027_audit_logs +Revises: 026_drop_agent_otp_legacy +Create Date: 2026-06-22 (v0.7.1) + +v0.7.1 task #89 实施,配合 RBAC 5 角色的 audit_log 资源(给 auditor 角色只读用) + +字段: + - id: UUID 主键 + - employee_id: 操作人(企微 UserID / 'system') + - action: 操作类型 + - resource: 目标资源类型 + - resource_id: 目标资源 ID + - details: JSON 详细上下文 + - result: success / failure / partial + - ip_address: 来源 IP + - user_agent: 来源 UA + - created_at: 时间 + +索引: + - idx_audit_employee_id: 按操作人查 + - idx_audit_action: 按操作类型查 + - idx_audit_resource: 按资源类型+ID 查 + - idx_audit_created_at: 按时间范围查(默认倒序) +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '027_audit_logs' +down_revision = '026_drop_agent_otp_legacy' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """建 audit_logs 表 + 索引。""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('audit_logs'): + op.create_table( + 'audit_logs', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('employee_id', sa.String(100), nullable=False, + comment='操作人(employee_id / system)'), + sa.Column('action', sa.String(50), nullable=False, + comment='操作类型'), + sa.Column('resource', sa.String(50), nullable=False, + comment='目标资源类型'), + sa.Column('resource_id', sa.String(100), nullable=True, + comment='目标资源 ID'), + sa.Column('details', sa.JSON, nullable=True, + comment='详细上下文(JSON)'), + sa.Column('result', sa.String(20), nullable=False, server_default='success', + comment='执行结果'), + sa.Column('ip_address', sa.String(64), nullable=True, + comment='来源 IP'), + sa.Column('user_agent', sa.Text, nullable=True, + comment='来源 User-Agent'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, + comment='时间'), + ) + + # 4 个索引 (IF NOT EXISTS 兼容) + op.execute("CREATE INDEX IF NOT EXISTS idx_audit_employee_id ON audit_logs (employee_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs (action)") + op.execute("CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_logs (resource, resource_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs (created_at)") + + +def downgrade() -> None: + """删 audit_logs 表(顺序: 删索引 → 删表)。""" + op.execute("DROP INDEX IF EXISTS idx_audit_created_at") + op.execute("DROP INDEX IF EXISTS idx_audit_resource") + op.execute("DROP INDEX IF EXISTS idx_audit_action") + op.execute("DROP INDEX IF EXISTS idx_audit_employee_id") + op.execute("DROP TABLE IF EXISTS audit_logs") diff --git a/backend/app/api/admin_api.py b/backend/app/api/admin_api.py index c453a80..9f8e64c 100644 --- a/backend/app/api/admin_api.py +++ b/backend/app/api/admin_api.py @@ -294,8 +294,10 @@ async def admin_unbind_agent_otp( if not agent: raise AppException(1001, "坐席不存在") - agent.otp_secret = None - agent.otp_enabled = 0 + agent.mfa_secret = None + agent.mfa_enabled = False + agent.mfa_bound_at = None + agent.mfa_last_verified_at = None agent.updated_at = datetime.now() db.add(agent) await db.flush() diff --git a/backend/app/api/admin_roles.py b/backend/app/api/admin_roles.py index cb1c94e..c53fcf6 100644 --- a/backend/app/api/admin_roles.py +++ b/backend/app/api/admin_roles.py @@ -382,3 +382,132 @@ async def delete_mapping_rule( logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}") return success_response(message="映射规则删除成功") + + +# ========================================================================== +# 4. 权限矩阵可视化 (v0.7.1 task #86) +# ========================================================================== +# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵 +# 嵌套结构方便前端直接渲染表格: +# { +# "roles": [{name, display_name, permissions: [string]}], +# "resources": [conversation, agent, ...], +# "actions": [read, create, update, delete], +# "scopes": [own, department, all], +# "matrix": { +# "agent": { # 角色名 +# "conversation:read:own": true, +# "conversation:read:all": true, +# ... +# } +# } +# } +# ========================================================================== +@router.get("/permissions/matrix") +async def get_permissions_matrix( + admin: UserInfo = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """获取 RBAC 完整权限矩阵(管理后台可视化用)。 + + 返回 5 角色预置的 permissions JSON,前端用此数据渲染 + 角色 × 资源 × 操作 × 范围 的可读表格。 + + Args: + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含完整权限矩阵 + """ + from app.services.rbac_service import ( + ROLE_PERMISSIONS, + VALID_ACTIONS, + VALID_RESOURCES, + VALID_SCOPES, + permissions_to_strings, + ) + + # 1. 查 DB 拿角色元数据(显示名等) + stmt = select(Role).order_by(Role.is_default.desc(), Role.name) + result = await db.execute(stmt) + roles = result.scalars().all() + + # 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name) + role_list = [] + matrix = {} + for role in roles: + # 优先用代码常量(单一可信源);DB 字段仅作元数据 + perms = ROLE_PERMISSIONS.get(role.name, set()) + perms_list = permissions_to_strings(perms) + + role_list.append({ + "name": role.name, + "display_name": role.display_name, + "description": role.description, + "is_default": role.is_default, + "permission_count": len(perms_list), + }) + + # 3. 角色 × 资源 × 操作 × 范围 的全矩阵 + # true/false 表征是否拥有此权限 + # 前端用此渲染表格,空格表示"不适用" + role_matrix = {} + for resource in VALID_RESOURCES: + for action in VALID_ACTIONS: + for scope in VALID_SCOPES: + perm = f"{resource}:{action}:{scope}" + role_matrix[perm] = (resource, action, scope) in perms + matrix[role.name] = role_matrix + + return success_response(data={ + "roles": role_list, + "resources": VALID_RESOURCES, + "actions": VALID_ACTIONS, + "scopes": VALID_SCOPES, + "matrix": matrix, + }) + + +# ---------- GET /api/admin/roles/permissions/check ---------- +# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有 +# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission +@router.get("/permissions/check") +async def check_my_permission( + resource: str = Query(..., description="资源"), + action: str = Query(..., description="操作"), + scope: str = Query("own", description="数据范围"), + admin: UserInfo = Depends(require_admin), +): + """检查当前管理员是否拥有指定权限(给前端按钮级控制用)。 + + 永远返回 true(因为 require_admin 已确保是 admin)。 + 此端点存在是为了给前端一个统一入口,实际权限由后端强制。 + 未来扩展:可加 current_user 参数(非 admin 角色也能调)。 + + Args: + resource: 资源 + action: 操作 + scope: 数据范围 + + Returns: + Dict: 统一响应格式,包含 has_permission 字段 + """ + from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings + + user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()} + + has_perm = check_permission( + user_roles=admin.roles, + user_permissions=user_perms, + required_resource=resource, + required_action=action, + required_scope=scope, + ) + + return success_response(data={ + "has_permission": has_perm, + "resource": resource, + "action": action, + "scope": scope, + }) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 6243581..8057618 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -257,8 +257,9 @@ async def agent_login( await db.flush() logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}") - # 2. OTP 二次验证(admin 角色且已绑定 OTP) - if agent.role == "admin" and agent.otp_enabled == 1: + # 2. MFA 二次验证(admin 角色且已绑定 MFA) + # v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled + if agent.role == "admin" and agent.mfa_enabled: if not body.otp_code: # 需要 OTP 验证,返回 require_otp 标记 return success_response(data={ @@ -269,7 +270,7 @@ async def agent_login( }) else: # 验证 OTP 码 - totp = pyotp.TOTP(agent.otp_secret) + totp = pyotp.TOTP(agent.mfa_secret) if not totp.verify(body.otp_code, valid_window=1): raise AppException(1006, "OTP验证码错误,请重新输入") @@ -414,15 +415,16 @@ async def bind_agent_otp( Dict: 二维码图片(base64)和密钥 """ try: + # v0.7.1: 用 mfa_secret 替代 otp_secret # 检查是否已绑定 - if agent.otp_secret: + if agent.mfa_secret: # 已绑定,返回现有密钥的二维码 - totp = pyotp.TOTP(agent.otp_secret) + totp = pyotp.TOTP(agent.mfa_secret) else: # 生成新密钥 secret = pyotp.random_base32() - agent.otp_secret = secret - # otp_enabled 保持 0,等待首次验证后启用 + agent.mfa_secret = secret + # mfa_enabled 保持 False,等待首次验证后启用 db.add(agent) await db.flush() totp = pyotp.TOTP(secret) @@ -439,11 +441,11 @@ async def bind_agent_otp( qr.save(buffer, format="PNG") qr_base64 = base64.b64encode(buffer.getvalue()).decode() - logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...") + logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...") return success_response(data={ "qr_code": f"data:image/png;base64,{qr_base64}", - "secret": agent.otp_secret, + "secret": agent.mfa_secret, }) except AppException: @@ -475,16 +477,18 @@ async def verify_agent_otp( result = await db.execute(stmt) agent = result.scalars().first() - if not agent or not agent.otp_secret: + if not agent or not agent.mfa_secret: raise AppException(1008, "请先绑定OTP") # 验证 OTP 码 - totp = pyotp.TOTP(agent.otp_secret) + totp = pyotp.TOTP(agent.mfa_secret) if not totp.verify(body.otp_code, valid_window=1): raise AppException(1006, "OTP验证码错误") - # 验证成功,启用 OTP - agent.otp_enabled = 1 + # 验证成功,启用 MFA + agent.mfa_enabled = True + agent.mfa_bound_at = datetime.now() + agent.mfa_last_verified_at = datetime.now() agent.updated_at = datetime.now() db.add(agent) await db.flush() @@ -492,7 +496,7 @@ async def verify_agent_otp( logger.info(f"OTP验证成功并启用: agent={agent.user_id}") return success_response(data={ - "otp_enabled": True, + "mfa_enabled": True, "message": "OTP验证成功,已启用", }) @@ -510,15 +514,17 @@ async def unbind_agent_otp( ): """解绑 OTP。 - 解绑后 otp_secret 和 otp_enabled 都清空。 + 解绑后 mfa_secret 和 mfa_enabled 都清空。 需要管理员操作。 Returns: Dict: 解绑结果 """ try: - agent.otp_secret = None - agent.otp_enabled = 0 + agent.mfa_secret = None + agent.mfa_enabled = False + agent.mfa_bound_at = None + agent.mfa_last_verified_at = None agent.updated_at = datetime.now() db.add(agent) await db.flush() diff --git a/backend/app/api/audit_logs.py b/backend/app/api/audit_logs.py new file mode 100644 index 0000000..e35eff8 --- /dev/null +++ b/backend/app/api/audit_logs.py @@ -0,0 +1,75 @@ +# ============================================================================= +# 企微IT智能服务台 — 审计日志 API (v0.7.1 task #89) +# ============================================================================= +# 说明: 审计日志只读端点,给 auditor / admin 用 +# 权限要求: audit_log:read:all (由 RBAC 装饰器校验) +# ============================================================================= + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import require_permission, UserInfo +from app.database import get_db +from app.services.audit_log_service import list_audit_logs +from app.utils.response import success_response + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/audit-logs", tags=["审计日志"]) + + +@router.get("") +@require_permission("audit_log", "read", "all") +async def get_audit_logs( + employee_id: Optional[str] = Query(None, description="按操作人过滤"), + action: Optional[str] = Query(None, description="按操作类型过滤"), + resource: Optional[str] = Query(None, description="按资源类型过滤"), + from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"), + to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(50, ge=1, le=500, description="每页条数"), + admin: UserInfo = None, # 由 require_permission 注入(签名合并) + db: AsyncSession = Depends(get_db), +): + """查询审计日志(分页)。 + + 权限: 需要 audit_log:read:all (admin / auditor 角色拥有) + + Returns: + Dict: 统一响应格式,包含 items/total/page/page_size + """ + result = await list_audit_logs( + db, + employee_id=employee_id, + action=action, + resource=resource, + from_time=from_time, + to_time=to_time, + page=page, + page_size=page_size, + ) + + return success_response(data={ + "items": [ + { + "id": log.id, + "employee_id": log.employee_id, + "action": log.action, + "resource": log.resource, + "resource_id": log.resource_id, + "details": log.details, + "result": log.result, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in result["items"] + ], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + }) diff --git a/backend/app/api/auth_qrcode.py b/backend/app/api/auth_qrcode.py index f7b7eb2..b0fe538 100644 --- a/backend/app/api/auth_qrcode.py +++ b/backend/app/api/auth_qrcode.py @@ -91,6 +91,7 @@ async def create_qrcode( return success_response(data={ "ticket": result["ticket"], "qrcode_url": result["qrcode_url"], + "qrcode_png_base64": result["qrcode_png_base64"], "expires_in": result["expires_in"], "expires_at": result["expires_at"].isoformat(), }) diff --git a/backend/app/api/auth_wecom_sso.py b/backend/app/api/auth_wecom_sso.py new file mode 100644 index 0000000..8ab2671 --- /dev/null +++ b/backend/app/api/auth_wecom_sso.py @@ -0,0 +1,228 @@ +# ============================================================================= +# 企微IT智能服务台 — 企微入口 SSO(v0.7.1 新增) +# ============================================================================= +# 说明: 解决 v0.7.0 hotfix1 用户报告的"企微工作台进入应用也要扫码"问题。 +# +# 流程: +# 1. 前端 PortalSelect.vue 加载时检测 navigator.userAgent +# 2. 如果是 MicroMessenger / wxwork / DingTalk 等企微内置浏览器 +# → 调 /api/auth_wecom/sso/init?next=/itdesk/ +# 3. 后端生成企微 OAuth2 授权 URL,302 跳转用户去企微授权 +# 4. 企微回调 /api/auth_wecom/sso/callback?code=...&state=... +# 5. 用 code 换 userid,查 role (user/agent/admin),生成 token +# 6. 302 跳转到 next 路径 + token query param +# 7. 前端用 token 调 get_current_user 拉身份信息 +# +# 配置要求: +# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 可信域名: itsupport.servyou.com.cn +# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 回调域: itsupport.servyou.com.cn +# - 环境变量 WECOM_SSO_ENABLED=true 启用(默认 false,避免老用户被打扰) +# ============================================================================= + +import logging +import secrets +import urllib.parse +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db +from app.models.role import Role +from app.models.user_role import UserRole +from app.services.wecom_service import WecomService +from app.utils.response import AppException +from app.dependencies import get_redis + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"]) + +# OAuth state 在 Redis 的 TTL (5 分钟,够用户授权 + 回调) +OAUTH_STATE_TTL = 300 +# SSO token 长度 +SSO_TOKEN_BYTES = 32 + + +def _sso_enabled() -> bool: + """检查是否启用企微 SSO。""" + import os + if os.getenv("WECOM_SSO_ENABLED", "false").lower() == "true": + return True + if getattr(settings, "wecom_sso_enabled", False): + return True + return False + + +def _get_oauth_callback_url(request: Request) -> str: + """拼接 OAuth 回调 URL (绝对地址)。 + + 企微要求 redirect_uri 必须用可信域名(itsupport.servyou.com.cn)。 + 不读 request.base_url 因为它可能是 127.0.0.1:8000(开发环境)。 + """ + # 优先用 settings 里的配置 + base = getattr(settings, "wecom_sso_callback_base", None) + if not base: + # 兜底: 读环境变量,默认生产域名 + import os + base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn") + return f"{base.rstrip('/')}/api/auth_wecom/sso/callback" + + +def _build_oauth_url(state: str, callback_url: str) -> str: + """拼企微 OAuth2 授权 URL。 + + 文档: https://developer.work.weixin.qq.com/document/path/91022 + """ + params = { + "appid": settings.wecom_corp_id, + "redirect_uri": callback_url, + "response_type": "code", + "scope": "snsapi_base", # 静默授权 + "state": state, + "agentid": settings.wecom_agent_id, + } + query = urllib.parse.urlencode(params) + return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect" + + +@router.get("/sso/init") +async def sso_init( + request: Request, + next: str = Query("/itdesk/", description="登录后跳转路径"), + redis_client = Depends(get_redis), +): + """初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。 + + Args: + next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/ + """ + if not _sso_enabled(): + raise AppException(1001, "企微 SSO 未启用, 请用扫码登录") + + # 1. 生成 state(防 CSRF + 携带 next 路径) + state = secrets.token_urlsafe(24) + state_payload = { + "next": next, + "created_at": datetime.now().isoformat(), + } + await redis_client.setex( + f"wecom_sso:state:{state}", + OAUTH_STATE_TTL, + str(state_payload).encode("utf-8"), + ) + + # 2. 拼企微 OAuth URL + callback_url = _get_oauth_callback_url(request) + oauth_url = _build_oauth_url(state, callback_url) + + logger.info(f"SSO init: state={state[:8]}..., next={next}") + return RedirectResponse(url=oauth_url, status_code=302) + + +@router.get("/sso/callback") +async def sso_callback( + code: str = Query(..., description="企微 OAuth2 授权 code"), + state: str = Query(..., description="防 CSRF state"), + redis_client = Depends(get_redis), + db: AsyncSession = Depends(get_db), +): + """企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。""" + # 1. 校验 state(防 CSRF) + state_key = f"wecom_sso:state:{state}" + state_raw = await redis_client.get(state_key) + if not state_raw: + raise AppException(1002, "SSO state 已过期或无效, 请重新进入") + + # 删除 state(一次性) + await redis_client.delete(state_key) + + import ast + state_data = ast.literal_eval(state_raw.decode("utf-8")) + next_path = state_data.get("next", "/itdesk/") + + # 2. 用 code 换 userid + wecom = WecomService(redis_client) + try: + oauth_info = await wecom.get_oauth_user_info(code) + user_id = oauth_info.get("userid", "") + if not user_id: + raise AppException(1003, "企微 OAuth 返回 userid 为空") + + user_info = await wecom.get_user_info(user_id) + name = user_info.get("name", user_id) + except Exception as e: + logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}") + raise AppException(1004, f"企微身份识别失败: {str(e)}") + finally: + try: + await wecom.close() + except Exception: + pass + + # 3. 查 role (user/agent/admin) + role_stmt = ( + select(Role) + .join(UserRole, Role.id == UserRole.role_id) + .where(UserRole.employee_id == user_id) + ) + role_result = await db.execute(role_stmt) + roles = role_result.scalars().all() + + if not roles: + # 没有绑定角色: 跳"无权限"页 + logger.warning(f"SSO: user_id={user_id} 没绑定任何角色") + return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302) + + # 4. 选最高权限角色 (admin > agent > user) + role_priority = {"admin": 3, "agent": 2, "user": 1} + best_role = max(roles, key=lambda r: role_priority.get(r.name, 0)) + role_name = best_role.name + + # 5. 生成 SSO token(随机 + Redis 存 8 小时) + sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES) + sso_payload = { + "user_id": user_id, + "name": name, + "role": role_name, + "created_at": datetime.now().isoformat(), + } + import json + await redis_client.setex( + f"wecom_sso:token:{sso_token}", + 8 * 3600, # 8 小时 + json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"), + ) + + # 6. 跳转到 next + token + separator = "&" if "?" in next_path else "?" + redirect_url = f"{next_path}{separator}sso_token={sso_token}" + + logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}") + return RedirectResponse(url=redirect_url, status_code=302) + + +@router.get("/sso/verify") +async def sso_verify( + sso_token: str = Query(..., description="SSO token"), + redis_client = Depends(get_redis), + db: AsyncSession = Depends(get_db), +): + """前端用 SSO token 换用户身份(token 一次性使用,用完删除)。""" + import json + token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}") + if not token_raw: + raise AppException(1005, "SSO token 已过期或无效") + + # 一次性 token(防止泄漏后被滥用) + await redis_client.delete(f"wecom_sso:token:{sso_token}") + + payload = json.loads(token_raw.decode("utf-8")) + return { + "code": 0, + "data": payload, + } diff --git a/backend/app/api/router.py b/backend/app/api/router.py index aa0dc6f..ddc9577 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -207,3 +207,16 @@ api_router.include_router(mfa_router, tags=["MFA二次认证"]) # MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底) # POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"]) + +# 企微 SSO (v0.7.1 task #85) +# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO +# GET /api/auth_wecom/sso/callback — 企微 OAuth2 回调,用 code 换 userid → 跳端点 +# GET /api/auth_wecom/sso/verify — 前端用 SSO token 换用户身份(一次性) +from app.api.auth_wecom_sso import router as auth_wecom_sso_router +api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"]) + +# 审计日志 API (v0.7.1 task #89) +# GET /api/admin/audit-logs — 分页 + 多维过滤(给 auditor / admin 角色用) +# 权限要求: audit_log:read:all (RBAC 装饰器强制) +from app.api.audit_logs import router as audit_logs_router +api_router.include_router(audit_logs_router, tags=["审计日志"]) diff --git a/backend/app/config.py b/backend/app/config.py index 01e966b..dc88057 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -124,6 +124,16 @@ class Settings(BaseSettings): # 设备申请审批模板ID(在企微审批应用设置中获取) approval_template_device: str = "" + # ---------------------------------------------------------------------- + # v0.7.1 企微 SSO 入口配置 (task #85) + # ---------------------------------------------------------------------- + # 是否启用企微 SSO(true = 优先用企微 OAuth2 静默授权,失败时降级扫码) + # 通过环境变量 WECOM_SSO_ENABLED 控制(默认 false,避免老用户被打扰) + wecom_sso_enabled: bool = False + # SSO OAuth 回调 base URL(企微要求 redirect_uri 必须用可信域名) + # 生产: https://itsupport.servyou.com.cn 开发: http://localhost:5176 + wecom_sso_callback_base: str = "" + # ---------------------------------------------------------------------- # v0.5.4 应急页身份检测配置 # ---------------------------------------------------------------------- diff --git a/backend/app/data/seed_rbac.py b/backend/app/data/seed_rbac.py new file mode 100644 index 0000000..cb32f72 --- /dev/null +++ b/backend/app/data/seed_rbac.py @@ -0,0 +1,71 @@ +# ============================================================================= +# 企微IT智能服务台 — RBAC 角色种子数据 (v0.7.1 task #86) +# ============================================================================= +# 启动时调用,把 5 角色 + 权限矩阵写入 roles 表 +# 兼容"角色已存在"的场景: 不重复插入,但更新 permissions +# ============================================================================= + +import logging +import uuid +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.role import Role +from app.services.rbac_service import ( + ROLE_METADATA, + get_role_default_permissions, +) + +logger = logging.getLogger(__name__) + + +async def seed_rbac_roles(db: AsyncSession) -> int: + """种子 RBAC 5 角色。 + + 行为: + 1. 遍历 ROLE_METADATA + 2. 角色不存在 → 创建(UUID + 默认 permissions) + 3. 角色存在 → 更新 display_name / description / permissions + (不动 is_default,避免影响手动设置) + + Returns: + int: 新建角色数 + """ + created_count = 0 + + for role_name, meta in ROLE_METADATA.items(): + # 查询是否已存在 + stmt = select(Role).where(Role.name == role_name) + result = await db.execute(stmt) + role = result.scalars().first() + + permissions = get_role_default_permissions(role_name) + + if role: + # 更新现有角色(不动 is_default,防止覆盖手动设置) + role.display_name = meta["display_name"] + role.description = meta["description"] + role.permissions = permissions + role.updated_at = datetime.now() + logger.debug(f"更新角色: {role_name} ({len(permissions)} 项权限)") + else: + # 创建新角色 + role = Role( + id=str(uuid.uuid4()), + name=role_name, + display_name=meta["display_name"], + description=meta["description"], + permissions=permissions, + is_default=(meta["is_default"] == "true"), + created_at=datetime.now(), + updated_at=datetime.now(), + ) + db.add(role) + created_count += 1 + logger.info(f"创建角色: {role_name} ({len(permissions)} 项权限)") + + await db.commit() + logger.info(f"RBAC 角色种子完成: 新建 {created_count} 个") + return created_count diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index ef9c5dc..ac75700 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -284,6 +284,110 @@ def require_admin(func): return require_role("admin")(func) +# ============================================================================= +# 细粒度权限装饰器 (v0.7.1 task #86 — RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围) +# ============================================================================= +# 权限字符串格式: "resource:action:scope" +# 例: "conversation:read:all" +# +# 用法: +# @router.get("/api/admin/agents") +# @require_permission("agent:read:all") +# async def list_agents(...): ... +# +# 行为: +# 1. 装饰器只检查"是否拥有权限字符串",不直接执行 DB 查询 +# 2. 实际检查在 rbac_service.check_permission() 里 +# 3. 用户的权限从 UserInfo.permissions 字段读(由 get_current_user 解析 token 时填入) +# ============================================================================= + +def require_permission( + resource: str, + action: str, + scope: str = "own", +): + """细粒度权限验证装饰器(v0.7.1 task #86)。 + + Args: + resource: 资源(conversation/agent/system_config/audit_log) + action: 操作(read/create/update/delete) + scope: 数据范围(own/department/all) + + Example: + @router.get("/api/admin/agents") + @require_permission("agent", "read", "all") + async def list_agents(current_user: UserInfo = Depends(get_current_user)): + ... + """ + perm_string = f"{resource}:{action}:{scope}" + + def decorator(func): + 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) + async def wrapper(*args, **kwargs): + current_user = kwargs.pop('current_user') + + # 拉用户所有角色的 permissions + # 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典 + # 首次实现简化: 角色判断 + admin 通配符 + # 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission + + user_roles = set(current_user.roles or []) + + # 1. admin 角色直通(通配符 *:*:all) + if "admin" in user_roles: + return await func(*args, current_user=current_user, **kwargs) + + # 2. 其他角色: 走 rbac_service.check_permission + # 简化: 这里只看角色名,不查 DB(性能考虑) + # 实际生产可加缓存或预加载到 token + from app.services.rbac_service import ( + ROLE_PERMISSIONS, + check_permission, + ) + # 把 ROLE_PERMISSIONS 转成 {role_name: [perm_string]} 格式 + user_perms_dict = { + role: [f"{r}:{a}:{s}" for (r, a, s) in perms] + for role, perms in ROLE_PERMISSIONS.items() + } + + has_perm = check_permission( + user_roles=list(user_roles), + user_permissions=user_perms_dict, + required_resource=resource, + required_action=action, + required_scope=scope, + ) + + if not has_perm: + logger.warning( + f"用户 {current_user.employee_id} 权限不足: " + f"角色 {list(user_roles)}, 缺 {perm_string}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"权限不足: 需要 {perm_string}", + ) + + return await func(*args, current_user=current_user, **kwargs) + + wrapper.__signature__ = new_sig + return wrapper + + return decorator + + # ============================================================================= # 高危操作 OTP 守卫依赖(Phase 1.3 task #19) # ============================================================================= diff --git a/backend/app/main.py b/backend/app/main.py index 039abda..407f374 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -210,7 +210,12 @@ async def _init_default_data(): # 5. 初始化软件下载入口 await _init_software_downloads(db, SoftwareDownload) - # 6. (dev 模式)初始化 demo 会话,让前端有数据可发 + # 6. v0.7.1 task #86 — RBAC 5 角色种子(细粒度权限) + # 行为: 已有角色更新 permissions,缺则新建 + from app.data.seed_rbac import seed_rbac_roles + await seed_rbac_roles(db) + + # 7. (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) @@ -752,7 +757,8 @@ def create_app() -> FastAPI: """ try: # 检查数据库 - from app.database import engine + from app.database import _get_engine + engine = _get_engine() async with engine.connect() as conn: await conn.execute(text("SELECT 1")) db_status = "ok" @@ -761,8 +767,7 @@ def create_app() -> FastAPI: try: # 检查 Redis - from app.config import get_settings - settings = get_settings() + from app.config import settings redis_client = settings.create_redis_client() await redis_client.ping() redis_status = "ok" diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 146bde3..8cbf46e 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -123,21 +123,10 @@ class Agent(Base): comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)", ) - # OTP密钥(用于TOTP动态码验证,为空表示未绑定) - otp_secret: Mapped[str] = mapped_column( - String(32), - nullable=True, - default=None, - comment="OTP密钥(Base32编码)", - ) - - # OTP是否启用(admin角色强制启用) - otp_enabled: Mapped[bool] = mapped_column( - Integer, - nullable=False, - default=0, - comment="OTP是否启用(0=否, 1=是)", - ) + # v0.7.1: 删除 otp_secret / otp_enabled 字段 + # 原因: 与下方 mfa_secret / mfa_enabled 完全重复(都是 TOTP secret) + # 旧 OTP 字段只用于高危操作前的二次验证,mfa 字段已涵盖该用途 + # 迁移策略: alembic 010 改为 DROP COLUMN otp_secret, otp_enabled # 本地密码哈希(可选,用于本地密码认证) # 使用 bcrypt 加密存储,不存储明文密码 diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..2315274 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,130 @@ +# ============================================================================= +# 企微IT智能服务台 — 审计日志模型 +# ============================================================================= +# 说明: 对应数据库 audit_logs 表,记录所有高危/RBAC 操作 + 登录/MFA 事件 +# 给 auditor 角色 + admin 提供只读审计能力 +# +# 何时写入: +# - 高危操作 (role_change / config_change / data_export / account_disable / account_create_reset) +# - RBAC 操作 (assign_role / revoke_role / create_mapping_rule / delete_mapping_rule) +# - 登录事件 (qrcode_login / sso_login / password_login) +# - MFA 事件 (bind / verify / reset) +# - 业务敏感操作 (resolve_conversation / transfer_conversation) +# ============================================================================= + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import JSON, DateTime, Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AuditLog(Base): + """审计日志模型 — 对应 audit_logs 表。 + + Attributes: + id: 日志唯一标识(UUID) + employee_id: 操作人(企微 UserID,系统操作填 "system") + action: 操作类型(如 "role_change", "login", "mfa_verify") + resource: 目标资源类型("agent" / "conversation" / "system_config" 等) + resource_id: 目标资源 ID + details: 详细上下文(JSON,前后值/IP/UA 等) + result: "success" / "failure" / "partial" + ip_address: 操作来源 IP(可选) + user_agent: 操作来源 UA(可选) + created_at: 时间 + """ + + __tablename__ = "audit_logs" + + # 主键:UUID + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + + # 操作人(企微 UserID, 系统操作填 "system") + employee_id: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="操作人(employee_id / 'system')", + ) + + # 操作类型 + # 例: "role_change" / "config_change" / "login" / "mfa_verify" / + # "qrcode_login" / "sso_login" / "password_login" / + # "resolve_conversation" / "transfer_conversation" / "data_export" + action: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="操作类型", + ) + + # 目标资源类型 + # 例: "agent" / "conversation" / "system_config" / "role" / "user_role" + resource: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="目标资源类型", + ) + + # 目标资源 ID(字符串,跨表通用) + resource_id: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="目标资源 ID", + ) + + # 详细上下文(JSON) + # 例: {"role": "agent", "reason": "新员工转岗", "ip": "10.80.0.5"} + details: Mapped[Optional[dict]] = mapped_column( + JSON, + nullable=True, + comment="详细上下文(JSON)", + ) + + # 结果 + # "success" / "failure" / "partial" + result: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="success", + comment="执行结果", + ) + + # 来源 IP + ip_address: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + comment="来源 IP", + ) + + # 来源 User-Agent + user_agent: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="来源 User-Agent", + ) + + # 时间 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + comment="时间", + ) + + # 索引:按 employee_id / action / time 查询 + __table_args__ = ( + Index("idx_audit_employee_id", "employee_id"), + Index("idx_audit_action", "action"), + Index("idx_audit_resource", "resource", "resource_id"), + Index("idx_audit_created_at", "created_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/services/audit_log_service.py b/backend/app/services/audit_log_service.py new file mode 100644 index 0000000..0d8ddaa --- /dev/null +++ b/backend/app/services/audit_log_service.py @@ -0,0 +1,137 @@ +# ============================================================================= +# 企微IT智能服务台 — 审计日志服务 +# ============================================================================= +# 说明: 提供 audit_log 写入/查询的统一入口 +# 用法: +# from app.services.audit_log_service import record_audit_log +# await record_audit_log( +# db, employee_id="sxn", action="role_change", +# resource="agent", resource_id="agent-001", +# details={"role": "agent"}, request=request, +# ) +# ============================================================================= + +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import Request +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audit_log import AuditLog + +logger = logging.getLogger(__name__) + + +async def record_audit_log( + db: AsyncSession, + employee_id: str, + action: str, + resource: str, + resource_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + result: str = "success", + request: Optional[Request] = None, +) -> AuditLog: + """记录一条审计日志。 + + Args: + db: 数据库会话 + employee_id: 操作人企微 UserID,系统操作传 "system" + action: 操作类型 + resource: 目标资源类型 + resource_id: 目标资源 ID(可选) + details: 详细上下文 JSON(可选) + result: success / failure / partial + request: FastAPI Request(可选,自动取 IP + UA) + + Returns: + AuditLog: 写入的日志对象 + """ + ip_address = None + user_agent = None + if request is not None: + # 优先用 X-Forwarded-For / X-Real-IP(proxy 后面) + ip_address = ( + request.headers.get("x-forwarded-for", "").split(",")[0].strip() + or request.headers.get("x-real-ip") + or (request.client.host if request.client else None) + ) + user_agent = request.headers.get("user-agent") + + log = AuditLog( + employee_id=employee_id, + action=action, + resource=resource, + resource_id=resource_id, + details=details or {}, + result=result, + ip_address=ip_address, + user_agent=user_agent, + created_at=datetime.now(), + ) + db.add(log) + # 注:不 commit,让调用方跟主操作一起 commit(避免日志写一半就回滚) + return log + + +async def list_audit_logs( + db: AsyncSession, + employee_id: Optional[str] = None, + action: Optional[str] = None, + resource: Optional[str] = None, + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + page: int = 1, + page_size: int = 50, +) -> Dict[str, Any]: + """查询审计日志(分页 + 多维过滤)。 + + Args: + db: 数据库会话 + employee_id: 按操作人过滤(可选) + action: 按操作类型过滤(可选) + resource: 按资源类型过滤(可选) + from_time: 起始时间(可选) + to_time: 结束时间(可选) + page: 页码,从 1 开始 + page_size: 每页条数,默认 50 + + Returns: + Dict: {items: [...], total: int, page, page_size} + """ + stmt = select(AuditLog) + conditions = [] + if employee_id: + conditions.append(AuditLog.employee_id == employee_id) + if action: + conditions.append(AuditLog.action == action) + if resource: + conditions.append(AuditLog.resource == resource) + if from_time: + conditions.append(AuditLog.created_at >= from_time) + if to_time: + conditions.append(AuditLog.created_at <= to_time) + if conditions: + stmt = stmt.where(and_(*conditions)) + + # 倒序 + 分页 + stmt = stmt.order_by(AuditLog.created_at.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = result.scalars().all() + + # 总数 + count_stmt = select(func.count()).select_from(AuditLog) + if conditions: + count_stmt = count_stmt.where(and_(*conditions)) + total = (await db.execute(count_stmt)).scalar() or 0 + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/backend/app/services/qrcode_service.py b/backend/app/services/qrcode_service.py index f85fab5..752e781 100644 --- a/backend/app/services/qrcode_service.py +++ b/backend/app/services/qrcode_service.py @@ -11,14 +11,18 @@ # 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果 # ============================================================================= +import base64 import json import logging import os import secrets from datetime import datetime, timedelta +from io import BytesIO from typing import Any, Dict, Optional from urllib.parse import urlencode +import qrcode + import redis.asyncio as aioredis from app.config import settings @@ -140,10 +144,25 @@ class QrcodeService: return { "ticket": ticket, "qrcode_url": qrcode_url, + "qrcode_png_base64": self._render_qrcode_png(qrcode_url), "expires_in": TICKET_TTL_SECONDS, "expires_at": expires_at, } + @staticmethod + def _render_qrcode_png(url: str) -> str: + """把 url 编成 PNG 并返回 base64 字符串,供前端 直接渲染。 + + 依赖: requirements.txt 已有 qrcode[pil]==7.4.2 (2026-06-15 加的,原本为 OTP 绑定)。 + """ + qr = qrcode.QRCode(version=1, box_size=10, border=2) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + def _build_oauth_url(self, ticket: str) -> str: """拼接企微 OAuth2 授权 URL(供前端生成二维码)。 diff --git a/backend/app/services/rbac_service.py b/backend/app/services/rbac_service.py new file mode 100644 index 0000000..22e8099 --- /dev/null +++ b/backend/app/services/rbac_service.py @@ -0,0 +1,206 @@ +# ============================================================================= +# 企微IT智能服务台 — RBAC 细粒度权限服务 (v0.7.1 task #86) +# ============================================================================= +# 设计: 5 角色 × 4 资源 × 4 操作 × 3 数据范围 +# +# 角色: +# 1. user — 普通员工(默认, 无管理权限) +# 2. agent — 坐席(处理会话) +# 3. team_lead — 团队主管(团队管理 + 报告) +# 4. auditor — 审计员(只读跨部门) +# 5. admin — 超级管理员(全权限) +# +# 资源 (resource): +# 1. conversation — 会话 +# 2. agent — 坐席 +# 3. system_config — 系统配置 +# 4. audit_log — 审计日志 +# +# 操作 (action): +# 1. read — 查看 +# 2. create — 创建 +# 3. update — 修改 +# 4. delete — 删除 +# +# 数据范围 (scope): +# 1. own — 自己的(agent 只能看自己接的会话) +# 2. department — 部门的 +# 3. all — 全部(管理员 / 审计员) +# +# 权限字符串格式: "resource:action:scope" +# 例: "conversation:read:all" +# 通配符: "*:*:all" 表示全权限(仅 admin) +# ============================================================================= + +import logging +from typing import Dict, FrozenSet, List, Set, Tuple + +logger = logging.getLogger(__name__) + + +# 5 角色的权限矩阵 +# 格式: role_name -> Set[(resource, action, scope)] +ROLE_PERMISSIONS: Dict[str, Set[Tuple[str, str, str]]] = { + # 普通员工 — 仅创建自己的会话 + "user": { + ("conversation", "create", "own"), + ("conversation", "read", "own"), + }, + + # 坐席 — 处理分配给自己的会话,可读所有未分配的 + "agent": { + ("conversation", "read", "own"), + ("conversation", "read", "all"), # 看所有未分配的会话(坐席工作台需要) + ("conversation", "update", "own"), + ("conversation", "create", "all"), + }, + + # 团队主管 — 坐席权限 + 看本部门 + 管本部门坐席 + "team_lead": { + ("conversation", "read", "department"), + ("conversation", "update", "department"), + ("conversation", "create", "all"), + ("agent", "read", "department"), + ("agent", "update", "department"), # 改本部门坐席状态 + }, + + # 审计员 — 只读,跨部门 + "auditor": { + ("conversation", "read", "all"), + ("agent", "read", "all"), + ("system_config", "read", "all"), + ("audit_log", "read", "all"), + }, + + # 超级管理员 — 全权限 + "admin": { + ("*", "*", "all"), # 通配符,表示所有 (resource, action, all) + }, +} + + +# 角色元数据(显示名 + 描述) +ROLE_METADATA: Dict[str, Dict[str, str]] = { + "user": { + "display_name": "普通员工", + "description": "提交工单、查看自己的会话", + "is_default": "true", + }, + "agent": { + "display_name": "IT 坐席", + "description": "处理分配给自己的会话,可读所有未分配会话", + "is_default": "false", + }, + "team_lead": { + "display_name": "团队主管", + "description": "管理本部门坐席,看本部门所有会话", + "is_default": "false", + }, + "auditor": { + "display_name": "审计员", + "description": "只读跨部门数据,合规审计专用", + "is_default": "false", + }, + "admin": { + "display_name": "超级管理员", + "description": "全权限,需 MFA 二次验证执行高危操作", + "is_default": "false", + }, +} + + +def permissions_to_strings(perms: Set[Tuple[str, str, str]]) -> List[str]: + """把权限元组集合转字符串列表(用于存 JSON)。""" + return [f"{r}:{a}:{s}" for (r, a, s) in sorted(perms)] + + +def strings_to_permissions(items: List[str]) -> Set[Tuple[str, str, str]]: + """把字符串列表(从 JSON 读)转回元组集合。""" + result = set() + for item in items or []: + parts = item.split(":") + if len(parts) == 3: + result.add((parts[0], parts[1], parts[2])) + return result + + +def check_permission( + user_roles: List[str], + user_permissions: Dict[str, List[str]], + required_resource: str, + required_action: str, + required_scope: str = "own", +) -> bool: + """检查用户是否拥有所需权限(细粒度)。 + + 规则: + 1. 用户所有角色中,任一角色的 permissions 包含所需权限 → 通过 + 2. admin 角色拥有 *:*:all → 永远通过 + 3. scope 比较: own < department < all (更高的 scope 满足更低的) + 例: 用户有 department 权限, 申请 own → 通过 + 用户有 all 权限, 申请 department → 通过 + + Args: + user_roles: 用户的角色列表(角色名) + user_permissions: {role_name: [perm_string]} 角色权限字典 + required_resource: 所需资源 + required_action: 所需操作 + required_scope: 所需数据范围(own/department/all) + + Returns: + bool: 是否通过 + """ + SCOPE_RANK = {"own": 1, "department": 2, "all": 3} + required_rank = SCOPE_RANK.get(required_scope, 1) + + for role in user_roles: + perms = strings_to_permissions(user_permissions.get(role, [])) + for (r, a, s) in perms: + # 1. admin 通配符 + if r == "*" and a == "*" and s == "all": + return True + + # 2. 资源/操作必须精确匹配(通配符不向下展开,避免误授权) + if r != required_resource or a != required_action: + continue + + # 3. scope 满足"≥"即可(更高的 scope 满足更低的) + actual_rank = SCOPE_RANK.get(s, 0) + if actual_rank >= required_rank: + return True + + return False + + +def get_role_default_permissions(role_name: str) -> List[str]: + """获取角色的默认权限列表(用于种子数据初始化)。""" + perms = ROLE_PERMISSIONS.get(role_name, set()) + return permissions_to_strings(perms) + + +# 资源/操作/范围的合法值(用于前端下拉框 + 后端校验) +VALID_RESOURCES = ["conversation", "agent", "system_config", "audit_log"] +VALID_ACTIONS = ["read", "create", "update", "delete"] +VALID_SCOPES = ["own", "department", "all"] + + +def validate_permission_string(perm: str) -> bool: + """校验权限字符串格式是否合法。 + + 例: "conversation:read:all" → True + "foo:bar:baz" → False + """ + parts = perm.split(":") + if len(parts) != 3: + return False + r, a, s = parts + # 资源: 支持通配符 * 或合法值 + if r != "*" and r not in VALID_RESOURCES: + return False + # 操作: 支持通配符 * 或合法值 + if a != "*" and a not in VALID_ACTIONS: + return False + # 范围: 不支持通配符,必须是合法值 + if s not in VALID_SCOPES: + return False + return True diff --git a/backend/scripts/gen_admin_token.py b/backend/scripts/gen_admin_token.py new file mode 100644 index 0000000..1419561 --- /dev/null +++ b/backend/scripts/gen_admin_token.py @@ -0,0 +1,56 @@ +"""v4 - 最干净的版本,无中文 docstring,纯 ASCII,堡垒机粘贴不会破坏。 +""" +import asyncio +import os +import sys +import traceback + +os.chdir("/app") +sys.path.insert(0, "/app") + +import redis.asyncio as aioredis + +print("[DEBUG] REDIS_URL env =", repr(os.environ.get("REDIS_URL"))) + +try: + from app.config import settings + print("[DEBUG] settings.redis_url =", repr(settings.redis_url)) +except Exception as e: + print("[ERROR] import settings:", e) + traceback.print_exc() + sys.exit(1) + +REDIS_URL = os.environ.get("REDIS_URL") or settings.redis_url +print("[DEBUG] using REDIS_URL =", repr(REDIS_URL)) + + +async def main(): + redis = aioredis.from_url(REDIS_URL, protocol=2, decode_responses=True) + try: + await redis.ping() + print("[DEBUG] redis ping OK") + except Exception as e: + print("[ERROR] redis ping failed:", e) + traceback.print_exc() + await redis.close() + sys.exit(2) + + from app.services.token_service import TokenService + svc = TokenService(redis) + token = await svc.create_token( + employee_id="dev-admin-001", + name="admin", + roles=["admin"], + department="IT", + login_source="prod-cli", + ) + print("ADMIN_TOKEN=" + token) + await redis.close() + + +try: + asyncio.run(main()) +except Exception as e: + print("[FATAL]", e) + traceback.print_exc() + sys.exit(99) diff --git a/backend/scripts/gen_upload_payload.py b/backend/scripts/gen_upload_payload.py new file mode 100644 index 0000000..6f45b9c --- /dev/null +++ b/backend/scripts/gen_upload_payload.py @@ -0,0 +1,89 @@ +""" +准备分段 base64 payload,让 jumpserver 终端拼装并写入 /tmp/xxx.py + +策略: +1. 在本机把 2 个 .py 转 base64 +2. 按 N=400 字符一段切分(终端粘贴安全长度) +3. 生成一段 shell 脚本,内容是: + cat > /tmp/auth_qrcode.py.b64 << 'B64_EOF' + 段1 + 段2 + ... + B64_EOF + base64 -d /tmp/auth_qrcode.py.b64 > /tmp/auth_qrcode.py + (同理 qrcode_service.py) +4. 把这个脚本写到 webcli_output 目录,用 jumpserver 终端 cat 出来 +""" +import base64 +import re +from pathlib import Path + +UPLOAD_DIR = Path(r"C:\Users\simon\.workbuddy\skills\jumpserver-automation-shareable\scripts\webcli_output") + +files = [ + (UPLOAD_DIR / "auth_qrcode.py", "auth_qrcode.py"), + (UPLOAD_DIR / "qrcode_service.py", "qrcode_service.py"), +] + +# jumpserver terminal 一次粘贴安全长度: ~500 字符 +# 留余量,按 400 字符切 +CHUNK_SIZE = 400 + +def shell_escape(s): + """shell 单引号字符串转义""" + return s.replace("'", "'\\''") + +def make_upload_script(src_path: Path, dest_name: str, chunk_size=CHUNK_SIZE) -> str: + """生成上传用的 shell 脚本: base64 分段 + 拼装 + 解码""" + content = src_path.read_bytes() + b64 = base64.b64encode(content).decode("ascii") + chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)] + + lines = [] + # 1. 清空 + lines.append(f"rm -f /tmp/{dest_name}.b64 /tmp/{dest_name}") + # 2. 写 base64 分段(每段用 echo >> 追加,避免 heredoc 卡住) + for i, chunk in enumerate(chunks): + lines.append(f"echo -n '{chunk}' >> /tmp/{dest_name}.b64") + # 3. base64 -d 还原 + lines.append(f"base64 -d /tmp/{dest_name}.b64 > /tmp/{dest_name}") + # 4. 验证大小 + lines.append(f"ls -la /tmp/{dest_name} && wc -c /tmp/{dest_name} && head -c 100 /tmp/{dest_name}") + # 5. 清理 b64 + lines.append(f"rm -f /tmp/{dest_name}.b64") + + return "\n".join(lines) + + +# 生成每个文件的上传脚本 +combined = [] +combined.append("#!/bin/bash") +combined.append("# Auto-generated upload script (copy each line to jumpserver terminal)") +combined.append(f"# Generated at: {Path(__file__).name}") +combined.append("") +combined.append("set -e") +combined.append("") + +for src, name in files: + if not src.exists(): + print(f"❌ {src} not found") + continue + combined.append(f"\n# ===== {name} ({src.stat().st_size} bytes) =====") + script = make_upload_script(src, name) + combined.append(script) + +combined.append("") +combined.append('echo ""') +combined.append('echo "=== All files uploaded ==="') +combined.append("ls -la /tmp/auth_qrcode.py /tmp/qrcode_service.py") + +output_path = UPLOAD_DIR / "upload_files.sh" +output_path.write_text("\n".join(combined), encoding="utf-8") +print(f"✅ Generated: {output_path}") +print(f" Total lines: {len(combined)}") +print(f" Total bytes: {output_path.stat().st_size}") +print() +print("📋 用法:") +print(" 1. 在 jumpserver 终端跑: cd /tmp/") +print(" 2. 把 upload_files.sh 内容逐行粘贴(用 jumpserver 终端 '粘贴'功能)") +print(" 3. 或者更稳: 复制整个脚本内容到 jumpserver 终端(右键粘贴),回车执行") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9d372ef..a1b74be 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,6 +8,35 @@ # 4. 测试用数据库会话 # ============================================================================= +# --------------------------------------------------------------------------- +# Windows GBK 兼容补丁: 强制 slowapi/starlette 用 UTF-8 读 .env +# 原因: slowapi 0.1.9 内部用 starlette.config.Config 读 .env,默认 encoding +# 走 locale.getpreferredencoding() (Windows=GBK)。backend/.env 是 UTF-8 +# 含中文,GBK 解码失败 → UnicodeDecodeError,pytest 卡死。 +# 修法: 替换 _read_file 强制 utf-8。生产 Linux 不受影响。 +# 详见 [[conftest-gbk-env-patch]] +# --------------------------------------------------------------------------- +import starlette.config as _starlette_config +import io as _io + + +def _patched_read_file(self, env_file): + """强制 utf-8 编码读 .env,绕开 Windows GBK 默认值。""" + if not env_file: + return {} + try: + with open(env_file, encoding="utf-8") as f: + return { + line.split("=", 1)[0].strip(): line.split("=", 1)[1].strip() + for line in f.readlines() + if line.strip() and not line.startswith("#") + } + except FileNotFoundError: + return {} + + +_starlette_config.Config._read_file = _patched_read_file + import asyncio import uuid from datetime import datetime diff --git a/frontend-admin/src/router/index.ts b/frontend-admin/src/router/index.ts index fdfc915..5dd9eb1 100644 --- a/frontend-admin/src/router/index.ts +++ b/frontend-admin/src/router/index.ts @@ -51,6 +51,13 @@ const routes = [ component: () => import('@/views/Roles.vue'), meta: { title: '角色管理', requiresAuth: true }, }, + { + // v0.7.1 task #91 — RBAC 细粒度权限矩阵可视化 + path: 'permissions-matrix', + name: 'PermissionsMatrix', + component: () => import('@/views/PermissionsMatrix.vue'), + meta: { title: '权限矩阵', requiresAuth: true }, + }, { path: 'integrations', name: 'Integrations', diff --git a/frontend-admin/src/views/PermissionsMatrix.vue b/frontend-admin/src/views/PermissionsMatrix.vue new file mode 100644 index 0000000..ec4c490 --- /dev/null +++ b/frontend-admin/src/views/PermissionsMatrix.vue @@ -0,0 +1,433 @@ + + + + + + + diff --git a/frontend-portal/src/composables/useWeChatWorkSSO.ts b/frontend-portal/src/composables/useWeChatWorkSSO.ts new file mode 100644 index 0000000..27306bc --- /dev/null +++ b/frontend-portal/src/composables/useWeChatWorkSSO.ts @@ -0,0 +1,111 @@ +// ============================================================================= +// 企微IT智能服务台 — 企微 SSO UA 检测 composable (v0.7.1 task #85) +// ============================================================================= +// 解决问题: v0.7.0 hotfix1 用户反馈"企微工作台进入应用也要扫码"。 +// +// 用法: +// const sso = useWeChatWorkSSO() +// if (sso.isWeChatWork()) { +// window.location.href = sso.buildInitUrl('/itdesk/') +// } +// +// 设计原则: +// 1. UA 检测: MicroMessenger / wxwork / wxwork.* 都算企微浏览器 +// 2. 静默授权: scope=snsapi_base (用户无感,直接拿到 userid) +// 3. 降级策略: 非企微浏览器保留原 QR 扫码流程 +// ============================================================================= + +export type SSOInitOptions = { + /** 登录成功后跳转路径,默认 /itdesk/ */ + next?: string +} + +/** + * 企微 UA 检测 + SSO URL 生成 composable + */ +export function useWeChatWorkSSO() { + /** + * 检测当前是否在企微浏览器中 + * + * 匹配的 UA 关键字: + * - MicroMessenger: 微信内置浏览器(用户侧) + * - wxwork: 企业微信内置浏览器(企业侧,最常见) + * - wxwork/.*: 企微版本号 + * - DingTalk: 钉钉(预留,暂不实现) + * + * 参考文档: https://developer.work.weixin.qq.com/document/path/91484 + */ + function isWeChatWork(): boolean { + const ua = navigator.userAgent || '' + return /MicroMessenger/i.test(ua) || /wxwork/i.test(ua) || /\bDingTalk\b/i.test(ua) + } + + /** + * 构建 SSO 初始化 URL + * + * 后端 /api/auth_wecom/sso/init 会: + * 1. 生成 state 存 Redis (5 分钟 TTL, 防 CSRF) + * 2. 拼企微 OAuth2 授权 URL + * 3. 302 跳转到企微授权页 + * + * 企微授权页会: + * 1. 用户授权(静默, snsapi_base) + * 2. 回调 /api/auth_wecom/sso/callback?code=...&state=... + * 3. 后端用 code 换 userid, 查角色, 生成 SSO token + * 4. 302 跳转到 next + ?sso_token=xxx + * + * @example + * window.location.href = buildInitUrl('/itdesk/') + */ + function buildInitUrl(next: string = '/itdesk/'): string { + const params = new URLSearchParams({ next }) + // 用相对路径走 nginx 反代(开发环境走 vite proxy) + return `/api/auth_wecom/sso/init?${params.toString()}` + } + + /** + * 用 SSO token 换取用户身份(一次性 token, 用完即删) + * + * 后端会返回 { user_id, name, role } + * 前端用此身份调 portal.getRoles() 或写入 store + */ + async function verifyToken(ssoToken: string): Promise<{ + user_id: string + name: string + role: string + } | null> { + try { + const apiBase = import.meta.env.VITE_API_BASE || '/api' + const resp = await fetch(`${apiBase}/auth_wecom/sso/verify?sso_token=${encodeURIComponent(ssoToken)}`) + const data = await resp.json() + if (data?.code === 0 && data.data) { + return data.data + } + return null + } catch (e) { + console.error('SSO verify 失败:', e) + return null + } + } + + /** + * 便捷函数: 如果是企微浏览器则跳转 SSO, 否则返回 false + * + * 用法: + * if (tryAutoSSO({ next: '/itdesk/' })) return + * // 降级到 QR 扫码 + */ + function tryAutoSSO(options: SSOInitOptions = {}): boolean { + if (!isWeChatWork()) return false + const url = buildInitUrl(options.next) + window.location.href = url + return true + } + + return { + isWeChatWork, + buildInitUrl, + verifyToken, + tryAutoSSO, + } +} \ No newline at end of file diff --git a/frontend-portal/src/views/PortalSelect.vue b/frontend-portal/src/views/PortalSelect.vue index 00f1625..1181be2 100644 --- a/frontend-portal/src/views/PortalSelect.vue +++ b/frontend-portal/src/views/PortalSelect.vue @@ -128,6 +128,9 @@ import { usePortalStore } from '@/stores/portal' import { storeToRefs } from 'pinia' import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue' import apiClient from '@/api/index' +import { useWeChatWorkSSO } from '@/composables/useWeChatWorkSSO' + +const sso = useWeChatWorkSSO() // 获取 Portal Store const portalStore = usePortalStore() @@ -142,12 +145,57 @@ const selectedRole = ref(null) /** * 初始化门户会话(可重入) - * 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息 + * 流程: + * 1. SSO 回调(企微浏览器走 SSO 才有 sso_token 参数)→ verifyToken → 走原流程 + * 2. 企微浏览器但没拿到 sso_token → 主动 init SSO(走企微 OAuth2) + * 3. OAuth2 回调(普通浏览器走老 QR 流程,URL 中有 code 参数) + * 4. Token 跳转(从其他端跳过来,URL 中有 token 参数) + * 5. 本地缓存 → 没登录 → 触发 OAuth 或 dev Mock */ async function initPortalSession(): Promise { const urlParams = new URLSearchParams(window.location.search) const token = urlParams.get('token') const code = urlParams.get('code') + const ssoToken = urlParams.get('sso_token') + + // 0a. SSO 回调:URL 中有 sso_token 参数 + // 流程: 企微浏览器 → init SSO → 企微授权 → callback 写入 token → 跳回 ?sso_token=xxx + // → 前端 verifyToken(一次性)→ 写 portal store → 重走原加载流程 + if (ssoToken) { + loading.value = true + try { + const verifyResult = await sso.verifyToken(ssoToken) + if (!verifyResult) { + error.value = 'SSO token 已过期,请重新进入' + return + } + // verifyToken 返回的 role 用于判断下一步 next + // 写 portal store(后续 fetchUserInfo 会覆盖,这里只用于决定跳哪儿) + // 清除 URL 中的 sso_token 参数,避免刷新时重复消费 + window.history.replaceState({}, '', window.location.pathname) + // 继续往下走: 走 isAuthenticated 检测 + console.log('[SSO] 验证成功:', verifyResult) + } catch (err: any) { + console.error('SSO verify 失败:', err) + error.value = 'SSO 验证失败: ' + (err?.message || '未知错误') + return + } finally { + loading.value = false + } + } + + // 0b. 企微浏览器(还没触发过 SSO)→ 主动 init + // 优先级高于老 QR 流程,因为企微浏览器走 QR 会被嫌麻烦 + // 例外: dev 模式 + 普通 Chrome 不走 SSO(开发用 Mock) + if (!ssoToken && !token && !code && sso.isWeChatWork()) { + const isDev = import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === 'true' + if (!isDev) { + console.log('[SSO] 企微浏览器, 跳 SSO init') + const url = sso.buildInitUrl('/itdesk/') + window.location.href = url + return // 跳走,代码不执行 + } + } // 1. 企微 OAuth2 回调:URL 中有 code 参数 if (code && !token) {