feat(v0.7.1): P0 修复 + 企微 SSO + RBAC 细粒度 + audit_log

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 坑 + 回滚预案
This commit is contained in:
Simon
2026-06-22 17:38:47 +08:00
parent 2e6ac0f0ab
commit 78f60c6857
30 changed files with 2928 additions and 49 deletions
+4
View File
@@ -106,6 +106,10 @@ it_smart_desk.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Base64 编码凭据(部署脚本用,含 admin token / 证书)
# 2026-06-22: gen_admin_token.b64 含生产 admin token,不能入仓
*.b64
# pytest / 临时 # pytest / 临时
.pytest_cache/ .pytest_cache/
/tmp/ /tmp/
+36 -1
View File
@@ -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 ## [v0.7.0] - 2026-06-21
+28 -9
View File
@@ -4,26 +4,31 @@
> >
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。 > 📝 **更新规则**:每次 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)。 **v0.7.0 + hotfix1 已上线,但生产有 2 个真 bug**:
**当前主线**:**等用户执行 `docs/DEPLOY-QUICK-v0.7.0.md` 6 步部署 + 35 项 E2E 验收**(deadline 今晚 7:00 → 现已凌晨,部署后看观察期)。 - 🐛 **员工/坐席扫码登录报错**(QR 码生成后,/api/auth_qrcode/scan 失败)
**待用户手动操作**:跑生产 migration + 应用 nginx access_log 脱敏(代码 push 已完成 ✅)。 - 🐛 **管理员 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 | | #77 | v0.7.1 范围规划 + CHANGELOG | 拆 6-8 个子 task,出 v0.7.1-dev 分支 | 拍板 P0/P1 优先级 | task #78/79/80 拿到 owner |
| #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 步 | 备份目录不存在 |
--- ---
@@ -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 凌晨(自动跑批) ### 2026-06-22 凌晨(自动跑批)
-**#23** `~/Downloads/patch1*` 已删(`backend-patch1-ws-fix.tar.gz` 21KB + `backend-v070-patch1.tar.gz` 63KB) -**#23** `~/Downloads/patch1*` 已删(`backend-patch1-ws-fix.tar.gz` 21KB + `backend-v070-patch1.tar.gz` 63KB)
+190
View File
@@ -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)
+595
View File
@@ -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" # 悬空镜像(<none>:<none>)
# 期望: 如果有 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` 确认。
@@ -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 二次验证'
)
)
@@ -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")
+4 -2
View File
@@ -294,8 +294,10 @@ async def admin_unbind_agent_otp(
if not agent: if not agent:
raise AppException(1001, "坐席不存在") raise AppException(1001, "坐席不存在")
agent.otp_secret = None agent.mfa_secret = None
agent.otp_enabled = 0 agent.mfa_enabled = False
agent.mfa_bound_at = None
agent.mfa_last_verified_at = None
agent.updated_at = datetime.now() agent.updated_at = datetime.now()
db.add(agent) db.add(agent)
await db.flush() await db.flush()
+129
View File
@@ -382,3 +382,132 @@ async def delete_mapping_rule(
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}") logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
return success_response(message="映射规则删除成功") 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,
})
+23 -17
View File
@@ -257,8 +257,9 @@ async def agent_login(
await db.flush() await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}") logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. OTP 二次验证(admin 角色且已绑定 OTP # 2. MFA 二次验证(admin 角色且已绑定 MFA
if agent.role == "admin" and agent.otp_enabled == 1: # v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled
if agent.role == "admin" and agent.mfa_enabled:
if not body.otp_code: if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记 # 需要 OTP 验证,返回 require_otp 标记
return success_response(data={ return success_response(data={
@@ -269,7 +270,7 @@ async def agent_login(
}) })
else: else:
# 验证 OTP 码 # 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret) totp = pyotp.TOTP(agent.mfa_secret)
if not totp.verify(body.otp_code, valid_window=1): if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入") raise AppException(1006, "OTP验证码错误,请重新输入")
@@ -414,15 +415,16 @@ async def bind_agent_otp(
Dict: 二维码图片(base64)和密钥 Dict: 二维码图片(base64)和密钥
""" """
try: 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: else:
# 生成新密钥 # 生成新密钥
secret = pyotp.random_base32() secret = pyotp.random_base32()
agent.otp_secret = secret agent.mfa_secret = secret
# otp_enabled 保持 0,等待首次验证后启用 # mfa_enabled 保持 False,等待首次验证后启用
db.add(agent) db.add(agent)
await db.flush() await db.flush()
totp = pyotp.TOTP(secret) totp = pyotp.TOTP(secret)
@@ -439,11 +441,11 @@ async def bind_agent_otp(
qr.save(buffer, format="PNG") qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode() 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={ return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}", "qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.otp_secret, "secret": agent.mfa_secret,
}) })
except AppException: except AppException:
@@ -475,16 +477,18 @@ async def verify_agent_otp(
result = await db.execute(stmt) result = await db.execute(stmt)
agent = result.scalars().first() agent = result.scalars().first()
if not agent or not agent.otp_secret: if not agent or not agent.mfa_secret:
raise AppException(1008, "请先绑定OTP") raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码 # 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret) totp = pyotp.TOTP(agent.mfa_secret)
if not totp.verify(body.otp_code, valid_window=1): if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误") raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 OTP # 验证成功,启用 MFA
agent.otp_enabled = 1 agent.mfa_enabled = True
agent.mfa_bound_at = datetime.now()
agent.mfa_last_verified_at = datetime.now()
agent.updated_at = datetime.now() agent.updated_at = datetime.now()
db.add(agent) db.add(agent)
await db.flush() await db.flush()
@@ -492,7 +496,7 @@ async def verify_agent_otp(
logger.info(f"OTP验证成功并启用: agent={agent.user_id}") logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={ return success_response(data={
"otp_enabled": True, "mfa_enabled": True,
"message": "OTP验证成功,已启用", "message": "OTP验证成功,已启用",
}) })
@@ -510,15 +514,17 @@ async def unbind_agent_otp(
): ):
"""解绑 OTP。 """解绑 OTP。
解绑后 otp_secret 和 otp_enabled 都清空。 解绑后 mfa_secret 和 mfa_enabled 都清空。
需要管理员操作。 需要管理员操作。
Returns: Returns:
Dict: 解绑结果 Dict: 解绑结果
""" """
try: try:
agent.otp_secret = None agent.mfa_secret = None
agent.otp_enabled = 0 agent.mfa_enabled = False
agent.mfa_bound_at = None
agent.mfa_last_verified_at = None
agent.updated_at = datetime.now() agent.updated_at = datetime.now()
db.add(agent) db.add(agent)
await db.flush() await db.flush()
+75
View File
@@ -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"],
})
+1
View File
@@ -91,6 +91,7 @@ async def create_qrcode(
return success_response(data={ return success_response(data={
"ticket": result["ticket"], "ticket": result["ticket"],
"qrcode_url": result["qrcode_url"], "qrcode_url": result["qrcode_url"],
"qrcode_png_base64": result["qrcode_png_base64"],
"expires_in": result["expires_in"], "expires_in": result["expires_in"],
"expires_at": result["expires_at"].isoformat(), "expires_at": result["expires_at"].isoformat(),
}) })
+228
View File
@@ -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,
}
+13
View File
@@ -207,3 +207,16 @@ api_router.include_router(mfa_router, tags=["MFA二次认证"])
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底) # MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA # POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
api_router.include_router(mfa_admin_router, tags=["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=["审计日志"])
+10
View File
@@ -124,6 +124,16 @@ class Settings(BaseSettings):
# 设备申请审批模板ID(在企微审批应用设置中获取) # 设备申请审批模板ID(在企微审批应用设置中获取)
approval_template_device: str = "" approval_template_device: str = ""
# ----------------------------------------------------------------------
# v0.7.1 企微 SSO 入口配置 (task #85)
# ----------------------------------------------------------------------
# 是否启用企微 SSOtrue = 优先用企微 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 应急页身份检测配置 # v0.5.4 应急页身份检测配置
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
+71
View File
@@ -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
+104
View File
@@ -284,6 +284,110 @@ def require_admin(func):
return require_role("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 # 高危操作 OTP 守卫依赖(Phase 1.3 task #19
# ============================================================================= # =============================================================================
+9 -4
View File
@@ -210,7 +210,12 @@ async def _init_default_data():
# 5. 初始化软件下载入口 # 5. 初始化软件下载入口
await _init_software_downloads(db, SoftwareDownload) 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 # 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true': if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
await _init_demo_conversations(db) await _init_demo_conversations(db)
@@ -752,7 +757,8 @@ def create_app() -> FastAPI:
""" """
try: try:
# 检查数据库 # 检查数据库
from app.database import engine from app.database import _get_engine
engine = _get_engine()
async with engine.connect() as conn: async with engine.connect() as conn:
await conn.execute(text("SELECT 1")) await conn.execute(text("SELECT 1"))
db_status = "ok" db_status = "ok"
@@ -761,8 +767,7 @@ def create_app() -> FastAPI:
try: try:
# 检查 Redis # 检查 Redis
from app.config import get_settings from app.config import settings
settings = get_settings()
redis_client = settings.create_redis_client() redis_client = settings.create_redis_client()
await redis_client.ping() await redis_client.ping()
redis_status = "ok" redis_status = "ok"
+4 -15
View File
@@ -123,21 +123,10 @@ class Agent(Base):
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)", comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
) )
# OTP密钥(用于TOTP动态码验证,为空表示未绑定) # v0.7.1: 删除 otp_secret / otp_enabled 字段
otp_secret: Mapped[str] = mapped_column( # 原因: 与下方 mfa_secret / mfa_enabled 完全重复(都是 TOTP secret)
String(32), # 旧 OTP 字段只用于高危操作前的二次验证,mfa 字段已涵盖该用途
nullable=True, # 迁移策略: alembic 010 改为 DROP COLUMN otp_secret, otp_enabled
default=None,
comment="OTP密钥(Base32编码)",
)
# OTP是否启用(admin角色强制启用)
otp_enabled: Mapped[bool] = mapped_column(
Integer,
nullable=False,
default=0,
comment="OTP是否启用(0=否, 1=是)",
)
# 本地密码哈希(可选,用于本地密码认证) # 本地密码哈希(可选,用于本地密码认证)
# 使用 bcrypt 加密存储,不存储明文密码 # 使用 bcrypt 加密存储,不存储明文密码
+130
View File
@@ -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"<AuditLog(action={self.action}, employee={self.employee_id}, result={self.result})>"
+137
View File
@@ -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,
}
+19
View File
@@ -11,14 +11,18 @@
# 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果 # 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果
# ============================================================================= # =============================================================================
import base64
import json import json
import logging import logging
import os import os
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import qrcode
import redis.asyncio as aioredis import redis.asyncio as aioredis
from app.config import settings from app.config import settings
@@ -140,10 +144,25 @@ class QrcodeService:
return { return {
"ticket": ticket, "ticket": ticket,
"qrcode_url": qrcode_url, "qrcode_url": qrcode_url,
"qrcode_png_base64": self._render_qrcode_png(qrcode_url),
"expires_in": TICKET_TTL_SECONDS, "expires_in": TICKET_TTL_SECONDS,
"expires_at": expires_at, "expires_at": expires_at,
} }
@staticmethod
def _render_qrcode_png(url: str) -> str:
"""把 url 编成 PNG 并返回 base64 字符串,供前端 <img :src="data:image/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: def _build_oauth_url(self, ticket: str) -> str:
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。 """拼接企微 OAuth2 授权 URL(供前端生成二维码)。
+206
View File
@@ -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
+56
View File
@@ -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)
+89
View File
@@ -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 终端(右键粘贴),回车执行")
+29
View File
@@ -8,6 +8,35 @@
# 4. 测试用数据库会话 # 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 asyncio
import uuid import uuid
from datetime import datetime from datetime import datetime
+7
View File
@@ -51,6 +51,13 @@ const routes = [
component: () => import('@/views/Roles.vue'), component: () => import('@/views/Roles.vue'),
meta: { title: '角色管理', requiresAuth: true }, 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', path: 'integrations',
name: 'Integrations', name: 'Integrations',
@@ -0,0 +1,433 @@
<!--
=============================================================================
企微IT智能服务台 RBAC 权限矩阵可视化页 (v0.7.1 task #91)
=============================================================================
说明: GET /api/admin/roles/permissions/matrix,渲染 5 角色 × 4 资源 ×
4 操作 × 3 范围的完整矩阵表格给管理员一眼看到角色权限边界
特性:
- = 资源:操作 (16 ,4 资源 × 4 操作)
- = 角色 (5 ,user/agent/team_lead/auditor/admin)
- 单元格颜色:
· own 权限 浅蓝
· department 权限 蓝色
· all 权限 深蓝
· 无权限 灰色
- 顶部 scope 图例 + 角色筛选 + 资源筛选
- 支持导出 CSV(复制权限矩阵给合规审计)
-->
<template>
<div class="matrix-page">
<div class="page-header">
<div>
<div class="page-title">RBAC 权限矩阵</div>
<div class="page-desc">
5 角色 × 4 资源 × 4 操作 × 3 数据范围, 240 个权限点 (5 × 16 × 3)
</div>
</div>
<div class="header-actions">
<el-select
v-model="selectedRole"
placeholder="筛选角色"
size="default"
clearable
style="width: 180px"
>
<el-option
v-for="r in matrixData.roles"
:key="r.name"
:label="r.display_name + ' (' + r.name + ')'"
:value="r.name"
/>
</el-select>
<el-select
v-model="selectedResource"
placeholder="筛选资源"
size="default"
clearable
style="width: 180px"
>
<el-option
v-for="r in matrixData.resources"
:key="r"
:label="r"
:value="r"
/>
</el-select>
<el-button @click="exportCsv">
<el-icon><Download /></el-icon>
导出 CSV
</el-button>
<el-button @click="loadMatrix" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-skeleton v-if="loading && !hasData" :rows="10" animated />
<template v-else-if="hasData">
<!-- scope 图例 -->
<div class="legend">
<span class="legend-title">数据范围:</span>
<span class="legend-item legend-own">own 自己的</span>
<span class="legend-item legend-dept">department 部门的</span>
<span class="legend-item legend-all">all 全部</span>
<span class="legend-item legend-none">无权限</span>
</div>
<!-- 权限矩阵表 -->
<el-table
:data="filteredMatrixRows"
stripe
size="small"
:empty-text="loading ? '加载中...' : '无数据'"
class="matrix-table"
>
<el-table-column label="资源" prop="resource" width="140" fixed>
<template #default="{ row }">
<span class="resource-label">{{ row.resource }}</span>
</template>
</el-table-column>
<el-table-column label="操作" prop="action" width="100" fixed>
<template #default="{ row }">
<el-tag size="small" :type="getActionTagType(row.action)">
{{ row.action }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-for="role in filteredRoles"
:key="role.name"
:label="role.display_name"
min-width="140"
align="center"
>
<template #header>
<div class="role-header">
<div class="role-name">{{ role.display_name }}</div>
<div class="role-id">{{ role.name }}</div>
</div>
</template>
<template #default="{ row }">
<div class="perm-cell">
<el-tag
v-for="scope in getScopesForRole(row, role.name)"
:key="scope"
size="small"
:class="['scope-tag', `scope-${scope}`]"
>
{{ scope }}
</el-tag>
<span v-if="getScopesForRole(row, role.name).length === 0" class="no-perm"></span>
</div>
</template>
</el-table-column>
</el-table>
<!-- 角色详情 -->
<div class="role-detail">
<div class="section-title">角色详情</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="role in filteredRoles"
:key="role.name"
:label="role.display_name + ' (' + role.name + ')'"
>
<div class="role-meta">
<div class="role-desc">{{ role.description || '—' }}</div>
<div class="role-perm-count">
权限数: <b>{{ role.permission_count }}</b>
<el-tag v-if="role.is_default" type="info" size="small" style="margin-left: 8px">默认</el-tag>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
<el-empty v-else description="加载失败, 请检查网络或权限" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Refresh } from '@element-plus/icons-vue'
import apiClient from '@/api/index'
// ---- 类型定义 ----
interface Role {
name: string
display_name: string
description: string
is_default: boolean
permission_count: number
}
interface MatrixData {
roles: Role[]
resources: string[]
actions: string[]
scopes: string[]
matrix: Record<string, Record<string, boolean>>
}
interface MatrixRow {
resource: string
action: string
key: string // "resource:action"
}
// ---- 状态 ----
const loading = ref(false)
const matrixData = ref<MatrixData>({
roles: [],
resources: [],
actions: [],
scopes: [],
matrix: {},
})
const selectedRole = ref<string>('')
const selectedResource = ref<string>('')
const hasData = computed(() => matrixData.value.roles.length > 0)
const filteredRoles = computed(() => {
if (!selectedRole.value) return matrixData.value.roles
return matrixData.value.roles.filter((r) => r.name === selectedRole.value)
})
// 矩阵行: 资源 × 操作 笛卡尔积
const matrixRows = computed<MatrixRow[]>(() => {
const rows: MatrixRow[] = []
for (const r of matrixData.value.resources) {
for (const a of matrixData.value.actions) {
rows.push({ resource: r, action: a, key: `${r}:${a}` })
}
}
return rows
})
const filteredMatrixRows = computed(() => {
if (!selectedResource.value) return matrixRows.value
return matrixRows.value.filter((row) => row.resource === selectedResource.value)
})
// ---- 方法 ----
async function loadMatrix() {
loading.value = true
try {
const resp = await apiClient.get('/admin/roles/permissions/matrix')
if (resp.data?.code === 0 && resp.data.data) {
matrixData.value = resp.data.data
} else {
ElMessage.error('拉取权限矩阵失败')
}
} catch (e: any) {
console.error('loadMatrix 失败:', e)
ElMessage.error('加载失败: ' + (e?.message || '未知错误'))
} finally {
loading.value = false
}
}
/**
* 拿到角色在 (resource, action) 下的所有 scope (own/department/all)。
* 例: agent 看 conversation:read 时,可能有 own + all 两个 scope。
*/
function getScopesForRole(row: MatrixRow, roleName: string): string[] {
const result: string[] = []
for (const scope of matrixData.value.scopes) {
const key = `${row.resource}:${row.action}:${scope}`
if (matrixData.value.matrix[roleName]?.[key]) {
result.push(scope)
}
}
return result
}
function getActionTagType(action: string): string {
const types: Record<string, string> = {
read: 'info',
create: 'success',
update: 'warning',
delete: 'danger',
}
return types[action] || 'info'
}
function exportCsv() {
// CSV 表头: 资源,操作, <每个角色>
const header = ['resource', 'action', ...filteredRoles.value.map((r) => r.name)]
const lines = [header.join(',')]
for (const row of filteredMatrixRows.value) {
const cells = [row.resource, row.action]
for (const role of filteredRoles.value) {
const scopes = getScopesForRole(row, role.name)
cells.push(scopes.join('|') || '')
}
lines.push(cells.join(','))
}
const csv = lines.join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `rbac-matrix-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('CSV 已导出')
}
onMounted(() => {
loadMatrix()
})
</script>
<style scoped>
.matrix-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #f1f5f9;
}
.page-desc {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.legend {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: rgba(30, 41, 59, 0.5);
border-radius: 8px;
font-size: 13px;
}
.legend-title {
color: #94a3b8;
font-weight: 500;
margin-right: 4px;
}
.legend-item {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.legend-own {
background: #dbeafe;
color: #1e40af;
}
.legend-dept {
background: #93c5fd;
color: #1e3a8a;
}
.legend-all {
background: #1e40af;
color: #fff;
}
.legend-none {
background: #e2e8f0;
color: #64748b;
}
.matrix-table {
margin-bottom: 24px;
}
.resource-label {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #1e293b;
}
.role-header {
text-align: center;
}
.role-name {
font-weight: 600;
color: #1e293b;
}
.role-id {
font-size: 11px;
color: #94a3b8;
font-family: monospace;
margin-top: 2px;
}
.perm-cell {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.scope-tag {
font-size: 10px;
font-family: monospace;
}
.scope-own {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.scope-department {
background: #93c5fd;
color: #1e3a8a;
border-color: #60a5fa;
}
.scope-all {
background: #1e40af;
color: #fff;
border-color: #1e3a8a;
}
.no-perm {
color: #cbd5e1;
font-size: 14px;
}
.role-detail {
margin-top: 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
.role-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.role-desc {
font-size: 13px;
color: #475569;
line-height: 1.4;
}
.role-perm-count {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
</style>
@@ -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,
}
}
+49 -1
View File
@@ -128,6 +128,9 @@ import { usePortalStore } from '@/stores/portal'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue' import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
import apiClient from '@/api/index' import apiClient from '@/api/index'
import { useWeChatWorkSSO } from '@/composables/useWeChatWorkSSO'
const sso = useWeChatWorkSSO()
// 获取 Portal Store // 获取 Portal Store
const portalStore = usePortalStore() const portalStore = usePortalStore()
@@ -142,12 +145,57 @@ const selectedRole = ref<string | null>(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<boolean> { async function initPortalSession(): Promise<boolean> {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token') const token = urlParams.get('token')
const code = urlParams.get('code') 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 参数 // 1. 企微 OAuth2 回调:URL 中有 code 参数
if (code && !token) { if (code && !token) {