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

P0 修复:
- /api/ready import 错误 (_get_engine + settings.create_redis_client)
- 删 agent.otp_secret/otp_enabled 双字段 (migration 026)
- 重建 021_rbac migration (IF NOT EXISTS 兼容)

P1 新增:
- 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测)
- RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission)
- audit_log 模型 + migration 027 + 服务 + API
- 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue)

质量:
- pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0)
- conftest GBK patch 强制 UTF-8 读 .env
- .gitignore 排除 *.b64 (含 admin token 凭据)
- DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
This commit is contained in:
Simon
2026-06-22 17:38:47 +08:00
parent 2e6ac0f0ab
commit 78f60c6857
30 changed files with 2928 additions and 49 deletions
@@ -0,0 +1,111 @@
// =============================================================================
// 企微IT智能服务台 — 企微 SSO UA 检测 composable (v0.7.1 task #85)
// =============================================================================
// 解决问题: v0.7.0 hotfix1 用户反馈"企微工作台进入应用也要扫码"。
//
// 用法:
// const sso = useWeChatWorkSSO()
// if (sso.isWeChatWork()) {
// window.location.href = sso.buildInitUrl('/itdesk/')
// }
//
// 设计原则:
// 1. UA 检测: MicroMessenger / wxwork / wxwork.* 都算企微浏览器
// 2. 静默授权: scope=snsapi_base (用户无感,直接拿到 userid)
// 3. 降级策略: 非企微浏览器保留原 QR 扫码流程
// =============================================================================
export type SSOInitOptions = {
/** 登录成功后跳转路径,默认 /itdesk/ */
next?: string
}
/**
* 企微 UA 检测 + SSO URL 生成 composable
*/
export function useWeChatWorkSSO() {
/**
* 检测当前是否在企微浏览器中
*
* 匹配的 UA 关键字:
* - MicroMessenger: 微信内置浏览器(用户侧)
* - wxwork: 企业微信内置浏览器(企业侧,最常见)
* - wxwork/.*: 企微版本号
* - DingTalk: 钉钉(预留,暂不实现)
*
* 参考文档: https://developer.work.weixin.qq.com/document/path/91484
*/
function isWeChatWork(): boolean {
const ua = navigator.userAgent || ''
return /MicroMessenger/i.test(ua) || /wxwork/i.test(ua) || /\bDingTalk\b/i.test(ua)
}
/**
* 构建 SSO 初始化 URL
*
* 后端 /api/auth_wecom/sso/init 会:
* 1. 生成 state 存 Redis (5 分钟 TTL, 防 CSRF)
* 2. 拼企微 OAuth2 授权 URL
* 3. 302 跳转到企微授权页
*
* 企微授权页会:
* 1. 用户授权(静默, snsapi_base)
* 2. 回调 /api/auth_wecom/sso/callback?code=...&state=...
* 3. 后端用 code 换 userid, 查角色, 生成 SSO token
* 4. 302 跳转到 next + ?sso_token=xxx
*
* @example
* window.location.href = buildInitUrl('/itdesk/')
*/
function buildInitUrl(next: string = '/itdesk/'): string {
const params = new URLSearchParams({ next })
// 用相对路径走 nginx 反代(开发环境走 vite proxy)
return `/api/auth_wecom/sso/init?${params.toString()}`
}
/**
* 用 SSO token 换取用户身份(一次性 token, 用完即删)
*
* 后端会返回 { user_id, name, role }
* 前端用此身份调 portal.getRoles() 或写入 store
*/
async function verifyToken(ssoToken: string): Promise<{
user_id: string
name: string
role: string
} | null> {
try {
const apiBase = import.meta.env.VITE_API_BASE || '/api'
const resp = await fetch(`${apiBase}/auth_wecom/sso/verify?sso_token=${encodeURIComponent(ssoToken)}`)
const data = await resp.json()
if (data?.code === 0 && data.data) {
return data.data
}
return null
} catch (e) {
console.error('SSO verify 失败:', e)
return null
}
}
/**
* 便捷函数: 如果是企微浏览器则跳转 SSO, 否则返回 false
*
* 用法:
* if (tryAutoSSO({ next: '/itdesk/' })) return
* // 降级到 QR 扫码
*/
function tryAutoSSO(options: SSOInitOptions = {}): boolean {
if (!isWeChatWork()) return false
const url = buildInitUrl(options.next)
window.location.href = url
return true
}
return {
isWeChatWork,
buildInitUrl,
verifyToken,
tryAutoSSO,
}
}
+49 -1
View File
@@ -128,6 +128,9 @@ import { usePortalStore } from '@/stores/portal'
import { storeToRefs } from 'pinia'
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
import apiClient from '@/api/index'
import { useWeChatWorkSSO } from '@/composables/useWeChatWorkSSO'
const sso = useWeChatWorkSSO()
// 获取 Portal Store
const portalStore = usePortalStore()
@@ -142,12 +145,57 @@ const selectedRole = ref<string | null>(null)
/**
* 初始化门户会话(可重入)
* 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息
* 流程:
* 1. SSO 回调(企微浏览器走 SSO 才有 sso_token 参数)→ verifyToken → 走原流程
* 2. 企微浏览器但没拿到 sso_token → 主动 init SSO(走企微 OAuth2)
* 3. OAuth2 回调(普通浏览器走老 QR 流程,URL 中有 code 参数)
* 4. Token 跳转(从其他端跳过来,URL 中有 token 参数)
* 5. 本地缓存 → 没登录 → 触发 OAuth 或 dev Mock
*/
async function initPortalSession(): Promise<boolean> {
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token')
const code = urlParams.get('code')
const ssoToken = urlParams.get('sso_token')
// 0a. SSO 回调:URL 中有 sso_token 参数
// 流程: 企微浏览器 → init SSO → 企微授权 → callback 写入 token → 跳回 ?sso_token=xxx
// → 前端 verifyToken(一次性)→ 写 portal store → 重走原加载流程
if (ssoToken) {
loading.value = true
try {
const verifyResult = await sso.verifyToken(ssoToken)
if (!verifyResult) {
error.value = 'SSO token 已过期,请重新进入'
return
}
// verifyToken 返回的 role 用于判断下一步 next
// 写 portal store(后续 fetchUserInfo 会覆盖,这里只用于决定跳哪儿)
// 清除 URL 中的 sso_token 参数,避免刷新时重复消费
window.history.replaceState({}, '', window.location.pathname)
// 继续往下走: 走 isAuthenticated 检测
console.log('[SSO] 验证成功:', verifyResult)
} catch (err: any) {
console.error('SSO verify 失败:', err)
error.value = 'SSO 验证失败: ' + (err?.message || '未知错误')
return
} finally {
loading.value = false
}
}
// 0b. 企微浏览器(还没触发过 SSO)→ 主动 init
// 优先级高于老 QR 流程,因为企微浏览器走 QR 会被嫌麻烦
// 例外: dev 模式 + 普通 Chrome 不走 SSO(开发用 Mock)
if (!ssoToken && !token && !code && sso.isWeChatWork()) {
const isDev = import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === 'true'
if (!isDev) {
console.log('[SSO] 企微浏览器, 跳 SSO init')
const url = sso.buildInitUrl('/itdesk/')
window.location.href = url
return // 跳走,代码不执行
}
}
// 1. 企微 OAuth2 回调:URL 中有 code 参数
if (code && !token) {