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:
@@ -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
|
||||
|
||||
@@ -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 分析食物图片"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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('确定要退出登录吗?')) {
|
||||
|
||||
Reference in New Issue
Block a user