From eb88d2638f2af71284c3a3492426a7a6d2f8c37b Mon Sep 17 00:00:00 2001 From: "liweiliang0905@gmail.com" Date: Fri, 23 Jan 2026 22:32:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20Keys=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98=E5=82=A8=E5=92=8C=20Web=20?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - database.py: 新增 get_api_key/set_api_key/delete_api_key/get_all_api_keys 函数 - qwen.py/deepseek.py/analyzer.py: 改用 db.get_api_key() 读取配置 - app.py: 新增管理员 API 接口 (/api/admin/api-keys) - settings 页面: 管理员可见的 API 密钥配置区域 API Key 优先级: 数据库 > 环境变量 Co-Authored-By: Claude Opus 4.5 --- src/vitals/core/database.py | 104 +++++++++++++ src/vitals/vision/analyzer.py | 4 +- src/vitals/vision/providers/deepseek.py | 4 +- src/vitals/vision/providers/qwen.py | 4 +- src/vitals/web/app.py | 194 ++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 6 deletions(-) diff --git a/src/vitals/core/database.py b/src/vitals/core/database.py index 25cd0f8..b85b3de 100644 --- a/src/vitals/core/database.py +++ b/src/vitals/core/database.py @@ -984,3 +984,107 @@ def delete_invite(invite_id: int): """删除邀请码""" with get_connection() as (conn, cursor): cursor.execute("DELETE FROM invites WHERE id = %s", (invite_id,)) + + +# ===== API Key 管理 ===== + +# API Key 与环境变量的映射 +API_KEY_ENV_MAP = { + "dashscope": "DASHSCOPE_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", +} + +# API Key 显示名称 +API_KEY_NAMES = { + "dashscope": "通义千问 (DashScope)", + "deepseek": "DeepSeek", + "anthropic": "Anthropic (Claude)", +} + + +def get_api_key(provider: str) -> Optional[str]: + """获取 API Key,数据库优先,环境变量备用""" + with get_connection() as (conn, cursor): + cursor.execute( + "SELECT value FROM config WHERE `key` = %s", + (f"api_key.{provider}",) + ) + row = cursor.fetchone() + if row and row["value"]: + return row["value"] + + # 回退到环境变量 + env_var = API_KEY_ENV_MAP.get(provider) + return os.environ.get(env_var) if env_var else None + + +def set_api_key(provider: str, value: str): + """保存 API Key 到数据库""" + if provider not in API_KEY_ENV_MAP: + raise ValueError(f"Unknown provider: {provider}") + + with get_connection() as (conn, cursor): + cursor.execute( + "REPLACE INTO config (`key`, value) VALUES (%s, %s)", + (f"api_key.{provider}", value) + ) + + +def delete_api_key(provider: str): + """从数据库删除 API Key(将回退到环境变量)""" + with get_connection() as (conn, cursor): + cursor.execute( + "DELETE FROM config WHERE `key` = %s", + (f"api_key.{provider}",) + ) + + +def get_all_api_keys(masked: bool = True) -> dict: + """获取所有 API Keys 状态 + + Args: + masked: 是否掩码显示值 + + Returns: + dict: {provider: {"name": 显示名, "value": 值或掩码, "source": "database"|"env"|None}} + """ + result = {} + + with get_connection() as (conn, cursor): + for provider, env_var in API_KEY_ENV_MAP.items(): + # 查数据库 + cursor.execute( + "SELECT value FROM config WHERE `key` = %s", + (f"api_key.{provider}",) + ) + row = cursor.fetchone() + + db_value = row["value"] if row and row["value"] else None + env_value = os.environ.get(env_var) + + # 确定来源和值 + if db_value: + source = "database" + value = db_value + elif env_value: + source = "env" + value = env_value + else: + source = None + value = None + + # 掩码处理 + if masked and value: + if len(value) > 8: + value = value[:4] + "*" * (len(value) - 8) + value[-4:] + else: + value = "*" * len(value) + + result[provider] = { + "name": API_KEY_NAMES.get(provider, provider), + "value": value, + "source": source, + } + + return result diff --git a/src/vitals/vision/analyzer.py b/src/vitals/vision/analyzer.py index c1b679f..c477173 100644 --- a/src/vitals/vision/analyzer.py +++ b/src/vitals/vision/analyzer.py @@ -31,8 +31,8 @@ class ClaudeFoodAnalyzer(FoodAnalyzer): """使用 Claude Vision API 的食物分析器""" def __init__(self, api_key: Optional[str] = None): - import os - self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") + from ..core import database as db + self.api_key = api_key or db.get_api_key("anthropic") def analyze(self, image_path: Path) -> dict: """使用 Claude Vision 分析食物图片""" diff --git a/src/vitals/vision/providers/deepseek.py b/src/vitals/vision/providers/deepseek.py index 92d902b..0e0eb4b 100644 --- a/src/vitals/vision/providers/deepseek.py +++ b/src/vitals/vision/providers/deepseek.py @@ -1,20 +1,20 @@ """DeepSeek Vision API 适配器""" import base64 -import os from pathlib import Path from typing import Optional import httpx from ...core.calories import estimate_meal_calories +from ...core import database as db class DeepSeekVisionAnalyzer: """DeepSeek Vision 食物识别分析器""" def __init__(self, api_key: Optional[str] = None): - self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY") + self.api_key = api_key or db.get_api_key("deepseek") self.base_url = "https://api.deepseek.com/v1" def analyze_image(self, image_path: Path) -> dict: diff --git a/src/vitals/vision/providers/qwen.py b/src/vitals/vision/providers/qwen.py index 0813771..c2837b0 100644 --- a/src/vitals/vision/providers/qwen.py +++ b/src/vitals/vision/providers/qwen.py @@ -1,20 +1,20 @@ """Qwen VL (通义千问视觉) API 适配器""" import base64 -import os from pathlib import Path from typing import Optional import httpx from ...core.calories import estimate_meal_calories +from ...core import database as db class QwenVisionAnalyzer: """Qwen VL 食物识别分析器""" def __init__(self, api_key: Optional[str] = None): - self.api_key = api_key or os.environ.get("DASHSCOPE_API_KEY") + self.api_key = api_key or db.get_api_key("dashscope") # 阿里云百炼 OpenAI 兼容接口 self.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" diff --git a/src/vitals/web/app.py b/src/vitals/web/app.py index 9fd759f..3f61434 100644 --- a/src/vitals/web/app.py +++ b/src/vitals/web/app.py @@ -726,6 +726,50 @@ async def admin_delete_invite(invite_id: int, admin: User = Depends(require_admi return {"message": "邀请码已删除"} +# ===== API 密钥管理(管理员) ===== + + +@app.get("/api/admin/api-keys") +async def admin_get_api_keys(admin: User = Depends(require_admin)): + """获取所有 API Keys 状态(掩码显示)""" + return db.get_all_api_keys(masked=True) + + +@app.get("/api/admin/api-keys/{provider}") +async def admin_get_api_key(provider: str, admin: User = Depends(require_admin)): + """获取指定 API Key 完整值""" + if provider not in db.API_KEY_ENV_MAP: + raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}") + value = db.get_api_key(provider) + return { + "provider": provider, + "name": db.API_KEY_NAMES.get(provider, provider), + "value": value, + } + + +class ApiKeyInput(BaseModel): + value: str + + +@app.put("/api/admin/api-keys/{provider}") +async def admin_set_api_key(provider: str, data: ApiKeyInput, admin: User = Depends(require_admin)): + """设置/更新 API Key""" + if provider not in db.API_KEY_ENV_MAP: + raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}") + db.set_api_key(provider, data.value) + return {"message": "API Key 已保存", "provider": provider} + + +@app.delete("/api/admin/api-keys/{provider}") +async def admin_delete_api_key(provider: str, admin: User = Depends(require_admin)): + """删除 API Key(回退到环境变量)""" + if provider not in db.API_KEY_ENV_MAP: + raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}") + db.delete_api_key(provider) + return {"message": "API Key 已删除,将使用环境变量配置", "provider": provider} + + # ===== 页面路由 ===== @@ -8030,6 +8074,18 @@ def get_settings_page_html() -> str: + + +

账户

@@ -8509,6 +8565,9 @@ def get_settings_page_html() -> str: document.getElementById('account-badge').style.display = 'block'; // 显示管理后台入口 document.getElementById('admin-entry').style.display = 'block'; + // 显示 API 密钥配置区域 + document.getElementById('api-keys-section').style.display = 'block'; + loadApiKeys(); } // 保存到 localStorage localStorage.setItem('user', JSON.stringify(user)); @@ -8522,6 +8581,8 @@ def get_settings_page_html() -> str: if (user.is_admin) { document.getElementById('account-badge').style.display = 'block'; document.getElementById('admin-entry').style.display = 'block'; + document.getElementById('api-keys-section').style.display = 'block'; + loadApiKeys(); } } } @@ -8536,11 +8597,144 @@ def get_settings_page_html() -> str: if (user.is_admin) { document.getElementById('account-badge').style.display = 'block'; document.getElementById('admin-entry').style.display = 'block'; + document.getElementById('api-keys-section').style.display = 'block'; + loadApiKeys(); } } } } + // API 密钥管理 + async function loadApiKeys() { + try { + const response = await fetch('/api/admin/api-keys', { credentials: 'same-origin' }); + if (response.ok) { + const keys = await response.json(); + renderApiKeys(keys); + } else { + document.getElementById('api-keys-list').innerHTML = '
加载失败,请刷新重试
'; + } + } catch (error) { + console.error('加载 API 密钥失败:', error); + document.getElementById('api-keys-list').innerHTML = '
加载失败,请刷新重试
'; + } + } + + function renderApiKeys(keys) { + const container = document.getElementById('api-keys-list'); + const providers = Object.keys(keys); + + container.innerHTML = providers.map(provider => { + const key = keys[provider]; + const hasValue = key.value !== null; + const sourceLabel = key.source === 'database' ? '数据库' : (key.source === 'env' ? '环境变量' : '未配置'); + const sourceColor = key.source === 'database' ? '#10B981' : (key.source === 'env' ? '#F59E0B' : '#94A3B8'); + + return ` +
+
+
+
${key.name}
+
+ 来源: ${sourceLabel} +
+
+
+ ${hasValue ? `` : ''} + ${key.source === 'database' ? `` : ''} +
+
+
+ + +
+
+ `; + }).join(''); + } + + async function toggleApiKeyVisibility(provider) { + const input = document.getElementById(`api-key-${provider}`); + if (input.type === 'password') { + // 获取完整值 + try { + const response = await fetch(`/api/admin/api-keys/${provider}`, { credentials: 'same-origin' }); + if (response.ok) { + const data = await response.json(); + input.value = data.value || ''; + input.type = 'text'; + // 更新按钮文字 + event.target.textContent = '隐藏'; + } + } catch (error) { + console.error('获取 API 密钥失败:', error); + showAlert('获取失败', 'error'); + } + } else { + input.type = 'password'; + event.target.textContent = '显示'; + // 重新加载掩码值 + loadApiKeys(); + } + } + + async function saveApiKey(provider) { + const input = document.getElementById(`api-key-${provider}`); + const value = input.value.trim(); + + if (!value) { + showAlert('请输入 API Key', 'error'); + return; + } + + try { + const response = await fetch(`/api/admin/api-keys/${provider}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ value }) + }); + + if (response.ok) { + showAlert('API Key 已保存', 'success'); + loadApiKeys(); + } else { + const error = await response.json(); + showAlert(error.detail || '保存失败', 'error'); + } + } catch (error) { + console.error('保存 API 密钥失败:', error); + showAlert('保存失败', 'error'); + } + } + + async function deleteApiKey(provider) { + if (!confirm('确定要删除此 API Key 吗?删除后将回退到使用环境变量配置。')) { + return; + } + + try { + const response = await fetch(`/api/admin/api-keys/${provider}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + + if (response.ok) { + showAlert('API Key 已删除', 'success'); + loadApiKeys(); + } else { + const error = await response.json(); + showAlert(error.detail || '删除失败', 'error'); + } + } catch (error) { + console.error('删除 API 密钥失败:', error); + showAlert('删除失败', 'error'); + } + } + // 退出登录 async function logoutAccount() { if (!confirm('确定要退出登录吗?')) {