Compare commits
6 Commits
v0.7.0
..
v0.7.1-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 78f60c6857 | |||
| 2e6ac0f0ab | |||
| 627f4aa924 | |||
| e47f750b9e | |||
| ffbe01e04d | |||
| e6c85d572e |
@@ -106,6 +106,10 @@ it_smart_desk.db
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Base64 编码凭据(部署脚本用,含 admin token / 证书)
|
||||||
|
# 2026-06-22: gen_admin_token.b64 含生产 admin token,不能入仓
|
||||||
|
*.b64
|
||||||
|
|
||||||
# pytest / 临时
|
# pytest / 临时
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
+36
-1
@@ -139,7 +139,42 @@
|
|||||||
- 📚 文档 - 文档更新
|
- 📚 文档 - 文档更新
|
||||||
- 🛠️ 工具链 - 工具脚本
|
- 🛠️ 工具链 - 工具脚本
|
||||||
|
|
||||||
[未发布]: https://gitea.simon.local/simon/wecom_it_smart_desk/compare/v0.7.0...HEAD
|
[未发布]: https://gitea.simon.local/simon/wecom/wecom_it_smart_desk/compare/v0.7.0...HEAD
|
||||||
|
|
||||||
|
## [v0.7.1] - 2026-06-23(规划中)
|
||||||
|
|
||||||
|
> **决策背景**(2026-06-22):v0.7.0.1-hotfix1(QR 码生成)上线后,生产仍报 2 个 bug:
|
||||||
|
> - 员工/坐席扫码登录报错(`/api/auth_qrcode/scan` 失败)
|
||||||
|
> - 管理员 sxn 登录报错(`agents.otp_secret` 列不存在 — alembic 010 未跑)
|
||||||
|
> 用户决策:**不再修 7.0.1**,直接进 v0.7.1 统一治理。
|
||||||
|
|
||||||
|
### 🔧 修复 (Fixed)
|
||||||
|
|
||||||
|
#### P0 — 登录失败
|
||||||
|
- **管理员 sxn 登录报错**:根因 — alembic 010 `agents.otp_secret` 列未在生产数据库创建
|
||||||
|
- 修复:合并 `otp_secret/otp_enabled`(010)与 `mfa_secret/mfa_enabled`(023)双字段,模型统一引用 `mfa_secret/mfa_enabled`
|
||||||
|
- migration:重写 021_rbac(原文件丢失),统一 010-025 chain
|
||||||
|
- **员工/坐席扫码登录报错**:根因待查(预计 ticket 状态机 / WecomService 初始化 / 高并发 session)
|
||||||
|
- 修复:在 dev 复现,出 patch
|
||||||
|
|
||||||
|
#### P0 — 基础设施
|
||||||
|
- **修 `/api/ready` import error**(原 defer to v0.7.1)
|
||||||
|
- **审计 alembic chain**:`021_rbac` 缺失 / 022-025 chain 错乱,出 `docs/alembic_history_audit.md`
|
||||||
|
|
||||||
|
### 🆕 新增 (Added)
|
||||||
|
|
||||||
|
#### P1 — 体验优化
|
||||||
|
- **企微入口 SSO**(原 v0.7.1+ backlog):识别 WeChat Work User-Agent,自动识别员工身份 + 跳对应端点,扫码登录降级为 fallback
|
||||||
|
|
||||||
|
#### P1 — 权限
|
||||||
|
- **管理后台 RBAC 细粒度角色权限**:5 角色 + 4 资源 + 4 操作 + 3 数据范围
|
||||||
|
|
||||||
|
### 📝 文档 (Documentation)
|
||||||
|
- `docs/DEPLOY-QUICK-v0.7.1.md` — 一键部署操作包(基于 7.0 模板)
|
||||||
|
- `docs/alembic_history_audit.md` — chain 审计报告
|
||||||
|
- `docs/USER-GUIDE-WECOM-SSO.md` — 企微 SSO 用户手册
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.7.0] - 2026-06-21
|
## [v0.7.0] - 2026-06-21
|
||||||
|
|
||||||
|
|||||||
+91
-7
@@ -4,15 +4,23 @@
|
|||||||
>
|
>
|
||||||
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
||||||
|
|
||||||
最后更新:**2026-06-16 11:10**(Claude 自动维护,看板上一次刷新)
|
最后更新:**2026-06-22 下午**(Claude 自动维护,看板本次刷新)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 一句话总览
|
## 🎯 一句话总览
|
||||||
|
|
||||||
**项目状态**:**v0.5.6-dev-tooling 完成**,本地 4 端 dev 链路全通(Mock 企微 OAuth + 3 个新 migration + 1 个 decorator bug 修复)。
|
**v0.7.0 + hotfix1 已上线,但生产有 2 个真 bug**:
|
||||||
**当前主线**:**等用户决策要不要上生产**(生产 3 个 migration + 1 个 bug 修复可上,7 个 dev 改动留在本地)。
|
- 🐛 **员工/坐席扫码登录报错**(QR 码生成后,/api/auth_qrcode/scan 失败)
|
||||||
**待回复**:#83 OTM 是什么 / 跟项目什么关系。
|
- 🐛 **管理员 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
| # | 任务 | 我做什么 | 你做什么 | 完成定义 |
|
| # | 任务 | 我做什么 | 你做什么 | 完成定义 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| #90 | 后端 pytest 测试套件 | 补 token_service / scoring_service 等 | 等结果 | 20+ 测试通过 |
|
| #77 | v0.7.1 范围规划 + CHANGELOG | 拆 6-8 个子 task,出 v0.7.1-dev 分支 | 拍板 P0/P1 优先级 | task #78/79/80 拿到 owner |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,7 +49,21 @@
|
|||||||
| #73 | 修后端文件未真正覆盖 | `yes | cp -f` 路径,部署时偶尔没生效 |
|
| #73 | 修后端文件未真正覆盖 | `yes | cp -f` 路径,部署时偶尔没生效 |
|
||||||
| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 |
|
| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 |
|
||||||
| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) |
|
| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) |
|
||||||
| #83 | 澄清"OTM 跟项目关系" | **我在这等你回答**:OTM 是什么?需要对接吗? |
|
| #83 | 澄清"OTM 跟项目关系" | 已 2026-06-21 决策:走 TOTP+SMS 双引擎(MFA Phase 2 实施) |
|
||||||
|
| 🆕 | v0.7.0 部署 + 35 项 E2E 验收 | 看 `docs/DEPLOY-QUICK-v0.7.0.md` 6 步 + `docs/E2E-CHECKLIST-v0.7.0.md` |
|
||||||
|
| 🆕 | 修 64 pre-existing 测试失败 | Role.data_scope 缺字段 / WecomService DI / test_message_experience 等 |
|
||||||
|
|
||||||
|
## 🟢 P2 / 等用户决策
|
||||||
|
|
||||||
|
| # | 任务 | 卡在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| **🆕 服务器更新?** | 把 v0.7.0 部署到生产(扫码+MFA+高危+4 项 P0) | **等你跑 `DEPLOY-QUICK-v0.7.0.md` 6 步** |
|
||||||
|
| #31 | 推 docker 镜像到生产 registry | 等你确认要走哪条路(自建 Harbor / 阿里云 / 别的) |
|
||||||
|
| #43 | 配置 HTTPS | 等域名备案完成 + 证书到位 |
|
||||||
|
| #53 | 用户在企微验证 /itportal/ | 等你去企微点一点 |
|
||||||
|
| 🆕 #23 | 清理 ~/Downloads/ patch1 包 | 部署观察期后拍板 |
|
||||||
|
| 🆕 #24 | 清理生产 patch1 回滚备份 | 1 周观察期后拍板 |
|
||||||
|
| 🆕 #48 | 收窄 set_real_ip_from 内网地址 | 部署后下一迭代(v1.0 前) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,7 +80,69 @@
|
|||||||
|
|
||||||
## ✅ 最近搞定(给你信心)
|
## ✅ 最近搞定(给你信心)
|
||||||
|
|
||||||
### 2026-06-16(今天)
|
### 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)
|
||||||
|
- ✅ **#41** MkDocs 文档站后台跑起来(`http://127.0.0.1:8765/`,58 个 markdown,Material theme)
|
||||||
|
- ✅ **#58** 38 → 13 backend pytest 失败修复(根因:`conftest` patch 路径错 + `h5_client` fixture 缺 WecomService mock)
|
||||||
|
- ✅ **#49** `/api/ready` defer 到 v0.7.1,backlog 已存 `memory/v0.7.1-backlog-2026-06-22.md`
|
||||||
|
- ✅ 4 个 agent 状态复核:#14/#17/#19/#20 全部合入 main(commit `bf872da` + `f564d0e`),worktree 分支已清
|
||||||
|
- ✅ 集成测试再确认:4 套新测试 70 passed(扫码 13 + MFA 21 + 高危 28 + UUID 8)+ WS 8 passed + 4 xfail = 78 + 4 xfail(跟 merge 报告一致)
|
||||||
|
|
||||||
|
### 2026-06-21(凌晨 1 小时 sprint)
|
||||||
|
|
||||||
|
#### 🆕 v0.7.0 release 收尾(8 个 worktree → main)
|
||||||
|
|
||||||
|
- ✅ **#14 阶段 1.1**:后端 `auth_qrcode.py` 4 端点(create/poll/scan/confirm)
|
||||||
|
- ✅ **#15 阶段 1.2**:前端 `Login.vue` + `QrcodeLogin.vue` 扫码 UI
|
||||||
|
- ✅ **#16 阶段 1.3**:坐席/管理员域名路由分发(`/itagent/` `/itadmin/`)
|
||||||
|
- ✅ **#17 阶段 2.1**:后端 MFA 服务 + pyotp 集成
|
||||||
|
- ✅ **#18 阶段 2.2**:数据库 User MFA 字段 + Alembic migration 023
|
||||||
|
- ✅ **#19 阶段 2.3**:高危操作路由白名单 + 中间件(5 类白名单)
|
||||||
|
- ✅ **#20 阶段 2.4**:前端 MFA UI(绑定 + 验证 + 高危弹窗 + 管理表格)
|
||||||
|
- ✅ **#21 集成测试 + E2E + 培训文档**:E2E-CHECKLIST 176 行 + DEPLOY-QUICK 252 行
|
||||||
|
|
||||||
|
#### 🔐 P0/P1 合规修复(#30)
|
||||||
|
|
||||||
|
- ✅ WS endpoint `missing argument 'request'`(签名 + 8 个回归测试)
|
||||||
|
- ✅ messages.id VARCHAR → UUID(migration 025)
|
||||||
|
- ✅ nginx access_log 脱敏脚本(删 Authorization/Cookie)
|
||||||
|
- ✅ Gitea token 撤销流程已文档化(旧 token 已 revoke,新 token 已签发)
|
||||||
|
|
||||||
|
#### 🐛 测试修复(#32)
|
||||||
|
|
||||||
|
- ✅ wordfilter 1.0.6 API 适配(`Wordfilter()` 实例 + `addWords()` + `blacklisted()`)
|
||||||
|
- ✅ SQLite ARRAY/JSONB 编译补丁(quiz.keywords / themes.palette)
|
||||||
|
- ✅ conftest autouse 业务表清理(feedback 事务隔离)
|
||||||
|
- ✅ h5_client 用 `127.0.0.1` 跳过企微 UA 检测
|
||||||
|
- ✅ wecom mock 默认 name 不覆盖 body.name
|
||||||
|
- ✅ 测试基线:570 ERROR → 470 passed, 4 xfailed, 64 failed
|
||||||
|
|
||||||
|
#### 📦 提交记录
|
||||||
|
|
||||||
|
- `8e748d1` docs: CHANGELOG.md 添加 v0.7.0 release 节
|
||||||
|
- `1255e95` docs: v0.7.0 一键部署操作包
|
||||||
|
- `c33abb6` fix(tests): h5_client UA 检测
|
||||||
|
- `a9b97de` fix(tests): wordfilter API + SQLite 编译补丁 + 事务隔离
|
||||||
|
- `e96fbb2` docs: v0.7.0 E2E 验收清单
|
||||||
|
- `bf872da` feat(merge): 4 个 worktree 合入 main
|
||||||
|
- **tag v0.7.0** 已打
|
||||||
|
|
||||||
|
### 历史(2026-06-16 选重点)
|
||||||
|
|
||||||
#### 🛠️ Dev 环境(本地链路全通)
|
#### 🛠️ Dev 环境(本地链路全通)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
if not agent:
|
||||||
raise AppException(1001, "坐席不存在")
|
raise AppException(1001, "坐席不存在")
|
||||||
|
|
||||||
agent.otp_secret = None
|
agent.mfa_secret = None
|
||||||
agent.otp_enabled = 0
|
agent.mfa_enabled = False
|
||||||
|
agent.mfa_bound_at = None
|
||||||
|
agent.mfa_last_verified_at = None
|
||||||
agent.updated_at = datetime.now()
|
agent.updated_at = datetime.now()
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -382,3 +382,132 @@ async def delete_mapping_rule(
|
|||||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
||||||
|
|
||||||
return success_response(message="映射规则删除成功")
|
return success_response(message="映射规则删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 4. 权限矩阵可视化 (v0.7.1 task #86)
|
||||||
|
# ==========================================================================
|
||||||
|
# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵
|
||||||
|
# 嵌套结构方便前端直接渲染表格:
|
||||||
|
# {
|
||||||
|
# "roles": [{name, display_name, permissions: [string]}],
|
||||||
|
# "resources": [conversation, agent, ...],
|
||||||
|
# "actions": [read, create, update, delete],
|
||||||
|
# "scopes": [own, department, all],
|
||||||
|
# "matrix": {
|
||||||
|
# "agent": { # 角色名
|
||||||
|
# "conversation:read:own": true,
|
||||||
|
# "conversation:read:all": true,
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# ==========================================================================
|
||||||
|
@router.get("/permissions/matrix")
|
||||||
|
async def get_permissions_matrix(
|
||||||
|
admin: UserInfo = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取 RBAC 完整权限矩阵(管理后台可视化用)。
|
||||||
|
|
||||||
|
返回 5 角色预置的 permissions JSON,前端用此数据渲染
|
||||||
|
角色 × 资源 × 操作 × 范围 的可读表格。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
admin: 管理员(权限校验)
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 统一响应格式,包含完整权限矩阵
|
||||||
|
"""
|
||||||
|
from app.services.rbac_service import (
|
||||||
|
ROLE_PERMISSIONS,
|
||||||
|
VALID_ACTIONS,
|
||||||
|
VALID_RESOURCES,
|
||||||
|
VALID_SCOPES,
|
||||||
|
permissions_to_strings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 查 DB 拿角色元数据(显示名等)
|
||||||
|
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
roles = result.scalars().all()
|
||||||
|
|
||||||
|
# 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name)
|
||||||
|
role_list = []
|
||||||
|
matrix = {}
|
||||||
|
for role in roles:
|
||||||
|
# 优先用代码常量(单一可信源);DB 字段仅作元数据
|
||||||
|
perms = ROLE_PERMISSIONS.get(role.name, set())
|
||||||
|
perms_list = permissions_to_strings(perms)
|
||||||
|
|
||||||
|
role_list.append({
|
||||||
|
"name": role.name,
|
||||||
|
"display_name": role.display_name,
|
||||||
|
"description": role.description,
|
||||||
|
"is_default": role.is_default,
|
||||||
|
"permission_count": len(perms_list),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 角色 × 资源 × 操作 × 范围 的全矩阵
|
||||||
|
# true/false 表征是否拥有此权限
|
||||||
|
# 前端用此渲染表格,空格表示"不适用"
|
||||||
|
role_matrix = {}
|
||||||
|
for resource in VALID_RESOURCES:
|
||||||
|
for action in VALID_ACTIONS:
|
||||||
|
for scope in VALID_SCOPES:
|
||||||
|
perm = f"{resource}:{action}:{scope}"
|
||||||
|
role_matrix[perm] = (resource, action, scope) in perms
|
||||||
|
matrix[role.name] = role_matrix
|
||||||
|
|
||||||
|
return success_response(data={
|
||||||
|
"roles": role_list,
|
||||||
|
"resources": VALID_RESOURCES,
|
||||||
|
"actions": VALID_ACTIONS,
|
||||||
|
"scopes": VALID_SCOPES,
|
||||||
|
"matrix": matrix,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- GET /api/admin/roles/permissions/check ----------
|
||||||
|
# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有
|
||||||
|
# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission
|
||||||
|
@router.get("/permissions/check")
|
||||||
|
async def check_my_permission(
|
||||||
|
resource: str = Query(..., description="资源"),
|
||||||
|
action: str = Query(..., description="操作"),
|
||||||
|
scope: str = Query("own", description="数据范围"),
|
||||||
|
admin: UserInfo = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""检查当前管理员是否拥有指定权限(给前端按钮级控制用)。
|
||||||
|
|
||||||
|
永远返回 true(因为 require_admin 已确保是 admin)。
|
||||||
|
此端点存在是为了给前端一个统一入口,实际权限由后端强制。
|
||||||
|
未来扩展:可加 current_user 参数(非 admin 角色也能调)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource: 资源
|
||||||
|
action: 操作
|
||||||
|
scope: 数据范围
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 统一响应格式,包含 has_permission 字段
|
||||||
|
"""
|
||||||
|
from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings
|
||||||
|
|
||||||
|
user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()}
|
||||||
|
|
||||||
|
has_perm = check_permission(
|
||||||
|
user_roles=admin.roles,
|
||||||
|
user_permissions=user_perms,
|
||||||
|
required_resource=resource,
|
||||||
|
required_action=action,
|
||||||
|
required_scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(data={
|
||||||
|
"has_permission": has_perm,
|
||||||
|
"resource": resource,
|
||||||
|
"action": action,
|
||||||
|
"scope": scope,
|
||||||
|
})
|
||||||
|
|||||||
+23
-17
@@ -257,8 +257,9 @@ async def agent_login(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||||
|
|
||||||
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
|
# 2. MFA 二次验证(admin 角色且已绑定 MFA)
|
||||||
if agent.role == "admin" and agent.otp_enabled == 1:
|
# v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled
|
||||||
|
if agent.role == "admin" and agent.mfa_enabled:
|
||||||
if not body.otp_code:
|
if not body.otp_code:
|
||||||
# 需要 OTP 验证,返回 require_otp 标记
|
# 需要 OTP 验证,返回 require_otp 标记
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
@@ -269,7 +270,7 @@ async def agent_login(
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 验证 OTP 码
|
# 验证 OTP 码
|
||||||
totp = pyotp.TOTP(agent.otp_secret)
|
totp = pyotp.TOTP(agent.mfa_secret)
|
||||||
if not totp.verify(body.otp_code, valid_window=1):
|
if not totp.verify(body.otp_code, valid_window=1):
|
||||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||||
|
|
||||||
@@ -414,15 +415,16 @@ async def bind_agent_otp(
|
|||||||
Dict: 二维码图片(base64)和密钥
|
Dict: 二维码图片(base64)和密钥
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# v0.7.1: 用 mfa_secret 替代 otp_secret
|
||||||
# 检查是否已绑定
|
# 检查是否已绑定
|
||||||
if agent.otp_secret:
|
if agent.mfa_secret:
|
||||||
# 已绑定,返回现有密钥的二维码
|
# 已绑定,返回现有密钥的二维码
|
||||||
totp = pyotp.TOTP(agent.otp_secret)
|
totp = pyotp.TOTP(agent.mfa_secret)
|
||||||
else:
|
else:
|
||||||
# 生成新密钥
|
# 生成新密钥
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
agent.otp_secret = secret
|
agent.mfa_secret = secret
|
||||||
# otp_enabled 保持 0,等待首次验证后启用
|
# mfa_enabled 保持 False,等待首次验证后启用
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
@@ -439,11 +441,11 @@ async def bind_agent_otp(
|
|||||||
qr.save(buffer, format="PNG")
|
qr.save(buffer, format="PNG")
|
||||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||||
|
|
||||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
|
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...")
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"qr_code": f"data:image/png;base64,{qr_base64}",
|
"qr_code": f"data:image/png;base64,{qr_base64}",
|
||||||
"secret": agent.otp_secret,
|
"secret": agent.mfa_secret,
|
||||||
})
|
})
|
||||||
|
|
||||||
except AppException:
|
except AppException:
|
||||||
@@ -475,16 +477,18 @@ async def verify_agent_otp(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
agent = result.scalars().first()
|
agent = result.scalars().first()
|
||||||
|
|
||||||
if not agent or not agent.otp_secret:
|
if not agent or not agent.mfa_secret:
|
||||||
raise AppException(1008, "请先绑定OTP")
|
raise AppException(1008, "请先绑定OTP")
|
||||||
|
|
||||||
# 验证 OTP 码
|
# 验证 OTP 码
|
||||||
totp = pyotp.TOTP(agent.otp_secret)
|
totp = pyotp.TOTP(agent.mfa_secret)
|
||||||
if not totp.verify(body.otp_code, valid_window=1):
|
if not totp.verify(body.otp_code, valid_window=1):
|
||||||
raise AppException(1006, "OTP验证码错误")
|
raise AppException(1006, "OTP验证码错误")
|
||||||
|
|
||||||
# 验证成功,启用 OTP
|
# 验证成功,启用 MFA
|
||||||
agent.otp_enabled = 1
|
agent.mfa_enabled = True
|
||||||
|
agent.mfa_bound_at = datetime.now()
|
||||||
|
agent.mfa_last_verified_at = datetime.now()
|
||||||
agent.updated_at = datetime.now()
|
agent.updated_at = datetime.now()
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -492,7 +496,7 @@ async def verify_agent_otp(
|
|||||||
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
|
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"otp_enabled": True,
|
"mfa_enabled": True,
|
||||||
"message": "OTP验证成功,已启用",
|
"message": "OTP验证成功,已启用",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -510,15 +514,17 @@ async def unbind_agent_otp(
|
|||||||
):
|
):
|
||||||
"""解绑 OTP。
|
"""解绑 OTP。
|
||||||
|
|
||||||
解绑后 otp_secret 和 otp_enabled 都清空。
|
解绑后 mfa_secret 和 mfa_enabled 都清空。
|
||||||
需要管理员操作。
|
需要管理员操作。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: 解绑结果
|
Dict: 解绑结果
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
agent.otp_secret = None
|
agent.mfa_secret = None
|
||||||
agent.otp_enabled = 0
|
agent.mfa_enabled = False
|
||||||
|
agent.mfa_bound_at = None
|
||||||
|
agent.mfa_last_verified_at = None
|
||||||
agent.updated_at = datetime.now()
|
agent.updated_at = datetime.now()
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -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={
|
return success_response(data={
|
||||||
"ticket": result["ticket"],
|
"ticket": result["ticket"],
|
||||||
"qrcode_url": result["qrcode_url"],
|
"qrcode_url": result["qrcode_url"],
|
||||||
|
"qrcode_png_base64": result["qrcode_png_base64"],
|
||||||
"expires_in": result["expires_in"],
|
"expires_in": result["expires_in"],
|
||||||
"expires_at": result["expires_at"].isoformat(),
|
"expires_at": result["expires_at"].isoformat(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,丢手机兜底)
|
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
|
||||||
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
|
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
|
||||||
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])
|
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])
|
||||||
|
|
||||||
|
# 企微 SSO (v0.7.1 task #85)
|
||||||
|
# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO
|
||||||
|
# GET /api/auth_wecom/sso/callback — 企微 OAuth2 回调,用 code 换 userid → 跳端点
|
||||||
|
# GET /api/auth_wecom/sso/verify — 前端用 SSO token 换用户身份(一次性)
|
||||||
|
from app.api.auth_wecom_sso import router as auth_wecom_sso_router
|
||||||
|
api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||||
|
|
||||||
|
# 审计日志 API (v0.7.1 task #89)
|
||||||
|
# GET /api/admin/audit-logs — 分页 + 多维过滤(给 auditor / admin 角色用)
|
||||||
|
# 权限要求: audit_log:read:all (RBAC 装饰器强制)
|
||||||
|
from app.api.audit_logs import router as audit_logs_router
|
||||||
|
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ class Settings(BaseSettings):
|
|||||||
# 设备申请审批模板ID(在企微审批应用设置中获取)
|
# 设备申请审批模板ID(在企微审批应用设置中获取)
|
||||||
approval_template_device: str = ""
|
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 应急页身份检测配置
|
# 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)
|
return require_role("admin")(func)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 细粒度权限装饰器 (v0.7.1 task #86 — RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围)
|
||||||
|
# =============================================================================
|
||||||
|
# 权限字符串格式: "resource:action:scope"
|
||||||
|
# 例: "conversation:read:all"
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# @router.get("/api/admin/agents")
|
||||||
|
# @require_permission("agent:read:all")
|
||||||
|
# async def list_agents(...): ...
|
||||||
|
#
|
||||||
|
# 行为:
|
||||||
|
# 1. 装饰器只检查"是否拥有权限字符串",不直接执行 DB 查询
|
||||||
|
# 2. 实际检查在 rbac_service.check_permission() 里
|
||||||
|
# 3. 用户的权限从 UserInfo.permissions 字段读(由 get_current_user 解析 token 时填入)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def require_permission(
|
||||||
|
resource: str,
|
||||||
|
action: str,
|
||||||
|
scope: str = "own",
|
||||||
|
):
|
||||||
|
"""细粒度权限验证装饰器(v0.7.1 task #86)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource: 资源(conversation/agent/system_config/audit_log)
|
||||||
|
action: 操作(read/create/update/delete)
|
||||||
|
scope: 数据范围(own/department/all)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@router.get("/api/admin/agents")
|
||||||
|
@require_permission("agent", "read", "all")
|
||||||
|
async def list_agents(current_user: UserInfo = Depends(get_current_user)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
perm_string = f"{resource}:{action}:{scope}"
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
params = list(sig.parameters.values())
|
||||||
|
params.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
'current_user',
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=UserInfo,
|
||||||
|
default=Depends(get_current_user),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_sig = sig.replace(parameters=params)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
current_user = kwargs.pop('current_user')
|
||||||
|
|
||||||
|
# 拉用户所有角色的 permissions
|
||||||
|
# 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典
|
||||||
|
# 首次实现简化: 角色判断 + admin 通配符
|
||||||
|
# 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission
|
||||||
|
|
||||||
|
user_roles = set(current_user.roles or [])
|
||||||
|
|
||||||
|
# 1. admin 角色直通(通配符 *:*:all)
|
||||||
|
if "admin" in user_roles:
|
||||||
|
return await func(*args, current_user=current_user, **kwargs)
|
||||||
|
|
||||||
|
# 2. 其他角色: 走 rbac_service.check_permission
|
||||||
|
# 简化: 这里只看角色名,不查 DB(性能考虑)
|
||||||
|
# 实际生产可加缓存或预加载到 token
|
||||||
|
from app.services.rbac_service import (
|
||||||
|
ROLE_PERMISSIONS,
|
||||||
|
check_permission,
|
||||||
|
)
|
||||||
|
# 把 ROLE_PERMISSIONS 转成 {role_name: [perm_string]} 格式
|
||||||
|
user_perms_dict = {
|
||||||
|
role: [f"{r}:{a}:{s}" for (r, a, s) in perms]
|
||||||
|
for role, perms in ROLE_PERMISSIONS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
has_perm = check_permission(
|
||||||
|
user_roles=list(user_roles),
|
||||||
|
user_permissions=user_perms_dict,
|
||||||
|
required_resource=resource,
|
||||||
|
required_action=action,
|
||||||
|
required_scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_perm:
|
||||||
|
logger.warning(
|
||||||
|
f"用户 {current_user.employee_id} 权限不足: "
|
||||||
|
f"角色 {list(user_roles)}, 缺 {perm_string}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"权限不足: 需要 {perm_string}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await func(*args, current_user=current_user, **kwargs)
|
||||||
|
|
||||||
|
wrapper.__signature__ = new_sig
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+9
-4
@@ -210,7 +210,12 @@ async def _init_default_data():
|
|||||||
# 5. 初始化软件下载入口
|
# 5. 初始化软件下载入口
|
||||||
await _init_software_downloads(db, SoftwareDownload)
|
await _init_software_downloads(db, SoftwareDownload)
|
||||||
|
|
||||||
# 6. (dev 模式)初始化 demo 会话,让前端有数据可发
|
# 6. v0.7.1 task #86 — RBAC 5 角色种子(细粒度权限)
|
||||||
|
# 行为: 已有角色更新 permissions,缺则新建
|
||||||
|
from app.data.seed_rbac import seed_rbac_roles
|
||||||
|
await seed_rbac_roles(db)
|
||||||
|
|
||||||
|
# 7. (dev 模式)初始化 demo 会话,让前端有数据可发
|
||||||
# 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
|
# 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
|
||||||
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
|
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
|
||||||
await _init_demo_conversations(db)
|
await _init_demo_conversations(db)
|
||||||
@@ -752,7 +757,8 @@ def create_app() -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 检查数据库
|
# 检查数据库
|
||||||
from app.database import engine
|
from app.database import _get_engine
|
||||||
|
engine = _get_engine()
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text("SELECT 1"))
|
await conn.execute(text("SELECT 1"))
|
||||||
db_status = "ok"
|
db_status = "ok"
|
||||||
@@ -761,8 +767,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 检查 Redis
|
# 检查 Redis
|
||||||
from app.config import get_settings
|
from app.config import settings
|
||||||
settings = get_settings()
|
|
||||||
redis_client = settings.create_redis_client()
|
redis_client = settings.create_redis_client()
|
||||||
await redis_client.ping()
|
await redis_client.ping()
|
||||||
redis_status = "ok"
|
redis_status = "ok"
|
||||||
|
|||||||
@@ -123,21 +123,10 @@ class Agent(Base):
|
|||||||
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
|
comment="技能标签列表(电脑/软件/外设/网络/安全/资产/其他)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# OTP密钥(用于TOTP动态码验证,为空表示未绑定)
|
# v0.7.1: 删除 otp_secret / otp_enabled 字段
|
||||||
otp_secret: Mapped[str] = mapped_column(
|
# 原因: 与下方 mfa_secret / mfa_enabled 完全重复(都是 TOTP secret)
|
||||||
String(32),
|
# 旧 OTP 字段只用于高危操作前的二次验证,mfa 字段已涵盖该用途
|
||||||
nullable=True,
|
# 迁移策略: alembic 010 改为 DROP COLUMN otp_secret, otp_enabled
|
||||||
default=None,
|
|
||||||
comment="OTP密钥(Base32编码)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# OTP是否启用(admin角色强制启用)
|
|
||||||
otp_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=0,
|
|
||||||
comment="OTP是否启用(0=否, 1=是)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 本地密码哈希(可选,用于本地密码认证)
|
# 本地密码哈希(可选,用于本地密码认证)
|
||||||
# 使用 bcrypt 加密存储,不存储明文密码
|
# 使用 bcrypt 加密存储,不存储明文密码
|
||||||
|
|||||||
@@ -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 用户直接模拟扫码结果
|
# 3. dev 模式: 跳过企微 OAuth2,使用预设 dev 用户直接模拟扫码结果
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -140,10 +144,25 @@ class QrcodeService:
|
|||||||
return {
|
return {
|
||||||
"ticket": ticket,
|
"ticket": ticket,
|
||||||
"qrcode_url": qrcode_url,
|
"qrcode_url": qrcode_url,
|
||||||
|
"qrcode_png_base64": self._render_qrcode_png(qrcode_url),
|
||||||
"expires_in": TICKET_TTL_SECONDS,
|
"expires_in": TICKET_TTL_SECONDS,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render_qrcode_png(url: str) -> str:
|
||||||
|
"""把 url 编成 PNG 并返回 base64 字符串,供前端 <img :src="data:image/png;base64,..."> 直接渲染。
|
||||||
|
|
||||||
|
依赖: requirements.txt 已有 qrcode[pil]==7.4.2 (2026-06-15 加的,原本为 OTP 绑定)。
|
||||||
|
"""
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=2)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
def _build_oauth_url(self, ticket: str) -> str:
|
def _build_oauth_url(self, ticket: str) -> str:
|
||||||
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
|
"""拼接企微 OAuth2 授权 URL(供前端生成二维码)。
|
||||||
|
|
||||||
|
|||||||
@@ -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. 测试用数据库会话
|
# 4. 测试用数据库会话
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Windows GBK 兼容补丁: 强制 slowapi/starlette 用 UTF-8 读 .env
|
||||||
|
# 原因: slowapi 0.1.9 内部用 starlette.config.Config 读 .env,默认 encoding
|
||||||
|
# 走 locale.getpreferredencoding() (Windows=GBK)。backend/.env 是 UTF-8
|
||||||
|
# 含中文,GBK 解码失败 → UnicodeDecodeError,pytest 卡死。
|
||||||
|
# 修法: 替换 _read_file 强制 utf-8。生产 Linux 不受影响。
|
||||||
|
# 详见 [[conftest-gbk-env-patch]]
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
import starlette.config as _starlette_config
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_read_file(self, env_file):
|
||||||
|
"""强制 utf-8 编码读 .env,绕开 Windows GBK 默认值。"""
|
||||||
|
if not env_file:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(env_file, encoding="utf-8") as f:
|
||||||
|
return {
|
||||||
|
line.split("=", 1)[0].strip(): line.split("=", 1)[1].strip()
|
||||||
|
for line in f.readlines()
|
||||||
|
if line.strip() and not line.startswith("#")
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
_starlette_config.Config._read_file = _patched_read_file
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -285,6 +314,16 @@ async def _mock_get_user_info_default(user_id: str, **kwargs):
|
|||||||
mock_wecom_module.get_user_info.side_effect = _mock_get_user_info_default
|
mock_wecom_module.get_user_info.side_effect = _mock_get_user_info_default
|
||||||
mock_wecom_module.get_department_users.return_value = []
|
mock_wecom_module.get_department_users.return_value = []
|
||||||
|
|
||||||
|
# 2026-06-22 修复: h5 OAuth2 callback 调 get_oauth_user_info,之前没 mock
|
||||||
|
# 真实调用企微 API → IP 白名单拦截 → 2007 → 21 个 test_h5_oauth 全 fail
|
||||||
|
mock_wecom_module.get_oauth_user_info.return_value = {
|
||||||
|
"userid": "test_oauth_user",
|
||||||
|
"name": "OAuth测试员工",
|
||||||
|
"department": [1],
|
||||||
|
"position": "员工",
|
||||||
|
"avatar": "",
|
||||||
|
}
|
||||||
|
|
||||||
mock_ai_module = AsyncMock()
|
mock_ai_module = AsyncMock()
|
||||||
mock_ai_module.generate_response.return_value = "这是AI的模拟回复"
|
mock_ai_module.generate_response.return_value = "这是AI的模拟回复"
|
||||||
|
|
||||||
@@ -365,10 +404,13 @@ async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenera
|
|||||||
mock_ai = mock_ai_module
|
mock_ai = mock_ai_module
|
||||||
|
|
||||||
# Patch WecomService 类(端点函数中会新建实例)
|
# Patch WecomService 类(端点函数中会新建实例)
|
||||||
# 注意:只 patch 模块中实际引用的名字
|
# 2026-06-22 修复: 必须 patch "app.services.wecom_service.WecomService"
|
||||||
# conversations.py 导入了 WecomService,但没有导入 AIService
|
# 而不是 "app.api.h5.WecomService" — 因为 dep_wecom_service() 工厂函数
|
||||||
|
# 在 app.services.wecom_service 模块内部 import WecomService,
|
||||||
|
# h5.py/agents.py 模块本身没 import WecomService,patch 它不生效
|
||||||
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
|
# 兼容历史: 部分代码可能仍然直接 import WecomService
|
||||||
with patch("app.api.conversations.WecomService", return_value=mock_wecom):
|
with patch("app.api.conversations.WecomService", return_value=mock_wecom):
|
||||||
# h5.py 和 agents.py 也需要 patch
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
||||||
with patch("app.api.agents.WecomService", return_value=mock_wecom):
|
with patch("app.api.agents.WecomService", return_value=mock_wecom):
|
||||||
with patch("app.api.agents._get_redis", return_value=mock_redis):
|
with patch("app.api.agents._get_redis", return_value=mock_redis):
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncCli
|
|||||||
|
|
||||||
with patch("app.api.h5._get_redis", return_value=mock_redis, create=True):
|
with patch("app.api.h5._get_redis", return_value=mock_redis, create=True):
|
||||||
with patch("redis.asyncio.from_url", return_value=mock_redis):
|
with patch("redis.asyncio.from_url", return_value=mock_redis):
|
||||||
|
# 2026-06-22 修复: h5 OAuth2 调 dep_wecom_service() 工厂,
|
||||||
|
# 必须 patch "app.services.wecom_service.WecomService" 而非 "app.services.wecom_service.WecomService"
|
||||||
|
with patch("app.services.wecom_service.WecomService") as MockWecom:
|
||||||
|
mock_wecom_instance = MockWecom.return_value
|
||||||
|
mock_wecom_instance.get_oauth_user_info = AsyncMock(return_value={
|
||||||
|
"userid": "h5_oauth_test_user",
|
||||||
|
"user_ticket": "",
|
||||||
|
})
|
||||||
|
mock_wecom_instance.get_user_info = AsyncMock(return_value={
|
||||||
|
"name": "H5测试员工",
|
||||||
|
"department": [1, 2],
|
||||||
|
"position": "员工",
|
||||||
|
"avatar": "",
|
||||||
|
})
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
# base_url 用 127.0.0.1,让 h5._require_wework_ua 跳过 UA 检测
|
# base_url 用 127.0.0.1,让 h5._require_wework_ua 跳过 UA 检测
|
||||||
# 原因:生产环境要求企微 UA,测试环境是 httpx 客户端没企微 UA
|
# 原因:生产环境要求企微 UA,测试环境是 httpx 客户端没企微 UA
|
||||||
@@ -179,7 +193,7 @@ class TestOAuthCallback:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_auth_code"},
|
json={"code": "valid_auth_code"},
|
||||||
@@ -209,7 +223,7 @@ class TestOAuthCallback:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_auth_code"},
|
json={"code": "valid_auth_code"},
|
||||||
@@ -236,7 +250,7 @@ class TestOAuthCallback:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_auth_code"},
|
json={"code": "valid_auth_code"},
|
||||||
@@ -257,7 +271,7 @@ class TestOAuthCallback:
|
|||||||
mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "", "user_ticket": ""})
|
mock_wecom.get_oauth_user_info = AsyncMock(return_value={"userid": "", "user_ticket": ""})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "bad_code"},
|
json={"code": "bad_code"},
|
||||||
@@ -273,7 +287,7 @@ class TestOAuthCallback:
|
|||||||
mock_wecom.get_oauth_user_info = AsyncMock(side_effect=Exception("企微API不可用"))
|
mock_wecom.get_oauth_user_info = AsyncMock(side_effect=Exception("企微API不可用"))
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "will_fail"},
|
json={"code": "will_fail"},
|
||||||
@@ -290,7 +304,7 @@ class TestOAuthCallback:
|
|||||||
mock_wecom.get_user_info = AsyncMock(side_effect=Exception("通讯录API失败"))
|
mock_wecom.get_user_info = AsyncMock(side_effect=Exception("通讯录API失败"))
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_code"},
|
json={"code": "valid_code"},
|
||||||
@@ -521,7 +535,7 @@ class TestGetCurrentEmployeeInfo:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.get(
|
response = await h5_client.get(
|
||||||
"/h5/me",
|
"/h5/me",
|
||||||
headers={"Authorization": "Bearer nocache_me_token"},
|
headers={"Authorization": "Bearer nocache_me_token"},
|
||||||
@@ -652,7 +666,7 @@ class TestErrorHandling:
|
|||||||
|
|
||||||
mock_redis.setex = broken_setex
|
mock_redis.setex = broken_setex
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_code"},
|
json={"code": "valid_code"},
|
||||||
@@ -677,7 +691,7 @@ class TestErrorHandling:
|
|||||||
)
|
)
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "timeout_code"},
|
json={"code": "timeout_code"},
|
||||||
@@ -698,7 +712,7 @@ class TestErrorHandling:
|
|||||||
)
|
)
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.get(
|
response = await h5_client.get(
|
||||||
"/h5/me",
|
"/h5/me",
|
||||||
headers={"Authorization": "Bearer wecom_fail_token"},
|
headers={"Authorization": "Bearer wecom_fail_token"},
|
||||||
@@ -729,7 +743,7 @@ class TestTokenTTLAndFormat:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "ttl_test_code"},
|
json={"code": "ttl_test_code"},
|
||||||
@@ -755,7 +769,7 @@ class TestTokenTTLAndFormat:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "info_ttl_code"},
|
json={"code": "info_ttl_code"},
|
||||||
@@ -778,7 +792,7 @@ class TestTokenTTLAndFormat:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "fmt_test_code"},
|
json={"code": "fmt_test_code"},
|
||||||
@@ -827,7 +841,7 @@ class TestSchemaValidation:
|
|||||||
mock_wecom.get_user_info = AsyncMock(return_value={"name": "", "department": [], "position": "", "avatar": ""})
|
mock_wecom.get_user_info = AsyncMock(return_value={"name": "", "department": [], "position": "", "avatar": ""})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
response = await h5_client.post(
|
response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "valid_code_here"},
|
json={"code": "valid_code_here"},
|
||||||
@@ -866,7 +880,7 @@ class TestOAuth2EndToEnd:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
callback_response = await h5_client.post(
|
callback_response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "e2e_auth_code"},
|
json={"code": "e2e_auth_code"},
|
||||||
@@ -905,7 +919,7 @@ class TestOAuth2EndToEnd:
|
|||||||
})
|
})
|
||||||
mock_wecom.close = AsyncMock()
|
mock_wecom.close = AsyncMock()
|
||||||
|
|
||||||
with patch("app.api.h5.WecomService", return_value=mock_wecom):
|
with patch("app.services.wecom_service.WecomService", return_value=mock_wecom):
|
||||||
callback_response = await h5_client.post(
|
callback_response = await h5_client.post(
|
||||||
"/h5/oauth/callback",
|
"/h5/oauth/callback",
|
||||||
json={"code": "cached_flow_code"},
|
json={"code": "cached_flow_code"},
|
||||||
@@ -914,7 +928,7 @@ class TestOAuth2EndToEnd:
|
|||||||
token = callback_response.json()["data"]["token"]
|
token = callback_response.json()["data"]["token"]
|
||||||
|
|
||||||
# Step 2: 第一次访问 /me(应从缓存读取,不再调用 WecomService)
|
# Step 2: 第一次访问 /me(应从缓存读取,不再调用 WecomService)
|
||||||
with patch("app.api.h5.WecomService") as MockWecomClass:
|
with patch("app.services.wecom_service.WecomService") as MockWecomClass:
|
||||||
me_response = await h5_client.get(
|
me_response = await h5_client.get(
|
||||||
"/h5/me",
|
"/h5/me",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# nginx access_log 脱敏脚本(生产服务器跑)
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:把默认的 access_log 换成自定义 log_format,删除 Authorization/Cookie 等
|
||||||
|
# 敏感 header,避免泄漏到日志
|
||||||
|
# 用法:bash nginx-access-log-redact.sh
|
||||||
|
# 回滚:bash nginx-access-log-redact.sh --rollback
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER="wecom_it_nginx" # 注意是下划线
|
||||||
|
CONF_PATH="/etc/nginx/conf.d/log-format.conf"
|
||||||
|
BACKUP_PATH="/etc/nginx/conf.d/log-format.conf.bak"
|
||||||
|
|
||||||
|
if [[ "$1" == "--rollback" ]]; then
|
||||||
|
echo "[ROLLBACK] 恢复默认 access_log..."
|
||||||
|
docker exec "$CONTAINER" bash -c "
|
||||||
|
if [[ -f $BACKUP_PATH ]]; then
|
||||||
|
mv $BACKUP_PATH $CONF_PATH
|
||||||
|
else
|
||||||
|
echo 'access_log /var/log/nginx/access.log;' > $CONF_PATH
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
docker exec "$CONTAINER" nginx -t
|
||||||
|
docker exec "$CONTAINER" nginx -s reload
|
||||||
|
echo "[OK] 已回滚到默认 access_log"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/5] 备份现有 log-format.conf(如有)..."
|
||||||
|
docker exec "$CONTAINER" bash -c "
|
||||||
|
if [[ -f $CONF_PATH ]]; then
|
||||||
|
cp $CONF_PATH $BACKUP_PATH
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[2/5] 写入脱敏 log_format 配置..."
|
||||||
|
docker exec "$CONTAINER" bash -c "cat > $CONF_PATH << 'EOF'
|
||||||
|
# 自定义 access_log 格式 — 删除 Authorization/Cookie 等敏感 header
|
||||||
|
# 仅保留请求方法 + URI + 状态码 + 字节数 + UA + Referer
|
||||||
|
log_format secure \$remote_addr - \$remote_user [\$time_local] \"\$request_method \$uri \$server_protocol\" \$status \$body_bytes_sent \"\$http_referer\" \"\$http_user_agent\";
|
||||||
|
|
||||||
|
# 应用:覆盖默认 access_log
|
||||||
|
access_log /var/log/nginx/access.log secure;
|
||||||
|
EOF
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[3/5] 验证配置文件..."
|
||||||
|
docker exec "$CONTAINER" cat $CONF_PATH
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[4/5] nginx -t 验证语法..."
|
||||||
|
docker exec "$CONTAINER" nginx -t
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[5/5] reload nginx(不中断连接)..."
|
||||||
|
docker exec "$CONTAINER" nginx -s reload
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "[OK] nginx access_log 脱敏已生效"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "验证:tail 一下 access.log 看新格式"
|
||||||
|
echo " docker exec $CONTAINER tail -5 /var/log/nginx/access.log"
|
||||||
|
echo ""
|
||||||
|
echo "回滚:bash nginx-access-log-redact.sh --rollback"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# v0.7.0 前端 dist 一键上传到生产
|
||||||
|
# =============================================================================
|
||||||
|
# 用途:打包 4 端 dist + scp 到生产 + 在生产解压 + 重载 nginx
|
||||||
|
# 用法:在 Windows PowerShell 7+ 跑 .\upload-frontend-v0.7.0.ps1
|
||||||
|
# 前置:已 PuTTY 跳到堡垒机 → 再到生产(同一会话)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = "D:\资料\03-项目开发\wecom_it_smart_desk-claude"
|
||||||
|
$TarPath = "$env:TEMP\frontend-v0.7.0.tar.gz"
|
||||||
|
$Server = "root@10.90.5.110"
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " v0.7.0 前端 dist 一键上传" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 步骤 1:打包 4 端 dist
|
||||||
|
Write-Host "[1/4] 打包 4 端 dist..." -ForegroundColor Yellow
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
& tar -czf $TarPath `
|
||||||
|
frontend-admin/dist `
|
||||||
|
frontend-agent/dist `
|
||||||
|
frontend-portal/dist `
|
||||||
|
frontend-h5/dist
|
||||||
|
$Size = (Get-Item $TarPath).Length / 1MB
|
||||||
|
Write-Host " OK: $TarPath ($([math]::Round($Size, 2)) MB)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 步骤 2:scp 到生产
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[2/4] scp 到生产 $Server:/tmp/..." -ForegroundColor Yellow
|
||||||
|
Write-Host " (会提示输入密码,用 PuTTY 的密码)" -ForegroundColor Gray
|
||||||
|
& scp -o StrictHostKeyChecking=no -o ConnectTimeout=30 $TarPath "${Server}:/tmp/frontend-v0.7.0.tar.gz"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " FAILED: scp 失败" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " OK" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 步骤 3:在生产解压(走 ssh,需要输密码)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[3/4] ssh 到生产解压到 nginx 挂载点..." -ForegroundColor Yellow
|
||||||
|
Write-Host " (会再次提示输入密码)" -ForegroundColor Gray
|
||||||
|
$RemoteCmd = @"
|
||||||
|
cd /opt/wecom-it-desk &&
|
||||||
|
echo '解压前端...' &&
|
||||||
|
sudo tar -xzf /tmp/frontend-v0.7.0.tar.gz &&
|
||||||
|
echo '清理 tar 包...' &&
|
||||||
|
sudo rm /tmp/frontend-v0.7.0.tar.gz &&
|
||||||
|
echo '清理本地 tar 包...' &&
|
||||||
|
rm $TarPath &&
|
||||||
|
echo '========================================' &&
|
||||||
|
echo '前端 4 端 dist 已更新到生产!' &&
|
||||||
|
echo '========================================'
|
||||||
|
"@
|
||||||
|
& ssh -o StrictHostKeyChecking=no -o ConnectTimeout=30 $Server $RemoteCmd
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " FAILED: ssh 解压失败" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[4/4] 完成!" -ForegroundColor Green
|
||||||
|
Write-Host "下一步:在生产跑 nginx 脱敏配置 + reload" -ForegroundColor Cyan
|
||||||
|
Write-Host "详见 docs/DEPLOY-QUICK-v0.7.0.md Step 5-6" -ForegroundColor Cyan
|
||||||
@@ -50,7 +50,7 @@ git remote -v
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1.1 备份 backend 当前镜像
|
# 1.1 备份 backend 当前镜像
|
||||||
sudo docker tag wecom_it_backend wecom_it_backend:v0.6.0-backup
|
sudo docker tag wecom-it-desk-backend:latest wecom-it-desk-backend:v0.6.0-backup
|
||||||
|
|
||||||
# 1.2 备份 4 端 dist
|
# 1.2 备份 4 端 dist
|
||||||
sudo mkdir -p /opt/wecom-it-desk/dist-backup-2026-06-21
|
sudo mkdir -p /opt/wecom-it-desk/dist-backup-2026-06-21
|
||||||
@@ -68,7 +68,7 @@ sudo docker exec wecom_it_postgres psql -U postgres -d wecom_it -c "SELECT versi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 2.1 拉新镜像
|
# 2.1 拉新镜像
|
||||||
sudo docker pull wecom_it_backend:v0.7.0
|
sudo docker pull wecom-it-desk-backend:v0.7.0
|
||||||
|
|
||||||
# 2.2 跑 migration(只 PG,SQLite 跳过)
|
# 2.2 跑 migration(只 PG,SQLite 跳过)
|
||||||
sudo docker exec wecom_it_backend alembic upgrade head
|
sudo docker exec wecom_it_backend alembic upgrade head
|
||||||
@@ -113,7 +113,7 @@ sudo docker ps | grep wecom_it_backend
|
|||||||
|
|
||||||
**🚨 若 backend 启动失败,回滚**:
|
**🚨 若 backend 启动失败,回滚**:
|
||||||
```bash
|
```bash
|
||||||
sudo docker tag wecom_it_backend:v0.6.0-backup wecom_it_backend
|
sudo docker tag wecom-it-desk-backend:v0.6.0-backup wecom-it-desk-backend:latest
|
||||||
sudo docker restart wecom_it_backend
|
sudo docker restart wecom_it_backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ const routes = [
|
|||||||
component: () => import('@/views/Roles.vue'),
|
component: () => import('@/views/Roles.vue'),
|
||||||
meta: { title: '角色管理', requiresAuth: true },
|
meta: { title: '角色管理', requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// v0.7.1 task #91 — RBAC 细粒度权限矩阵可视化
|
||||||
|
path: 'permissions-matrix',
|
||||||
|
name: 'PermissionsMatrix',
|
||||||
|
component: () => import('@/views/PermissionsMatrix.vue'),
|
||||||
|
meta: { title: '权限矩阵', requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'integrations',
|
path: 'integrations',
|
||||||
name: 'Integrations',
|
name: 'Integrations',
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<!--
|
||||||
|
=============================================================================
|
||||||
|
企微IT智能服务台 — RBAC 权限矩阵可视化页 (v0.7.1 task #91)
|
||||||
|
=============================================================================
|
||||||
|
说明: 拉 GET /api/admin/roles/permissions/matrix,渲染 5 角色 × 4 资源 ×
|
||||||
|
4 操作 × 3 范围的完整矩阵表格。给管理员一眼看到角色权限边界。
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 行 = 资源:操作 (16 行,4 资源 × 4 操作)
|
||||||
|
- 列 = 角色 (5 列,user/agent/team_lead/auditor/admin)
|
||||||
|
- 单元格颜色:
|
||||||
|
· own 权限 → 浅蓝
|
||||||
|
· department 权限 → 蓝色
|
||||||
|
· all 权限 → 深蓝
|
||||||
|
· 无权限 → 灰色
|
||||||
|
- 顶部 scope 图例 + 角色筛选 + 资源筛选
|
||||||
|
- 支持导出 CSV(复制权限矩阵给合规审计)
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="matrix-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">RBAC 权限矩阵</div>
|
||||||
|
<div class="page-desc">
|
||||||
|
5 角色 × 4 资源 × 4 操作 × 3 数据范围,共 240 个权限点 (5 × 16 × 3)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedRole"
|
||||||
|
placeholder="筛选角色"
|
||||||
|
size="default"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="r in matrixData.roles"
|
||||||
|
:key="r.name"
|
||||||
|
:label="r.display_name + ' (' + r.name + ')'"
|
||||||
|
:value="r.name"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="selectedResource"
|
||||||
|
placeholder="筛选资源"
|
||||||
|
size="default"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="r in matrixData.resources"
|
||||||
|
:key="r"
|
||||||
|
:label="r"
|
||||||
|
:value="r"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="exportCsv">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出 CSV
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="loadMatrix" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading && !hasData" :rows="10" animated />
|
||||||
|
|
||||||
|
<template v-else-if="hasData">
|
||||||
|
<!-- scope 图例 -->
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-title">数据范围:</span>
|
||||||
|
<span class="legend-item legend-own">own — 自己的</span>
|
||||||
|
<span class="legend-item legend-dept">department — 部门的</span>
|
||||||
|
<span class="legend-item legend-all">all — 全部</span>
|
||||||
|
<span class="legend-item legend-none">无权限</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 权限矩阵表 -->
|
||||||
|
<el-table
|
||||||
|
:data="filteredMatrixRows"
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
:empty-text="loading ? '加载中...' : '无数据'"
|
||||||
|
class="matrix-table"
|
||||||
|
>
|
||||||
|
<el-table-column label="资源" prop="resource" width="140" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="resource-label">{{ row.resource }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" prop="action" width="100" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getActionTagType(row.action)">
|
||||||
|
{{ row.action }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="role in filteredRoles"
|
||||||
|
:key="role.name"
|
||||||
|
:label="role.display_name"
|
||||||
|
min-width="140"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="role-header">
|
||||||
|
<div class="role-name">{{ role.display_name }}</div>
|
||||||
|
<div class="role-id">{{ role.name }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="perm-cell">
|
||||||
|
<el-tag
|
||||||
|
v-for="scope in getScopesForRole(row, role.name)"
|
||||||
|
:key="scope"
|
||||||
|
size="small"
|
||||||
|
:class="['scope-tag', `scope-${scope}`]"
|
||||||
|
>
|
||||||
|
{{ scope }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="getScopesForRole(row, role.name).length === 0" class="no-perm">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 角色详情 -->
|
||||||
|
<div class="role-detail">
|
||||||
|
<div class="section-title">角色详情</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item
|
||||||
|
v-for="role in filteredRoles"
|
||||||
|
:key="role.name"
|
||||||
|
:label="role.display_name + ' (' + role.name + ')'"
|
||||||
|
>
|
||||||
|
<div class="role-meta">
|
||||||
|
<div class="role-desc">{{ role.description || '—' }}</div>
|
||||||
|
<div class="role-perm-count">
|
||||||
|
权限数: <b>{{ role.permission_count }}</b>
|
||||||
|
<el-tag v-if="role.is_default" type="info" size="small" style="margin-left: 8px">默认</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-else description="加载失败, 请检查网络或权限" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Download, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import apiClient from '@/api/index'
|
||||||
|
|
||||||
|
// ---- 类型定义 ----
|
||||||
|
interface Role {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
is_default: boolean
|
||||||
|
permission_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixData {
|
||||||
|
roles: Role[]
|
||||||
|
resources: string[]
|
||||||
|
actions: string[]
|
||||||
|
scopes: string[]
|
||||||
|
matrix: Record<string, Record<string, boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixRow {
|
||||||
|
resource: string
|
||||||
|
action: string
|
||||||
|
key: string // "resource:action"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态 ----
|
||||||
|
const loading = ref(false)
|
||||||
|
const matrixData = ref<MatrixData>({
|
||||||
|
roles: [],
|
||||||
|
resources: [],
|
||||||
|
actions: [],
|
||||||
|
scopes: [],
|
||||||
|
matrix: {},
|
||||||
|
})
|
||||||
|
const selectedRole = ref<string>('')
|
||||||
|
const selectedResource = ref<string>('')
|
||||||
|
|
||||||
|
const hasData = computed(() => matrixData.value.roles.length > 0)
|
||||||
|
|
||||||
|
const filteredRoles = computed(() => {
|
||||||
|
if (!selectedRole.value) return matrixData.value.roles
|
||||||
|
return matrixData.value.roles.filter((r) => r.name === selectedRole.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 矩阵行: 资源 × 操作 笛卡尔积
|
||||||
|
const matrixRows = computed<MatrixRow[]>(() => {
|
||||||
|
const rows: MatrixRow[] = []
|
||||||
|
for (const r of matrixData.value.resources) {
|
||||||
|
for (const a of matrixData.value.actions) {
|
||||||
|
rows.push({ resource: r, action: a, key: `${r}:${a}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMatrixRows = computed(() => {
|
||||||
|
if (!selectedResource.value) return matrixRows.value
|
||||||
|
return matrixRows.value.filter((row) => row.resource === selectedResource.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 方法 ----
|
||||||
|
async function loadMatrix() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.get('/admin/roles/permissions/matrix')
|
||||||
|
if (resp.data?.code === 0 && resp.data.data) {
|
||||||
|
matrixData.value = resp.data.data
|
||||||
|
} else {
|
||||||
|
ElMessage.error('拉取权限矩阵失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('loadMatrix 失败:', e)
|
||||||
|
ElMessage.error('加载失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拿到角色在 (resource, action) 下的所有 scope (own/department/all)。
|
||||||
|
* 例: agent 看 conversation:read 时,可能有 own + all 两个 scope。
|
||||||
|
*/
|
||||||
|
function getScopesForRole(row: MatrixRow, roleName: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
for (const scope of matrixData.value.scopes) {
|
||||||
|
const key = `${row.resource}:${row.action}:${scope}`
|
||||||
|
if (matrixData.value.matrix[roleName]?.[key]) {
|
||||||
|
result.push(scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionTagType(action: string): string {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
read: 'info',
|
||||||
|
create: 'success',
|
||||||
|
update: 'warning',
|
||||||
|
delete: 'danger',
|
||||||
|
}
|
||||||
|
return types[action] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
// CSV 表头: 资源,操作, <每个角色>
|
||||||
|
const header = ['resource', 'action', ...filteredRoles.value.map((r) => r.name)]
|
||||||
|
const lines = [header.join(',')]
|
||||||
|
|
||||||
|
for (const row of filteredMatrixRows.value) {
|
||||||
|
const cells = [row.resource, row.action]
|
||||||
|
for (const role of filteredRoles.value) {
|
||||||
|
const scopes = getScopesForRole(row, role.name)
|
||||||
|
cells.push(scopes.join('|') || '')
|
||||||
|
}
|
||||||
|
lines.push(cells.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = lines.join('\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `rbac-matrix-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('CSV 已导出')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadMatrix()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.matrix-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(30, 41, 59, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.legend-title {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.legend-item {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.legend-own {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.legend-dept {
|
||||||
|
background: #93c5fd;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
.legend-all {
|
||||||
|
background: #1e40af;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.legend-none {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.resource-label {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.role-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.role-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.role-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.perm-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.scope-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.scope-own {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
.scope-department {
|
||||||
|
background: #93c5fd;
|
||||||
|
color: #1e3a8a;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
.scope-all {
|
||||||
|
background: #1e40af;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1e3a8a;
|
||||||
|
}
|
||||||
|
.no-perm {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-detail {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.role-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.role-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.role-perm-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 企微IT智能服务台 — 企微 SSO UA 检测 composable (v0.7.1 task #85)
|
||||||
|
// =============================================================================
|
||||||
|
// 解决问题: v0.7.0 hotfix1 用户反馈"企微工作台进入应用也要扫码"。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// const sso = useWeChatWorkSSO()
|
||||||
|
// if (sso.isWeChatWork()) {
|
||||||
|
// window.location.href = sso.buildInitUrl('/itdesk/')
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 设计原则:
|
||||||
|
// 1. UA 检测: MicroMessenger / wxwork / wxwork.* 都算企微浏览器
|
||||||
|
// 2. 静默授权: scope=snsapi_base (用户无感,直接拿到 userid)
|
||||||
|
// 3. 降级策略: 非企微浏览器保留原 QR 扫码流程
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type SSOInitOptions = {
|
||||||
|
/** 登录成功后跳转路径,默认 /itdesk/ */
|
||||||
|
next?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微 UA 检测 + SSO URL 生成 composable
|
||||||
|
*/
|
||||||
|
export function useWeChatWorkSSO() {
|
||||||
|
/**
|
||||||
|
* 检测当前是否在企微浏览器中
|
||||||
|
*
|
||||||
|
* 匹配的 UA 关键字:
|
||||||
|
* - MicroMessenger: 微信内置浏览器(用户侧)
|
||||||
|
* - wxwork: 企业微信内置浏览器(企业侧,最常见)
|
||||||
|
* - wxwork/.*: 企微版本号
|
||||||
|
* - DingTalk: 钉钉(预留,暂不实现)
|
||||||
|
*
|
||||||
|
* 参考文档: https://developer.work.weixin.qq.com/document/path/91484
|
||||||
|
*/
|
||||||
|
function isWeChatWork(): boolean {
|
||||||
|
const ua = navigator.userAgent || ''
|
||||||
|
return /MicroMessenger/i.test(ua) || /wxwork/i.test(ua) || /\bDingTalk\b/i.test(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 SSO 初始化 URL
|
||||||
|
*
|
||||||
|
* 后端 /api/auth_wecom/sso/init 会:
|
||||||
|
* 1. 生成 state 存 Redis (5 分钟 TTL, 防 CSRF)
|
||||||
|
* 2. 拼企微 OAuth2 授权 URL
|
||||||
|
* 3. 302 跳转到企微授权页
|
||||||
|
*
|
||||||
|
* 企微授权页会:
|
||||||
|
* 1. 用户授权(静默, snsapi_base)
|
||||||
|
* 2. 回调 /api/auth_wecom/sso/callback?code=...&state=...
|
||||||
|
* 3. 后端用 code 换 userid, 查角色, 生成 SSO token
|
||||||
|
* 4. 302 跳转到 next + ?sso_token=xxx
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* window.location.href = buildInitUrl('/itdesk/')
|
||||||
|
*/
|
||||||
|
function buildInitUrl(next: string = '/itdesk/'): string {
|
||||||
|
const params = new URLSearchParams({ next })
|
||||||
|
// 用相对路径走 nginx 反代(开发环境走 vite proxy)
|
||||||
|
return `/api/auth_wecom/sso/init?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 SSO token 换取用户身份(一次性 token, 用完即删)
|
||||||
|
*
|
||||||
|
* 后端会返回 { user_id, name, role }
|
||||||
|
* 前端用此身份调 portal.getRoles() 或写入 store
|
||||||
|
*/
|
||||||
|
async function verifyToken(ssoToken: string): Promise<{
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE || '/api'
|
||||||
|
const resp = await fetch(`${apiBase}/auth_wecom/sso/verify?sso_token=${encodeURIComponent(ssoToken)}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data?.code === 0 && data.data) {
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SSO verify 失败:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 便捷函数: 如果是企微浏览器则跳转 SSO, 否则返回 false
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* if (tryAutoSSO({ next: '/itdesk/' })) return
|
||||||
|
* // 降级到 QR 扫码
|
||||||
|
*/
|
||||||
|
function tryAutoSSO(options: SSOInitOptions = {}): boolean {
|
||||||
|
if (!isWeChatWork()) return false
|
||||||
|
const url = buildInitUrl(options.next)
|
||||||
|
window.location.href = url
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWeChatWork,
|
||||||
|
buildInitUrl,
|
||||||
|
verifyToken,
|
||||||
|
tryAutoSSO,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,9 @@ import { usePortalStore } from '@/stores/portal'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
|
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
|
||||||
import apiClient from '@/api/index'
|
import apiClient from '@/api/index'
|
||||||
|
import { useWeChatWorkSSO } from '@/composables/useWeChatWorkSSO'
|
||||||
|
|
||||||
|
const sso = useWeChatWorkSSO()
|
||||||
|
|
||||||
// 获取 Portal Store
|
// 获取 Portal Store
|
||||||
const portalStore = usePortalStore()
|
const portalStore = usePortalStore()
|
||||||
@@ -142,12 +145,57 @@ const selectedRole = ref<string | null>(null)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化门户会话(可重入)
|
* 初始化门户会话(可重入)
|
||||||
* 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息
|
* 流程:
|
||||||
|
* 1. SSO 回调(企微浏览器走 SSO 才有 sso_token 参数)→ verifyToken → 走原流程
|
||||||
|
* 2. 企微浏览器但没拿到 sso_token → 主动 init SSO(走企微 OAuth2)
|
||||||
|
* 3. OAuth2 回调(普通浏览器走老 QR 流程,URL 中有 code 参数)
|
||||||
|
* 4. Token 跳转(从其他端跳过来,URL 中有 token 参数)
|
||||||
|
* 5. 本地缓存 → 没登录 → 触发 OAuth 或 dev Mock
|
||||||
*/
|
*/
|
||||||
async function initPortalSession(): Promise<boolean> {
|
async function initPortalSession(): Promise<boolean> {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const token = urlParams.get('token')
|
const token = urlParams.get('token')
|
||||||
const code = urlParams.get('code')
|
const code = urlParams.get('code')
|
||||||
|
const ssoToken = urlParams.get('sso_token')
|
||||||
|
|
||||||
|
// 0a. SSO 回调:URL 中有 sso_token 参数
|
||||||
|
// 流程: 企微浏览器 → init SSO → 企微授权 → callback 写入 token → 跳回 ?sso_token=xxx
|
||||||
|
// → 前端 verifyToken(一次性)→ 写 portal store → 重走原加载流程
|
||||||
|
if (ssoToken) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const verifyResult = await sso.verifyToken(ssoToken)
|
||||||
|
if (!verifyResult) {
|
||||||
|
error.value = 'SSO token 已过期,请重新进入'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// verifyToken 返回的 role 用于判断下一步 next
|
||||||
|
// 写 portal store(后续 fetchUserInfo 会覆盖,这里只用于决定跳哪儿)
|
||||||
|
// 清除 URL 中的 sso_token 参数,避免刷新时重复消费
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
// 继续往下走: 走 isAuthenticated 检测
|
||||||
|
console.log('[SSO] 验证成功:', verifyResult)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('SSO verify 失败:', err)
|
||||||
|
error.value = 'SSO 验证失败: ' + (err?.message || '未知错误')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0b. 企微浏览器(还没触发过 SSO)→ 主动 init
|
||||||
|
// 优先级高于老 QR 流程,因为企微浏览器走 QR 会被嫌麻烦
|
||||||
|
// 例外: dev 模式 + 普通 Chrome 不走 SSO(开发用 Mock)
|
||||||
|
if (!ssoToken && !token && !code && sso.isWeChatWork()) {
|
||||||
|
const isDev = import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === 'true'
|
||||||
|
if (!isDev) {
|
||||||
|
console.log('[SSO] 企微浏览器, 跳 SSO init')
|
||||||
|
const url = sso.buildInitUrl('/itdesk/')
|
||||||
|
window.location.href = url
|
||||||
|
return // 跳走,代码不执行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
||||||
if (code && !token) {
|
if (code && !token) {
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
site_name: WeCom IT 智能服务台
|
||||||
|
site_description: 企微 IT 智能服务台项目文档
|
||||||
|
site_author: Simon & Claude
|
||||||
|
docs_dir: docs
|
||||||
|
site_dir: site
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: zh
|
||||||
|
features:
|
||||||
|
- navigation.instant
|
||||||
|
- navigation.tabs
|
||||||
|
- search.suggest
|
||||||
|
nav:
|
||||||
|
- 首页: 01-项目总览与部署手册.md
|
||||||
|
- 架构:
|
||||||
|
- 总览: ARCHITECTURE.md
|
||||||
|
- 后台管理: ARCHITECTURE-admin.md
|
||||||
|
- 部署:
|
||||||
|
- 快速部署: DEPLOY-QUICK-v0.7.0.md
|
||||||
|
- 登录迁移: DEPLOY-LOGIN-MIGRATION-v0.7.0.md
|
||||||
|
- NAS: NAS部署指南.md
|
||||||
|
- 安全:
|
||||||
|
- OTP 二次验证: OTP二次验证实现.md
|
||||||
|
- 部署修复记录: IT服务台部署修复记录-2026-06-13.md
|
||||||
|
- E2E 验收: E2E-CHECKLIST-v0.7.0.md
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- codehilite
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
Reference in New Issue
Block a user