feat: API Keys 支持数据库存储和 Web 界面管理

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 22:32:17 +08:00
parent dba052bb5e
commit eb88d2638f
5 changed files with 304 additions and 6 deletions

View File

@@ -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

View File

@@ -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 分析食物图片"""

View File

@@ -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:

View File

@@ -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"

View File

@@ -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:
</form>
</div>
<!-- API 密钥配置(仅管理员可见) -->
<div class="section" id="api-keys-section" style="display: none;">
<h2>API 密钥配置</h2>
<p style="color: #64748B; margin-bottom: 20px;">配置 AI 服务的 API 密钥,用于食物图片识别功能</p>
<div id="api-keys-list">
<div style="text-align: center; padding: 40px; color: #94A3B8;">
加载中...
</div>
</div>
</div>
<!-- 账户管理 -->
<div class="section">
<h2>账户</h2>
@@ -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 = '<div style="color: #EF4444; padding: 20px;">加载失败,请刷新重试</div>';
}
} catch (error) {
console.error('加载 API 密钥失败:', error);
document.getElementById('api-keys-list').innerHTML = '<div style="color: #EF4444; padding: 20px;">加载失败,请刷新重试</div>';
}
}
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 `
<div class="api-key-item" style="padding: 20px; border: 2px solid #E2E8F0; border-radius: 8px; margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<div style="font-weight: 600; color: #1E293B;">${key.name}</div>
<div style="font-size: 0.85rem; color: ${sourceColor}; margin-top: 4px;">
来源: ${sourceLabel}
</div>
</div>
<div style="display: flex; gap: 8px;">
${hasValue ? `<button class="btn btn-secondary btn-small" onclick="toggleApiKeyVisibility('${provider}')">显示</button>` : ''}
${key.source === 'database' ? `<button class="btn btn-danger btn-small" onclick="deleteApiKey('${provider}')">删除</button>` : ''}
</div>
</div>
<div style="display: flex; gap: 10px;">
<input type="password" id="api-key-${provider}" class="api-key-input"
value="${hasValue ? key.value : ''}"
placeholder="${hasValue ? '' : '未配置,请输入 API Key'}"
style="flex: 1; padding: 10px 14px; border: 2px solid #E2E8F0; border-radius: 6px; font-family: monospace;">
<button class="btn btn-primary btn-small" onclick="saveApiKey('${provider}')">保存</button>
</div>
</div>
`;
}).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('确定要退出登录吗?')) {