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