Files
ds2api/routes/admin.py

1038 lines
37 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
import time
import hashlib
import hmac
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", "")
# JWT 配置
JWT_SECRET = os.getenv("DS2API_JWT_SECRET", ADMIN_KEY or "ds2api-default-secret")
JWT_EXPIRE_HOURS = int(os.getenv("DS2API_JWT_EXPIRE_HOURS", "24"))
# Vercel 预配置(可通过环境变量设置)
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
# ----------------------------------------------------------------------
# JWT 工具函数(轻量实现,无需额外依赖)
# ----------------------------------------------------------------------
def _b64_encode(data: bytes) -> str:
"""Base64 URL 安全编码"""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64_decode(data: str) -> bytes:
"""Base64 URL 安全解码"""
padding = 4 - len(data) % 4
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
def create_jwt_token(expire_hours: int = None) -> str:
"""创建 JWT Token"""
if expire_hours is None:
expire_hours = JWT_EXPIRE_HOURS
now = int(time.time())
payload = {
"iat": now,
"exp": now + expire_hours * 3600,
"type": "admin"
}
header = {"alg": "HS256", "typ": "JWT"}
header_b64 = _b64_encode(json.dumps(header).encode())
payload_b64 = _b64_encode(json.dumps(payload).encode())
signature = hmac.new(
JWT_SECRET.encode(),
f"{header_b64}.{payload_b64}".encode(),
hashlib.sha256
).digest()
signature_b64 = _b64_encode(signature)
return f"{header_b64}.{payload_b64}.{signature_b64}"
def verify_jwt_token(token: str) -> dict:
"""验证 JWT Token返回 payload 或抛出异常"""
try:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Invalid token format")
header_b64, payload_b64, signature_b64 = parts
# 验证签名
expected_sig = hmac.new(
JWT_SECRET.encode(),
f"{header_b64}.{payload_b64}".encode(),
hashlib.sha256
).digest()
actual_sig = _b64_decode(signature_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
raise ValueError("Invalid signature")
# 解析 payload
payload = json.loads(_b64_decode(payload_b64))
# 检查过期
if payload.get("exp", 0) < time.time():
raise ValueError("Token expired")
return payload
except Exception as e:
raise ValueError(f"Token verification failed: {e}")
# ----------------------------------------------------------------------
# 登录端点
# ----------------------------------------------------------------------
@router.post("/login")
async def admin_login(request: Request):
"""管理员登录,返回 JWT Token"""
try:
data = await request.json()
except:
data = {}
admin_key = data.get("admin_key", "")
# 开发模式:未配置 ADMIN_KEY 时允许任意登录
if not ADMIN_KEY:
token = create_jwt_token()
return JSONResponse(content={
"success": True,
"token": token,
"expires_in": JWT_EXPIRE_HOURS * 3600,
"message": "开发模式:未配置 ADMIN_KEY"
})
# 验证 admin key
if admin_key != ADMIN_KEY:
raise HTTPException(status_code=401, detail="管理密钥错误")
token = create_jwt_token()
return JSONResponse(content={
"success": True,
"token": token,
"expires_in": JWT_EXPIRE_HOURS * 3600,
})
@router.get("/verify")
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""验证当前 Token 是否有效"""
if not credentials:
raise HTTPException(status_code=401, detail="未提供认证信息")
token = credentials.credentials
# 先尝试 JWT 验证
try:
payload = verify_jwt_token(token)
return JSONResponse(content={
"valid": True,
"expires_at": payload.get("exp"),
"remaining": payload.get("exp", 0) - int(time.time())
})
except:
pass
# 回退到直接 admin key 验证(兼容旧方式)
if ADMIN_KEY and token == ADMIN_KEY:
return JSONResponse(content={"valid": True, "type": "admin_key"})
raise HTTPException(status_code=401, detail="Token 无效或已过期")
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""验证 Admin 权限(支持 JWT 和直接 admin key"""
if not ADMIN_KEY:
# 未配置 Admin Key允许访问开发模式
return True
if not credentials:
raise HTTPException(status_code=401, detail="未提供认证信息")
token = credentials.credentials
# 先尝试 JWT 验证
try:
verify_jwt_token(token)
return True
except:
pass
# 回退到直接 admin key 验证
if token == ADMIN_KEY:
return True
raise HTTPException(status_code=401, detail="认证失败Token 无效或已过期")
# ----------------------------------------------------------------------
# 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", []):
token = acc.get("token", "")
safe_acc = {
"email": acc.get("email", ""),
"mobile": acc.get("mobile", ""),
"has_password": bool(acc.get("password")),
"has_token": bool(token),
"token_preview": token[:12] + "..." if len(token) > 12 else "" if token else "",
}
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", message: str = "") -> dict:
"""测试单个账号的 API 调用能力
如果提供 message会发送实际请求并返回 AI 回复;
否则只快速测试创建会话。
"""
from curl_cffi import requests as cffi_requests
from core.deepseek import DEEPSEEK_CREATE_SESSION_URL, DEEPSEEK_COMPLETION_URL, BASE_HEADERS
from core.pow import get_pow_response, compute_pow_answer, WASM_PATH
from core.config import WASM_PATH
from core.models import get_model_config
import json
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}"}
# 创建会话来测试 API 可用性
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.get("data", {}).get("biz_data", {}).get("id")
# 如果没有消息,只测试会话创建
if not message.strip():
result["success"] = True
result["message"] = "API 测试成功(仅会话创建)"
result["response_time"] = round((time.time() - start_time) * 1000)
return result
# 获取 PoW
pow_url = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
pow_resp = cffi_requests.post(
pow_url,
headers=headers,
json={"target_path": "/api/v0/chat/completion"},
timeout=30,
impersonate="safari15_3",
)
pow_data = pow_resp.json()
if pow_data.get("code") != 0:
result["message"] = f"获取 PoW 失败: {pow_data.get('msg')}"
return result
# 计算 PoW 答案
import base64
challenge = pow_data["data"]["biz_data"]["challenge"]
try:
answer = compute_pow_answer(
challenge["algorithm"],
challenge["challenge"],
challenge["salt"],
challenge.get("difficulty", 144000),
challenge.get("expire_at", 1680000000),
challenge["signature"],
challenge["target_path"],
WASM_PATH,
)
except Exception as e:
result["message"] = f"PoW 计算失败: {str(e)}"
return result
pow_dict = {
"algorithm": challenge["algorithm"],
"challenge": challenge["challenge"],
"salt": challenge["salt"],
"answer": answer,
"signature": challenge["signature"],
"target_path": challenge["target_path"],
}
pow_str = json.dumps(pow_dict, separators=(",", ":"), ensure_ascii=False)
pow_header = base64.b64encode(pow_str.encode("utf-8")).decode("utf-8").rstrip()
# 准备请求参数
thinking_enabled, search_enabled = get_model_config(model)
if thinking_enabled is None:
thinking_enabled = False
search_enabled = False
# 发送实际请求
payload = {
"chat_session_id": session_id,
"prompt": f"<User>{message}",
"ref_file_ids": [],
"thinking_enabled": thinking_enabled,
"search_enabled": search_enabled,
}
completion_headers = {**headers, "x-ds-pow-response": pow_header}
completion_resp = cffi_requests.post(
DEEPSEEK_COMPLETION_URL,
headers=completion_headers,
json=payload,
impersonate="safari15_3",
timeout=60,
stream=True,
)
if completion_resp.status_code != 200:
result["message"] = f"请求失败: HTTP {completion_resp.status_code}"
return result
# 收集响应
thinking_parts = []
content_parts = []
for line in completion_resp.iter_lines():
if not line:
continue
try:
line_str = line.decode("utf-8")
except:
continue
if not line_str.startswith("data:"):
continue
data_str = line_str[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
if "v" in chunk:
v_value = chunk["v"]
path = chunk.get("p", "")
# 跳过搜索状态
if path == "response/search_status":
continue
# 判断内容类型
ptype = "text"
if "thinking" in path:
ptype = "thinking"
if isinstance(v_value, str):
if v_value == "FINISHED":
break
# 收集内容
if ptype == "thinking":
thinking_parts.append(v_value)
else:
content_parts.append(v_value)
elif isinstance(v_value, list):
for item in v_value:
if item.get("p") == "status" and item.get("v") == "FINISHED":
break
except:
continue
completion_resp.close()
result["success"] = True
result["response_time"] = round((time.time() - start_time) * 1000)
result["message"] = "".join(content_parts) or "(无回复内容)"
if thinking_parts:
result["thinking"] = "".join(thinking_parts)
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 调用
如果提供 message会发送实际请求并返回 AI 回复;
否则只快速测试创建会话。
"""
data = await request.json()
identifier = data.get("identifier", "")
model = data.get("model", "deepseek-chat")
message = data.get("message", "")
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, message)
# 保存可能更新的 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,
})