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): with get_connection() as (conn, cursor):
cursor.execute("DELETE FROM invites WHERE id = %s", (invite_id,)) 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 的食物分析器""" """使用 Claude Vision API 的食物分析器"""
def __init__(self, api_key: Optional[str] = None): def __init__(self, api_key: Optional[str] = None):
import os from ..core import database as db
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") self.api_key = api_key or db.get_api_key("anthropic")
def analyze(self, image_path: Path) -> dict: def analyze(self, image_path: Path) -> dict:
"""使用 Claude Vision 分析食物图片""" """使用 Claude Vision 分析食物图片"""

View File

@@ -1,20 +1,20 @@
"""DeepSeek Vision API 适配器""" """DeepSeek Vision API 适配器"""
import base64 import base64
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx import httpx
from ...core.calories import estimate_meal_calories from ...core.calories import estimate_meal_calories
from ...core import database as db
class DeepSeekVisionAnalyzer: class DeepSeekVisionAnalyzer:
"""DeepSeek Vision 食物识别分析器""" """DeepSeek Vision 食物识别分析器"""
def __init__(self, api_key: Optional[str] = None): 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" self.base_url = "https://api.deepseek.com/v1"
def analyze_image(self, image_path: Path) -> dict: def analyze_image(self, image_path: Path) -> dict:

View File

@@ -1,20 +1,20 @@
"""Qwen VL (通义千问视觉) API 适配器""" """Qwen VL (通义千问视觉) API 适配器"""
import base64 import base64
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx import httpx
from ...core.calories import estimate_meal_calories from ...core.calories import estimate_meal_calories
from ...core import database as db
class QwenVisionAnalyzer: class QwenVisionAnalyzer:
"""Qwen VL 食物识别分析器""" """Qwen VL 食物识别分析器"""
def __init__(self, api_key: Optional[str] = None): 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 兼容接口 # 阿里云百炼 OpenAI 兼容接口
self.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" 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": "邀请码已删除"} 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> </form>
</div> </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"> <div class="section">
<h2>账户</h2> <h2>账户</h2>
@@ -8509,6 +8565,9 @@ def get_settings_page_html() -> str:
document.getElementById('account-badge').style.display = 'block'; document.getElementById('account-badge').style.display = 'block';
// 显示管理后台入口 // 显示管理后台入口
document.getElementById('admin-entry').style.display = 'block'; document.getElementById('admin-entry').style.display = 'block';
// 显示 API 密钥配置区域
document.getElementById('api-keys-section').style.display = 'block';
loadApiKeys();
} }
// 保存到 localStorage // 保存到 localStorage
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));
@@ -8522,6 +8581,8 @@ def get_settings_page_html() -> str:
if (user.is_admin) { if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block'; document.getElementById('account-badge').style.display = 'block';
document.getElementById('admin-entry').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) { if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block'; document.getElementById('account-badge').style.display = 'block';
document.getElementById('admin-entry').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() { async function logoutAccount() {
if (!confirm('确定要退出登录吗?')) { if (!confirm('确定要退出登录吗?')) {