Files
ds2api/routes/admin.py

735 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Admin API 路由 - 管理界面后端"""
import base64
import json
import os
import httpx
import asyncio
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from core.config import CONFIG, save_config, logger
from core.auth import account_queue, init_account_queue, get_queue_status, get_account_identifier
from core.deepseek import login_deepseek_via_account
router = APIRouter(prefix="/admin", tags=["admin"])
security = HTTPBearer(auto_error=False)
# Admin Key 验证
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "")
# Vercel 预配置(可通过环境变量设置)
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""验证 Admin 权限"""
if not ADMIN_KEY:
# 未配置 Admin Key允许访问开发模式
return True
if not credentials or credentials.credentials != ADMIN_KEY:
raise HTTPException(status_code=401, detail="Invalid admin key")
return True
# ----------------------------------------------------------------------
# Vercel 预配置信息
# ----------------------------------------------------------------------
@router.get("/vercel/config")
async def get_vercel_config(_: bool = Depends(verify_admin)):
"""获取预配置的 Vercel 信息(脱敏)"""
return JSONResponse(content={
"has_token": bool(VERCEL_TOKEN),
"project_id": VERCEL_PROJECT_ID,
"team_id": VERCEL_TEAM_ID,
"token_preview": VERCEL_TOKEN[:8] + "****" if VERCEL_TOKEN else "",
})
# ----------------------------------------------------------------------
# 配置管理
# ----------------------------------------------------------------------
@router.get("/config")
async def get_config(_: bool = Depends(verify_admin)):
"""获取当前配置(密码脱敏)"""
safe_config = {
"keys": CONFIG.get("keys", []),
"accounts": [],
"claude_model_mapping": CONFIG.get("claude_model_mapping", {}),
}
for acc in CONFIG.get("accounts", []):
safe_acc = {
"email": acc.get("email", ""),
"mobile": acc.get("mobile", ""),
"has_password": bool(acc.get("password")),
"has_token": bool(acc.get("token")),
}
safe_config["accounts"].append(safe_acc)
return JSONResponse(content=safe_config)
@router.post("/config")
async def update_config(request: Request, _: bool = Depends(verify_admin)):
"""更新完整配置"""
try:
new_config = await request.json()
# 更新 keys
if "keys" in new_config:
CONFIG["keys"] = new_config["keys"]
# 更新 accounts
if "accounts" in new_config:
CONFIG["accounts"] = new_config["accounts"]
init_account_queue() # 重新初始化账号队列
# 更新 claude_model_mapping
if "claude_model_mapping" in new_config:
CONFIG["claude_model_mapping"] = new_config["claude_model_mapping"]
save_config(CONFIG)
return JSONResponse(content={"success": True, "message": "配置已更新"})
except Exception as e:
logger.error(f"[update_config] 错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------------
# API Keys 管理
# ----------------------------------------------------------------------
@router.post("/keys")
async def add_key(request: Request, _: bool = Depends(verify_admin)):
"""添加 API Key"""
data = await request.json()
key = data.get("key", "").strip()
if not key:
raise HTTPException(status_code=400, detail="Key 不能为空")
if key in CONFIG.get("keys", []):
raise HTTPException(status_code=400, detail="Key 已存在")
if "keys" not in CONFIG:
CONFIG["keys"] = []
CONFIG["keys"].append(key)
save_config(CONFIG)
return JSONResponse(content={"success": True})
@router.delete("/keys/{key}")
async def delete_key(key: str, _: bool = Depends(verify_admin)):
"""删除 API Key"""
if key not in CONFIG.get("keys", []):
raise HTTPException(status_code=404, detail="Key 不存在")
CONFIG["keys"].remove(key)
save_config(CONFIG)
return JSONResponse(content={"success": True})
# ----------------------------------------------------------------------
# 账号管理
# ----------------------------------------------------------------------
@router.post("/accounts")
async def add_account(request: Request, _: bool = Depends(verify_admin)):
"""添加账号"""
data = await request.json()
email = data.get("email", "").strip()
mobile = data.get("mobile", "").strip()
password = data.get("password", "").strip()
if not password:
raise HTTPException(status_code=400, detail="密码不能为空")
if not email and not mobile:
raise HTTPException(status_code=400, detail="Email 或手机号至少填一个")
# 检查重复
for acc in CONFIG.get("accounts", []):
if email and acc.get("email") == email:
raise HTTPException(status_code=400, detail="该 Email 已存在")
if mobile and acc.get("mobile") == mobile:
raise HTTPException(status_code=400, detail="该手机号已存在")
new_account = {"password": password, "token": ""}
if email:
new_account["email"] = email
if mobile:
new_account["mobile"] = mobile
if "accounts" not in CONFIG:
CONFIG["accounts"] = []
CONFIG["accounts"].append(new_account)
init_account_queue()
save_config(CONFIG)
return JSONResponse(content={"success": True})
@router.delete("/accounts/{identifier}")
async def delete_account(identifier: str, _: bool = Depends(verify_admin)):
"""删除账号(通过 email 或 mobile"""
accounts = CONFIG.get("accounts", [])
for i, acc in enumerate(accounts):
if acc.get("email") == identifier or acc.get("mobile") == identifier:
accounts.pop(i)
init_account_queue()
save_config(CONFIG)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=404, detail="账号不存在")
# ----------------------------------------------------------------------
# 账号队列状态(监控)
# ----------------------------------------------------------------------
@router.get("/queue/status")
async def get_account_queue_status(_: bool = Depends(verify_admin)):
"""获取账号轮询队列状态"""
status = get_queue_status()
return JSONResponse(content=status)
# ----------------------------------------------------------------------
# 账号验证
# ----------------------------------------------------------------------
async def validate_single_account(account: dict) -> dict:
"""验证单个账号的有效性"""
acc_id = get_account_identifier(account)
result = {
"account": acc_id,
"valid": False,
"has_token": bool(account.get("token", "").strip()),
"message": "",
}
try:
# 如果已有 token尝试简单验证这里简化处理
if result["has_token"]:
result["valid"] = True
result["message"] = "已有有效 token"
else:
# 尝试登录
try:
login_deepseek_via_account(account)
result["valid"] = True
result["has_token"] = True
result["message"] = "登录成功"
except Exception as e:
result["valid"] = False
result["message"] = f"登录失败: {str(e)}"
except Exception as e:
result["message"] = f"验证出错: {str(e)}"
return result
@router.post("/accounts/validate")
async def validate_account(request: Request, _: bool = Depends(verify_admin)):
"""验证单个账号"""
data = await request.json()
identifier = data.get("identifier", "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="需要账号标识email 或 mobile")
# 查找账号
account = None
for acc in CONFIG.get("accounts", []):
if acc.get("email") == identifier or acc.get("mobile") == identifier:
account = acc
break
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
result = await validate_single_account(account)
# 如果验证成功且获取了新 token保存配置
if result["valid"] and result["has_token"]:
save_config(CONFIG)
return JSONResponse(content=result)
@router.post("/accounts/validate-all")
async def validate_all_accounts(_: bool = Depends(verify_admin)):
"""批量验证所有账号"""
accounts = CONFIG.get("accounts", [])
if not accounts:
return JSONResponse(content={
"total": 0,
"valid": 0,
"invalid": 0,
"results": [],
})
results = []
valid_count = 0
for acc in accounts:
result = await validate_single_account(acc)
results.append(result)
if result["valid"]:
valid_count += 1
# 添加小延迟避免请求过快
await asyncio.sleep(0.5)
# 保存可能更新的 token
save_config(CONFIG)
return JSONResponse(content={
"total": len(accounts),
"valid": valid_count,
"invalid": len(accounts) - valid_count,
"results": results,
})
# ----------------------------------------------------------------------
# 账号 API 测试(实际发送请求)
# ----------------------------------------------------------------------
async def test_account_api(account: dict, model: str = "deepseek-chat") -> dict:
"""测试单个账号的 API 调用能力"""
from curl_cffi import requests as cffi_requests
from core.deepseek import DEEPSEEK_CREATE_SESSION_URL, DEEPSEEK_CHAT_COMPLETION_URL, BASE_HEADERS
from core.pow import get_pow_response_direct
acc_id = get_account_identifier(account)
result = {
"account": acc_id,
"success": False,
"response_time": 0,
"message": "",
"model": model,
}
import time
start_time = time.time()
try:
# 确保有 token
token = account.get("token", "").strip()
if not token:
try:
login_deepseek_via_account(account)
token = account.get("token", "")
except Exception as e:
result["message"] = f"登录失败: {str(e)}"
return result
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
# 1. 创建会话
session_resp = cffi_requests.post(
DEEPSEEK_CREATE_SESSION_URL,
headers=headers,
json={"agent": "chat"},
impersonate="safari15_3",
timeout=15,
)
if session_resp.status_code != 200:
result["message"] = f"创建会话失败: HTTP {session_resp.status_code}"
return result
session_data = session_resp.json()
if session_data.get("code") != 0:
result["message"] = f"创建会话失败: {session_data.get('msg', 'Unknown error')}"
# token 可能过期,清除它
account["token"] = ""
return result
session_id = session_data["data"]["biz_data"]["id"]
result["success"] = True
result["message"] = "API 测试成功"
result["response_time"] = round((time.time() - start_time) * 1000) # ms
except Exception as e:
result["message"] = f"测试失败: {str(e)}"
return result
@router.post("/accounts/test")
async def test_single_account(request: Request, _: bool = Depends(verify_admin)):
"""测试单个账号的 API 调用"""
data = await request.json()
identifier = data.get("identifier", "")
model = data.get("model", "deepseek-chat")
if not identifier:
raise HTTPException(status_code=400, detail="需要账号标识email 或 mobile")
# 查找账号
account = None
for acc in CONFIG.get("accounts", []):
if acc.get("email") == identifier or acc.get("mobile") == identifier:
account = acc
break
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
result = await test_account_api(account, model)
# 保存可能更新的 token
save_config(CONFIG)
return JSONResponse(content=result)
@router.post("/accounts/test-all")
async def test_all_accounts(request: Request, _: bool = Depends(verify_admin)):
"""批量测试所有账号的 API 调用"""
data = await request.json()
model = data.get("model", "deepseek-chat")
accounts = CONFIG.get("accounts", [])
if not accounts:
return JSONResponse(content={
"total": 0,
"success": 0,
"failed": 0,
"results": [],
})
results = []
success_count = 0
for acc in accounts:
result = await test_account_api(acc, model)
results.append(result)
if result["success"]:
success_count += 1
# 添加小延迟避免请求过快
await asyncio.sleep(1)
# 保存可能更新的 token
save_config(CONFIG)
return JSONResponse(content={
"total": len(accounts),
"success": success_count,
"failed": len(accounts) - success_count,
"results": results,
})
# ----------------------------------------------------------------------
# 批量导入
# ----------------------------------------------------------------------
@router.post("/import")
async def batch_import(request: Request, _: bool = Depends(verify_admin)):
"""批量导入配置 (JSON 格式)"""
try:
data = await request.json()
imported_keys = 0
imported_accounts = 0
# 导入 keys
if "keys" in data:
for key in data["keys"]:
if key not in CONFIG.get("keys", []):
if "keys" not in CONFIG:
CONFIG["keys"] = []
CONFIG["keys"].append(key)
imported_keys += 1
# 导入 accounts
if "accounts" in data:
existing_ids = set()
for acc in CONFIG.get("accounts", []):
existing_ids.add(acc.get("email", ""))
existing_ids.add(acc.get("mobile", ""))
for acc in data["accounts"]:
acc_id = acc.get("email", "") or acc.get("mobile", "")
if acc_id and acc_id not in existing_ids:
if "accounts" not in CONFIG:
CONFIG["accounts"] = []
CONFIG["accounts"].append(acc)
existing_ids.add(acc_id)
imported_accounts += 1
init_account_queue()
save_config(CONFIG)
return JSONResponse(content={
"success": True,
"imported_keys": imported_keys,
"imported_accounts": imported_accounts,
})
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="无效的 JSON 格式")
except Exception as e:
logger.error(f"[batch_import] 错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------------
# API 测试
# ----------------------------------------------------------------------
@router.post("/test")
async def test_api(request: Request, _: bool = Depends(verify_admin)):
"""测试 API 调用"""
try:
data = await request.json()
model = data.get("model", "deepseek-chat")
message = data.get("message", "你好")
api_key = data.get("api_key", "")
if not api_key:
# 使用配置中的第一个 key
keys = CONFIG.get("keys", [])
if not keys:
raise HTTPException(status_code=400, detail="没有可用的 API Key")
api_key = keys[0]
# 构造请求
host = request.headers.get("host", "localhost:5001")
scheme = "https" if "vercel" in host.lower() else "http"
base_url = f"{scheme}://{host}"
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model,
"messages": [{"role": "user", "content": message}],
"stream": False,
},
)
return JSONResponse(content={
"success": response.status_code == 200,
"status_code": response.status_code,
"response": response.json() if response.status_code == 200 else response.text,
})
except Exception as e:
logger.error(f"[test_api] 错误: {e}")
return JSONResponse(content={
"success": False,
"error": str(e),
})
# ----------------------------------------------------------------------
# Vercel 同步
# ----------------------------------------------------------------------
@router.post("/vercel/sync")
async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
"""同步配置到 Vercel 并触发重新部署"""
try:
data = await request.json()
vercel_token = data.get("vercel_token", "")
project_id = data.get("project_id", "")
team_id = data.get("team_id", "") # 可选
auto_validate = data.get("auto_validate", True) # 默认自动验证
save_vercel_credentials = data.get("save_credentials", True) # 是否保存 Vercel 凭证
# 支持使用预配置的 token
use_preconfig = vercel_token == "__USE_PRECONFIG__" or not vercel_token
if use_preconfig:
vercel_token = VERCEL_TOKEN
if not project_id:
project_id = VERCEL_PROJECT_ID
if not team_id:
team_id = VERCEL_TEAM_ID
if not vercel_token or not project_id:
raise HTTPException(status_code=400, detail="需要 Vercel Token 和 Project ID可通过环境变量 VERCEL_TOKEN 和 VERCEL_PROJECT_ID 预配置)")
# 自动验证所有无 token 的账号
validated_count = 0
failed_accounts = []
if auto_validate:
accounts = CONFIG.get("accounts", [])
for acc in accounts:
acc_id = get_account_identifier(acc)
if not acc.get("token", "").strip():
try:
logger.info(f"[sync_to_vercel] 自动验证账号: {acc_id}")
login_deepseek_via_account(acc)
validated_count += 1
except Exception as e:
logger.warning(f"[sync_to_vercel] 账号 {acc_id} 验证失败: {e}")
failed_accounts.append(acc_id)
await asyncio.sleep(0.5) # 避免请求过快
# 准备配置 JSON
config_json = json.dumps(CONFIG, ensure_ascii=False, separators=(",", ":"))
config_b64 = base64.b64encode(config_json.encode("utf-8")).decode("utf-8")
headers = {"Authorization": f"Bearer {vercel_token}"}
base_url = "https://api.vercel.com"
async with httpx.AsyncClient(timeout=30.0) as client:
# 1. 获取现有环境变量
params = {"teamId": team_id} if team_id else {}
env_resp = await client.get(
f"{base_url}/v9/projects/{project_id}/env",
headers=headers,
params=params,
)
if env_resp.status_code != 200:
raise HTTPException(status_code=env_resp.status_code, detail=f"获取环境变量失败: {env_resp.text}")
env_vars = env_resp.json().get("envs", [])
existing_env = None
for env in env_vars:
if env.get("key") == "DS2API_CONFIG_JSON":
existing_env = env
break
# 2. 更新或创建环境变量
if existing_env:
# 更新
env_id = existing_env["id"]
update_resp = await client.patch(
f"{base_url}/v9/projects/{project_id}/env/{env_id}",
headers=headers,
params=params,
json={"value": config_b64},
)
if update_resp.status_code not in [200, 201]:
raise HTTPException(status_code=update_resp.status_code, detail=f"更新环境变量失败: {update_resp.text}")
else:
# 创建
create_resp = await client.post(
f"{base_url}/v10/projects/{project_id}/env",
headers=headers,
params=params,
json={
"key": "DS2API_CONFIG_JSON",
"value": config_b64,
"type": "encrypted",
"target": ["production", "preview"],
},
)
if create_resp.status_code not in [200, 201]:
raise HTTPException(status_code=create_resp.status_code, detail=f"创建环境变量失败: {create_resp.text}")
# 2.5 保存 Vercel 凭证到环境变量(方便后续快捷同步)
saved_credentials = []
if save_vercel_credentials and not use_preconfig:
# 要保存的凭证列表
creds_to_save = [
("VERCEL_TOKEN", vercel_token),
("VERCEL_PROJECT_ID", project_id),
]
if team_id:
creds_to_save.append(("VERCEL_TEAM_ID", team_id))
for key, value in creds_to_save:
# 检查是否已存在
existing = None
for env in env_vars:
if env.get("key") == key:
existing = env
break
if existing:
# 更新
upd_resp = await client.patch(
f"{base_url}/v9/projects/{project_id}/env/{existing['id']}",
headers=headers,
params=params,
json={"value": value},
)
if upd_resp.status_code in [200, 201]:
saved_credentials.append(key)
else:
# 创建
crt_resp = await client.post(
f"{base_url}/v10/projects/{project_id}/env",
headers=headers,
params=params,
json={
"key": key,
"value": value,
"type": "encrypted",
"target": ["production", "preview"],
},
)
if crt_resp.status_code in [200, 201]:
saved_credentials.append(key)
# 3. 触发重新部署 (获取最新的 git 信息并创建新部署)
# 获取项目信息
project_resp = await client.get(
f"{base_url}/v9/projects/{project_id}",
headers=headers,
params=params,
)
if project_resp.status_code == 200:
project_data = project_resp.json()
repo = project_data.get("link", {})
if repo.get("type") == "github":
# 使用 GitHub 信息创建部署
deploy_resp = await client.post(
f"{base_url}/v13/deployments",
headers=headers,
params=params,
json={
"name": project_id,
"project": project_id,
"target": "production",
"gitSource": {
"type": "github",
"repoId": repo.get("repoId"),
"ref": repo.get("productionBranch", "main"),
},
},
)
if deploy_resp.status_code in [200, 201]:
deploy_data = deploy_resp.json()
result = {
"success": True,
"message": "配置已同步,正在重新部署...",
"deployment_url": deploy_data.get("url"),
"validated_accounts": validated_count,
}
if failed_accounts:
result["failed_accounts"] = failed_accounts
if saved_credentials:
result["saved_credentials"] = saved_credentials
return JSONResponse(content=result)
# 如果无法自动部署,返回成功但提示手动部署
result = {
"success": True,
"message": "配置已同步到 Vercel请手动触发重新部署",
"manual_deploy_required": True,
"validated_accounts": validated_count,
}
if failed_accounts:
result["failed_accounts"] = failed_accounts
if saved_credentials:
result["saved_credentials"] = saved_credentials
return JSONResponse(content=result)
except HTTPException:
raise
except Exception as e:
logger.error(f"[sync_to_vercel] 错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------------
# 导出配置
# ----------------------------------------------------------------------
@router.get("/export")
async def export_config(_: bool = Depends(verify_admin)):
"""导出完整配置JSON 和 Base64"""
config_json = json.dumps(CONFIG, ensure_ascii=False, separators=(",", ":"))
config_b64 = base64.b64encode(config_json.encode("utf-8")).decode("utf-8")
return JSONResponse(content={
"json": config_json,
"base64": config_b64,
})