From 6a93e05ec3f3364be3eabcf467c16010fda38a0c Mon Sep 17 00:00:00 2001 From: 111 Date: Sat, 24 Jan 2026 10:05:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=94=E7=94=A8=E6=89=81=E5=B9=B3?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=20Token=20=E9=AA=8C=E8=AF=81=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/auth/verify 接口供外部应用验证 token - 简化应用管理:移除 tools 字段,每个应用独立存在 - 简化应用配置:移除 allowed_tools,专注于租户订阅 - 优化 Token 展示和复制功能 --- backend/app/routers/auth.py | 117 +++++++- frontend/src/views/app-config/index.vue | 356 +++++++++--------------- frontend/src/views/apps/index.vue | 122 ++------ 3 files changed, 264 insertions(+), 331 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 6657556..48e7bb0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,8 +1,9 @@ """认证路由""" +import hmac from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from sqlalchemy.orm import Session from ..database import get_db @@ -15,7 +16,10 @@ from ..services.auth import ( TokenData, UserInfo ) +from ..services.crypto import decrypt_config from ..models.user import User +from ..models.tenant_app import TenantApp +from ..models.tenant_wechat_app import TenantWechatApp router = APIRouter(prefix="/auth", tags=["认证"]) security = HTTPBearer() @@ -221,3 +225,114 @@ async def delete_user( db.commit() return {"success": True} + + +# ============ Token 验证 API(供外部应用调用) ============ + +class VerifyTokenRequest(BaseModel): + """Token 验证请求""" + token: str + app_code: Optional[str] = None # 可选,用于验证 token 是否属于特定应用 + + +class WechatConfig(BaseModel): + """企微配置""" + corp_id: Optional[str] = None + agent_id: Optional[str] = None + secret: Optional[str] = None + + +class VerifyTokenResponse(BaseModel): + """Token 验证响应""" + valid: bool + tenant_id: Optional[str] = None + app_code: Optional[str] = None + wechat_config: Optional[WechatConfig] = None + error: Optional[str] = None + + +@router.post("/verify", response_model=VerifyTokenResponse) +async def verify_token( + request: VerifyTokenRequest, + db: Session = Depends(get_db) +): + """ + 验证 Token 有效性(供外部应用调用,无需登录) + + 外部应用收到用户请求后,可调用此接口验证 token: + 1. 验证 token 是否存在且有效 + 2. 如传入 app_code,验证 token 是否属于该应用 + 3. 返回租户信息和企微配置 + + Args: + token: 访问令牌 + app_code: 应用代码(可选,用于验证 token 是否属于特定应用) + + Returns: + valid: 是否有效 + tenant_id: 租户ID + app_code: 应用代码 + wechat_config: 企微配置(如有) + """ + if not request.token: + return VerifyTokenResponse(valid=False, error="Token 不能为空") + + # 根据 token 查询租户应用配置 + query = db.query(TenantApp).filter( + TenantApp.access_token == request.token, + TenantApp.status == 1 + ) + + # 如果指定了 app_code,验证 token 是否属于该应用 + if request.app_code: + query = query.filter(TenantApp.app_code == request.app_code) + + tenant_app = query.first() + + if not tenant_app: + return VerifyTokenResponse(valid=False, error="Token 无效或已过期") + + # 获取关联的企微配置 + wechat_config = None + if tenant_app.wechat_app_id: + wechat_app = db.query(TenantWechatApp).filter( + TenantWechatApp.id == tenant_app.wechat_app_id, + TenantWechatApp.status == 1 + ).first() + + if wechat_app: + # 解密 secret + secret = None + if wechat_app.secret_encrypted: + try: + secret = decrypt_config(wechat_app.secret_encrypted) + except: + pass + + wechat_config = WechatConfig( + corp_id=wechat_app.corp_id, + agent_id=wechat_app.agent_id, + secret=secret + ) + + return VerifyTokenResponse( + valid=True, + tenant_id=tenant_app.tenant_id, + app_code=tenant_app.app_code, + wechat_config=wechat_config + ) + + +@router.get("/verify") +async def verify_token_get( + token: str, + app_code: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 验证 Token(GET 方式,便于简单测试) + """ + return await verify_token( + VerifyTokenRequest(token=token, app_code=app_code), + db + ) diff --git a/frontend/src/views/app-config/index.vue b/frontend/src/views/app-config/index.vue index 5c0e7ce..93f73ad 100644 --- a/frontend/src/views/app-config/index.vue +++ b/frontend/src/views/app-config/index.vue @@ -18,11 +18,11 @@ const query = reactive({ // 应用列表(从应用管理获取) const appList = ref([]) -const appToolsMap = ref({}) // app_code -> tools[] const appRequireJssdk = ref({}) // app_code -> require_jssdk +const appBaseUrl = ref({}) // app_code -> base_url // 企微应用列表(按租户) -const wechatAppList = ref([]) // 当前表单租户的企微应用列表 +const wechatAppList = ref([]) // 对话框 const dialogVisible = ref(false) @@ -31,10 +31,9 @@ const editingId = ref(null) const formRef = ref(null) const form = reactive({ tenant_id: '', - app_code: 'tools', + app_code: '', app_name: '', - wechat_app_id: null, // 关联的企微应用ID - allowed_tools: [] + wechat_app_id: null }) // 当前选择的应用是否需要 JS-SDK @@ -42,34 +41,15 @@ const currentAppRequireJssdk = computed(() => { return appRequireJssdk.value[form.app_code] || false }) -// 根据选择的应用获取工具选项 -const toolOptions = computed(() => { - const tools = appToolsMap.value[form.app_code] || [] - if (tools.length > 0) { - return tools.map(t => ({ label: t.name, value: t.code })) - } - // 默认工具列表(兼容旧数据) - return [ - { label: '高情商回复', value: 'high-eq' }, - { label: '头脑风暴', value: 'brainstorm' }, - { label: '面诊方案', value: 'consultation' }, - { label: '客户画像', value: 'customer-profile' }, - { label: '医疗合规', value: 'medical-compliance' } - ] -}) - const rules = { tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }], app_code: [{ required: true, message: '请选择应用', trigger: 'change' }] } -// 生成链接对话框 -const urlDialogVisible = ref(false) -const urlLoading = ref(false) -const currentRow = ref(null) -const selectedTool = ref('') -const generatedUrl = ref('') -const urlInfo = ref({}) +// 查看 Token 对话框 +const tokenDialogVisible = ref(false) +const currentToken = ref('') +const currentAppUrl = ref('') async function fetchApps() { try { @@ -77,15 +57,9 @@ async function fetchApps() { const apps = res.data.items || [] appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name })) - // 获取每个应用的工具列表和 JS-SDK 要求 for (const app of apps) { appRequireJssdk.value[app.app_code] = app.require_jssdk || false - try { - const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`) - appToolsMap.value[app.app_code] = toolsRes.data || [] - } catch (e) { - appToolsMap.value[app.app_code] = [] - } + appBaseUrl.value[app.app_code] = app.base_url || '' } } catch (e) { console.error('获取应用列表失败:', e) @@ -130,13 +104,12 @@ function handlePageChange(page) { function handleCreate() { editingId.value = null - dialogTitle.value = '新建应用配置' + dialogTitle.value = '新建应用订阅' Object.assign(form, { tenant_id: '', - app_code: 'tools', + app_code: '', app_name: '', - wechat_app_id: null, - allowed_tools: [] + wechat_app_id: null }) wechatAppList.value = [] dialogVisible.value = true @@ -144,20 +117,17 @@ function handleCreate() { async function handleEdit(row) { editingId.value = row.id - dialogTitle.value = '编辑应用配置' + dialogTitle.value = '编辑应用订阅' Object.assign(form, { tenant_id: row.tenant_id, app_code: row.app_code, app_name: row.app_name || '', - wechat_app_id: row.wechat_app_id || null, - allowed_tools: row.allowed_tools || [] + wechat_app_id: row.wechat_app_id || null }) - // 获取该租户的企微应用列表 await fetchWechatApps(row.tenant_id) dialogVisible.value = true } -// 租户ID变化时重新获取企微应用列表 async function handleTenantChange() { form.wechat_app_id = null await fetchWechatApps(form.tenant_id) @@ -174,7 +144,9 @@ async function handleSubmit() { ElMessage.success('更新成功') } else { const res = await api.post('/api/tenant-apps', data) - ElMessage.success(`创建成功,Access Token: ${res.data.access_token}`) + ElMessage.success(`创建成功`) + // 显示新生成的 token + showToken(res.data.access_token, form.app_code) } dialogVisible.value = false fetchList() @@ -184,7 +156,7 @@ async function handleSubmit() { } async function handleDelete(row) { - await ElMessageBox.confirm(`确定删除此配置吗?`, '提示', { + await ElMessageBox.confirm(`确定删除「${row.app_code}」的订阅配置吗?`, '提示', { type: 'warning' }) @@ -198,96 +170,43 @@ async function handleDelete(row) { } async function handleRegenerateToken(row) { - await ElMessageBox.confirm('重新生成 Access Token 将使旧的链接失效,确定继续?', '提示', { + await ElMessageBox.confirm('重新生成 Token 将使旧 Token 失效,确定继续?', '提示', { type: 'warning' }) try { const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`) - ElMessage.success(`新 Access Token: ${res.data.access_token}`) + showToken(res.data.access_token, row.app_code) fetchList() } catch (e) { // 错误已在拦截器处理 } } -// 生成链接功能 -function handleShowUrl(row) { - currentRow.value = row - selectedTool.value = '' - generatedUrl.value = '' - urlInfo.value = {} - urlDialogVisible.value = true +function showToken(token, appCode) { + currentToken.value = token + currentAppUrl.value = appBaseUrl.value[appCode] || '' + tokenDialogVisible.value = true } -async function handleGenerateUrl() { - if (!currentRow.value) return - - urlLoading.value = true - try { - const res = await api.post('/api/apps/generate-url', { - tenant_id: currentRow.value.tenant_id, - app_code: currentRow.value.app_code, - tool_code: selectedTool.value || null - }) - - if (res.data.success) { - generatedUrl.value = res.data.url - urlInfo.value = res.data - } else { - ElMessage.error(res.data.error || '生成失败') - } - } catch (e) { - console.error(e) - } finally { - urlLoading.value = false - } -} - -function handleCopyUrl() { - if (!generatedUrl.value) return - - navigator.clipboard.writeText(generatedUrl.value).then(() => { - ElMessage.success('链接已复制到剪贴板') - }).catch(() => { - // 降级方案 - const input = document.createElement('input') - input.value = generatedUrl.value - document.body.appendChild(input) - input.select() - document.execCommand('copy') - document.body.removeChild(input) - ElMessage.success('链接已复制到剪贴板') +function handleCopyToken() { + navigator.clipboard.writeText(currentToken.value).then(() => { + ElMessage.success('Token 已复制') }) } -// 获取当前行可选的工具 -const currentToolOptions = computed(() => { - if (!currentRow.value) return [] - const appTools = appToolsMap.value[currentRow.value.app_code] || [] - const allowedTools = currentRow.value.allowed_tools || [] - - if (appTools.length > 0) { - // 过滤出允许的工具 - if (allowedTools.length > 0) { - return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.code })) - } - return appTools.map(t => ({ label: t.name, value: t.code })) - } - - // 默认工具 - const defaultTools = [ - { label: '高情商回复', value: 'high-eq' }, - { label: '头脑风暴', value: 'brainstorm' }, - { label: '面诊方案', value: 'consultation' }, - { label: '客户画像', value: 'customer-profile' }, - { label: '医疗合规', value: 'medical-compliance' } - ] - if (allowedTools.length > 0) { - return defaultTools.filter(t => allowedTools.includes(t.value)) - } - return defaultTools -}) +function handleCopyUrl() { + const url = `${currentAppUrl.value}?token=${currentToken.value}` + navigator.clipboard.writeText(url).then(() => { + ElMessage.success('链接已复制') + }) +} + +async function handleViewToken(row) { + // 这里需要后端返回真实 token,暂时用 placeholder + // 实际生产中可能需要单独 API 获取 + showToken(row.access_token === '******' ? '需要调用API获取' : row.access_token, row.app_code) +} onMounted(() => { fetchApps() @@ -298,13 +217,19 @@ onMounted(() => {