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:
@@ -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/
|
||||
|
||||
+36
-1
@@ -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
|
||||
|
||||
|
||||
+28
-9
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
+23
-17
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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=["审计日志"])
|
||||
|
||||
@@ -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 应急页身份检测配置
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
+9
-4
@@ -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"
|
||||
|
||||
@@ -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 加密存储,不存储明文密码
|
||||
|
||||
@@ -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})>"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 字符串,供前端 <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:
|
||||
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 终端(右键粘贴),回车执行")
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<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> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user