Files
cursornew2026/backend/app/api/client.py
huangzhenpc ac19d029da backend v2.1: 公告管理功能 + 系统重构
- 新增 Announcement 数据模型,支持公告的增删改查
- 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用)
- 客户端 /api/announcement 改为从数据库读取
- 账号服务重构,新增无感换号、自动分析等功能
- 新增后台任务调度器、数据库迁移脚本
- Schema/Service/Config 全面升级至 v2.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:58:05 +08:00

785 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.

"""
蜂鸟Pro 客户端 API v2.1
基于系统设计文档重构
"""
import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from typing import Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
from app.database import get_db
from app.services import (
AccountService, KeyService, LogService, GlobalSettingsService,
cursor_usage_service, analyze_account_from_token
)
from app.models.models import (
AccountStatus, AccountType, KeyMembershipType, KeyStatus,
CursorAccount, ActivationKey, Announcement
)
router = APIRouter(prefix="/api", tags=["Client API"])
# ==================== 请求/响应模型 ====================
class ActivateRequest(BaseModel):
"""激活请求"""
key: str
device_id: Optional[str] = None
class EnableSeamlessRequest(BaseModel):
"""启用无感换号请求"""
key: str
device_id: str
class DisableSeamlessRequest(BaseModel):
"""禁用无感换号请求"""
key: str
class SwitchRequest(BaseModel):
"""换号请求"""
key: str
# ==================== 辅助函数 ====================
def build_key_response(key: ActivationKey, include_account: bool = False) -> Dict[str, Any]:
"""构建密钥响应数据"""
data = {
"key": key.key[:8] + "****" + key.key[-4:],
"status": key.status.value if key.status else None,
"membership_type": key.membership_type.value if key.membership_type else None,
"seamless_enabled": key.seamless_enabled,
"switch_count": key.switch_count,
"first_activated_at": key.first_activated_at.isoformat() if key.first_activated_at else None,
"last_active_at": key.last_active_at.isoformat() if key.last_active_at else None,
}
# Auto密钥信息
if key.membership_type == KeyMembershipType.AUTO:
data["expire_at"] = key.expire_at.isoformat() if key.expire_at else None
if key.expire_at:
delta = key.expire_at - datetime.now()
data["days_remaining"] = max(0, delta.days)
else:
data["days_remaining"] = key.duration_days
# Pro密钥信息
if key.membership_type == KeyMembershipType.PRO:
data["quota"] = key.quota
data["quota_used"] = key.quota_used
data["quota_remaining"] = key.quota_remaining
return data
def build_account_response(account: CursorAccount) -> Dict[str, Any]:
"""构建账号响应数据"""
return {
"email": account.email,
"token": account.token,
"access_token": account.access_token,
"refresh_token": account.refresh_token,
"workos_session_token": account.workos_session_token,
"account_type": account.account_type.value if account.account_type else None,
"membership_type": account.membership_type,
"trial_days_remaining": account.trial_days_remaining,
"usage_percent": float(account.usage_percent) if account.usage_percent else 0,
"usage_limit": account.usage_limit,
"usage_used": account.usage_used,
"usage_remaining": account.usage_remaining,
"total_requests": account.total_requests,
"total_cost_usd": account.total_cost_usd,
"last_analyzed_at": account.last_analyzed_at.isoformat() if account.last_analyzed_at else None
}
# ==================== 核心 API ====================
@router.post("/activate")
async def activate(request: ActivateRequest, req: Request, db: Session = Depends(get_db)):
"""
激活密钥 (不分配账号)
流程:
1. 验证密钥
2. 处理设备绑定
3. 处理密钥合并 (如果该设备已有同类型主密钥)
4. 返回密钥信息 (不包含账号)
"""
requested_key = request.key
key = KeyService.get_by_key(db, request.key)
if not key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 激活密钥
success, message, active_key = KeyService.activate(db, key, request.device_id)
if not success:
LogService.log(
db, key.id, "activate",
ip_address=req.client.host if req.client else None,
device_id=request.device_id,
success=False,
message=message
)
code = "KEY_MERGED" if "已合并" in message else "ACTIVATION_FAILED"
return {"success": False, "error": message, "code": code}
# 记录日志
LogService.log(
db, active_key.id, "activate",
ip_address=req.client.host if req.client else None,
device_id=request.device_id,
success=True,
message=message
)
# 构建兼容前端的扁平响应
merged = requested_key != active_key.key
response = {
"success": True,
"valid": True,
"message": message,
"key": active_key.key,
"merged": merged,
"original_key": requested_key if merged else None,
"master_key": active_key.key if merged else None,
"membership_type": active_key.membership_type.value if active_key.membership_type else "pro",
"status": active_key.status.value if active_key.status else None,
"seamless_enabled": active_key.seamless_enabled,
"switch_count": active_key.switch_count,
}
# Auto密钥信息
if active_key.membership_type == KeyMembershipType.AUTO:
response["expire_date"] = active_key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if active_key.expire_at else None
if active_key.expire_at:
delta = active_key.expire_at - datetime.now()
response["days_remaining"] = max(0, delta.days)
else:
response["days_remaining"] = active_key.duration_days
response["switch_remaining"] = 999 # Auto无限换号
# Pro密钥信息
if active_key.membership_type == KeyMembershipType.PRO:
response["quota"] = active_key.quota
response["quota_used"] = active_key.quota_used
response["switch_remaining"] = active_key.quota_remaining
# 合并信息
response["merged_count"] = active_key.merged_count
if active_key.master_key_id:
master = KeyService.get_by_id(db, active_key.master_key_id)
response["master_key"] = master.key[:8] + "****" if master else None
return response
@router.post("/enable-seamless")
async def enable_seamless(request: EnableSeamlessRequest, req: Request, db: Session = Depends(get_db)):
"""
启用无感换号并分配账号
前置条件: 密钥已激活 (status=active)
流程:
1. 验证密钥状态
2. 根据密钥类型选择账号池
3. 分配并锁定账号
4. 返回账号信息
"""
key = KeyService.get_by_key(db, request.key)
if not key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 如果是子密钥,找到主密钥
if key.master_key_id:
key = KeyService.get_by_id(db, key.master_key_id)
if not key:
return {"success": False, "error": "主密钥不存在", "code": "INVALID_KEY"}
# 启用无感换号
success, message, account = KeyService.enable_seamless(db, key, request.device_id)
if not success:
LogService.log(
db, key.id, "enable_seamless",
ip_address=req.client.host if req.client else None,
device_id=request.device_id,
success=False,
message=message
)
return {"success": False, "error": message, "code": "ENABLE_FAILED"}
# 记录日志
LogService.log(
db, key.id, "enable_seamless",
account_id=account.id,
ip_address=req.client.host if req.client else None,
device_id=request.device_id,
success=True,
message="无感换号已启用"
)
return {
"success": True,
"message": "无感换号已启用",
"data": {
"key_info": build_key_response(key),
"account": build_account_response(account)
}
}
@router.post("/disable-seamless")
async def disable_seamless(request: DisableSeamlessRequest, req: Request, db: Session = Depends(get_db)):
"""
禁用无感换号
流程:
1. 释放当前账号
2. 清除无感状态
"""
key = KeyService.get_by_key(db, request.key)
if not key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 如果是子密钥,找到主密钥
if key.master_key_id:
key = KeyService.get_by_id(db, key.master_key_id)
success, message = KeyService.disable_seamless(db, key)
LogService.log(
db, key.id, "disable_seamless",
ip_address=req.client.host if req.client else None,
success=success,
message=message
)
return {
"success": success,
"message": message
}
@router.post("/switch")
async def switch_account(request: SwitchRequest, req: Request, db: Session = Depends(get_db)):
"""
手动换号
前置条件: 已启用无感换号 (seamless_enabled=true)
流程:
1. 检查换号条件
2. 释放当前账号
3. 分配新账号
4. Pro密钥扣除积分
"""
key = KeyService.get_by_key(db, request.key)
if not key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 如果是子密钥,找到主密钥
if key.master_key_id:
key = KeyService.get_by_id(db, key.master_key_id)
success, message, new_account = KeyService.switch_account(db, key)
if not success:
return {"success": False, "error": message, "code": "SWITCH_FAILED"}
# 计算下次可换号时间
next_switch_at = None
if key.membership_type == KeyMembershipType.AUTO:
interval_minutes = GlobalSettingsService.get_int(db, "auto_switch_interval") or 0
if interval_minutes > 0 and key.last_switch_at:
next_switch_at = (key.last_switch_at + timedelta(minutes=interval_minutes)).isoformat()
return {
"success": True,
"message": "换号成功",
"data": {
"key_info": build_key_response(key),
"account": build_account_response(new_account),
"switch_count": key.switch_count,
"quota_remaining": key.quota_remaining if key.membership_type == KeyMembershipType.PRO else None,
"next_switch_at": next_switch_at
}
}
@router.get("/status")
async def get_status(key: str, db: Session = Depends(get_db)):
"""
获取密钥完整状态
返回:
- 密钥信息
- 账号信息 (如果已启用无感)
"""
activation_key = KeyService.get_by_key(db, key)
if not activation_key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 如果是子密钥,找到主密钥
if activation_key.master_key_id:
activation_key = KeyService.get_by_id(db, activation_key.master_key_id)
data = KeyService.get_status(db, activation_key)
return {
"success": True,
"data": data
}
@router.get("/account-usage")
async def get_account_usage(key: str, refresh: bool = False, db: Session = Depends(get_db)):
"""
获取当前账号用量 (可选刷新)
参数:
- refresh: 是否从 Cursor API 刷新数据
"""
activation_key = KeyService.get_by_key(db, key)
if not activation_key:
return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"}
# 如果是子密钥,找到主密钥
if activation_key.master_key_id:
activation_key = KeyService.get_by_id(db, activation_key.master_key_id)
if not activation_key.seamless_enabled or not activation_key.current_account_id:
return {"success": False, "error": "未启用无感换号", "code": "SEAMLESS_NOT_ENABLED"}
account = AccountService.get_by_id(db, activation_key.current_account_id)
if not account:
return {"success": False, "error": "账号不存在", "code": "ACCOUNT_NOT_FOUND"}
# 如果需要刷新,从 Cursor API 获取最新数据
if refresh:
try:
analysis_data = await analyze_account_from_token(account.token)
if analysis_data.get("success"):
AccountService.update_from_analysis(db, account.id, analysis_data)
db.refresh(account)
except Exception as e:
logger.warning("刷新账号用量失败 (account_id=%s): %s", activation_key.current_account_id, e)
return {
"success": True,
"data": {
"email": account.email,
"membership_type": account.membership_type,
"trial_days_remaining": account.trial_days_remaining,
"billing_cycle": {
"start": account.billing_cycle_start.isoformat() if account.billing_cycle_start else None,
"end": account.billing_cycle_end.isoformat() if account.billing_cycle_end else None
},
"usage": {
"limit": account.usage_limit,
"used": account.usage_used,
"remaining": account.usage_remaining,
"percent": float(account.usage_percent) if account.usage_percent else 0
},
"cost": {
"total_requests": account.total_requests,
"total_input_tokens": account.total_input_tokens,
"total_output_tokens": account.total_output_tokens,
"total_cost_usd": account.total_cost_usd
},
"updated_at": account.last_analyzed_at.isoformat() if account.last_analyzed_at else None
}
}
# ==================== 兼容旧 API ====================
@router.post("/verify")
async def verify(request: ActivateRequest, req: Request, db: Session = Depends(get_db)):
"""兼容旧版验证接口 - 重定向到 activate"""
return await activate(request, req, db)
@router.post("/verify-key")
async def verify_key(request: ActivateRequest, req: Request, db: Session = Depends(get_db)):
"""兼容旧版验证接口 - 重定向到 activate"""
return await activate(request, req, db)
@router.post("/switch-account")
async def switch_account_compat(request: SwitchRequest, req: Request, db: Session = Depends(get_db)):
"""兼容旧版换号接口 - 重定向到 switch"""
return await switch_account(request, req, db)
# ==================== 无感换号注入代码 API ====================
@router.get("/seamless/status")
async def seamless_status():
"""
获取无感换号状态 (前端用)
注意:实际注入状态由前端本地检测,此接口仅用于兼容
"""
return {
"success": True,
"is_injected": False, # 实际状态由前端本地检测
"message": "请使用前端本地检测"
}
@router.get("/seamless/config")
async def get_seamless_config(db: Session = Depends(get_db)):
"""获取无感配置"""
return {
"success": True,
"enabled": True,
"auto_switch_threshold": GlobalSettingsService.get_int(db, "auto_switch_threshold") or 98,
"auto_switch_interval": GlobalSettingsService.get_int(db, "auto_switch_interval") or 0
}
@router.post("/seamless/config")
async def update_seamless_config(config: dict):
"""更新无感配置 (客户端本地管理)"""
return {"success": True, "message": "配置已保存"}
@router.get("/seamless/get-token")
async def seamless_get_token(
userKey: str = None,
key: str = None,
db: Session = Depends(get_db)
):
"""
获取无感Token (注入代码调用)
返回格式直接包含 token、email 等字段,供注入代码使用
"""
actual_key = userKey or key
if not actual_key:
return {"success": False, "error": "缺少激活码参数"}
activation_key = KeyService.get_by_key(db, actual_key)
if not activation_key:
return {"success": False, "error": "激活码不存在"}
# 如果是子密钥,找到主密钥
if activation_key.master_key_id:
activation_key = KeyService.get_by_id(db, activation_key.master_key_id)
# 检查状态
if activation_key.status != KeyStatus.ACTIVE:
return {"success": False, "error": "密钥未激活"}
if activation_key.is_expired:
return {"success": False, "error": "密钥已过期"}
if not activation_key.seamless_enabled or not activation_key.current_account_id:
return {"success": False, "error": "未启用无感换号"}
account = AccountService.get_by_id(db, activation_key.current_account_id)
if not account:
return {"success": False, "error": "账号不存在"}
if account.status not in (AccountStatus.AVAILABLE, AccountStatus.IN_USE):
return {"success": False, "error": "账号不可用"}
# 返回注入代码需要的格式
access_token = account.access_token or account.token
workos_token = account.workos_session_token or account.token
refresh_token = account.refresh_token
return {
"success": True,
"accessToken": access_token,
"refreshToken": refresh_token,
"workosToken": workos_token,
"email": account.email,
"membership_type": account.membership_type,
"usage_percent": float(account.usage_percent) if account.usage_percent else 0,
"switchVersion": activation_key.switch_count,
"switchRemaining": activation_key.quota_remaining if activation_key.membership_type == KeyMembershipType.PRO else 999
}
@router.post("/seamless/switch-token")
async def seamless_switch_token(data: dict, req: Request, db: Session = Depends(get_db)):
"""
注入代码触发换号
"""
user_key = data.get("userKey")
if not user_key:
return {"switched": False, "message": "缺少userKey参数"}
request = SwitchRequest(key=user_key)
result = await switch_account(request, req, db)
if not result.get("success"):
return {"switched": False, "message": result.get("error")}
account_data = result.get("data", {}).get("account", {})
access_token = account_data.get("access_token") or account_data.get("token")
workos_token = account_data.get("workos_session_token") or account_data.get("token")
refresh_token = account_data.get("refresh_token")
# 构建前端 _writeAccountToLocal 需要的数据
write_data = {
"accessToken": access_token,
"refreshToken": refresh_token,
"email": account_data.get("email"),
"membership_type": account_data.get("membership_type"),
"workosToken": workos_token
}
return {
"switched": True,
"switchRemaining": result.get("data", {}).get("quota_remaining", 999),
"email": account_data.get("email"),
"accessToken": access_token,
"workosToken": workos_token,
"switchVersion": result.get("data", {}).get("switch_count", 0),
"data": write_data # 供 _writeAccountToLocal 使用
}
@router.get("/seamless/user-status")
async def seamless_user_status(key: str = None, userKey: str = None, db: Session = Depends(get_db)):
"""获取用户切换状态 (注入代码调用)"""
actual_key = key or userKey
if not actual_key:
return {"success": False, "valid": False, "error": "缺少激活码参数"}
activation_key = KeyService.get_by_key(db, actual_key)
if not activation_key:
return {"success": False, "valid": False, "error": "激活码不存在"}
# 如果是子密钥,找到主密钥
if activation_key.master_key_id:
activation_key = KeyService.get_by_id(db, activation_key.master_key_id)
# 检查是否有效
if activation_key.status != KeyStatus.ACTIVE:
return {"success": False, "valid": False, "error": "密钥未激活"}
if activation_key.is_expired:
return {"success": False, "valid": False, "error": "密钥已过期"}
# 获取当前账号信息
locked_account = None
if activation_key.seamless_enabled and activation_key.current_account_id:
account = AccountService.get_by_id(db, activation_key.current_account_id)
if account and account.status in (AccountStatus.AVAILABLE, AccountStatus.IN_USE):
locked_account = {
"email": account.email,
"membership_type": account.membership_type
}
# 计算剩余次数
is_pro = activation_key.membership_type == KeyMembershipType.PRO
switch_remaining = activation_key.quota_remaining if is_pro else 999
# 计算下次可换号时间 (Auto密钥)
next_switch_at = None
if not is_pro:
interval_minutes = GlobalSettingsService.get_int(db, "auto_switch_interval") or 0
if interval_minutes > 0 and activation_key.last_switch_at:
next_time = activation_key.last_switch_at + timedelta(minutes=interval_minutes)
if next_time > datetime.now():
next_switch_at = next_time.isoformat()
return {
"success": True,
"valid": True,
"switchRemaining": switch_remaining,
"canSwitch": switch_remaining > 0,
"lockedAccount": locked_account,
"membershipType": activation_key.membership_type.value if activation_key.membership_type else None,
"seamlessEnabled": activation_key.seamless_enabled,
"nextSwitchAt": next_switch_at,
"data": {
"canSwitch": switch_remaining > 0,
"quotaRemaining": activation_key.quota_remaining if is_pro else None,
"switchCount": activation_key.switch_count
}
}
# ==================== 设备密钥信息 API ====================
@router.get("/device-keys")
async def get_device_keys(device_id: str = None, db: Session = Depends(get_db)):
"""获取设备的所有密钥信息Auto和Pro"""
if not device_id:
return {"success": False, "error": "缺少设备ID"}
result = {
"success": True,
"device_id": device_id,
"auto": {"has_key": False},
"pro": {"has_key": False}
}
# 查找 Auto 主密钥
auto_key = db.query(ActivationKey).filter(
ActivationKey.device_id == device_id,
ActivationKey.membership_type == KeyMembershipType.AUTO,
ActivationKey.status == KeyStatus.ACTIVE,
ActivationKey.master_key_id == None
).first()
if auto_key:
result["auto"] = {
"has_key": True,
"master_key": auto_key.key[:8] + "****",
"expire_at": auto_key.expire_at.strftime("%Y/%m/%d %H:%M:%S") if auto_key.expire_at else None,
"days_remaining": max(0, (auto_key.expire_at - datetime.now()).days) if auto_key.expire_at else auto_key.duration_days,
"merged_count": auto_key.merged_count,
"seamless_enabled": auto_key.seamless_enabled,
"current_account": auto_key.current_account.email if auto_key.current_account else None,
"status": auto_key.status.value
}
# 查找 Pro 主密钥
pro_key = db.query(ActivationKey).filter(
ActivationKey.device_id == device_id,
ActivationKey.membership_type == KeyMembershipType.PRO,
ActivationKey.status == KeyStatus.ACTIVE,
ActivationKey.master_key_id == None
).first()
if pro_key:
result["pro"] = {
"has_key": True,
"master_key": pro_key.key[:8] + "****",
"quota": pro_key.quota,
"quota_used": pro_key.quota_used,
"quota_remaining": pro_key.quota_remaining,
"merged_count": pro_key.merged_count,
"seamless_enabled": pro_key.seamless_enabled,
"current_account": pro_key.current_account.email if pro_key.current_account else None,
"status": pro_key.status.value
}
return result
# ==================== 账号用量查询 API ====================
@router.get("/cursor-accounts/query")
async def query_cursor_account(
email: str,
refresh: bool = False,
db: Session = Depends(get_db)
):
"""
查询 Cursor 账号用量信息
前端用于显示当前账号的用量
"""
if not email:
return {"success": False, "error": "缺少邮箱参数"}
account = AccountService.get_by_email(db, email)
if not account:
return {"success": False, "error": "账号不存在"}
# 如果需要刷新,从 Cursor API 获取最新数据
if refresh:
try:
analysis_data = await analyze_account_from_token(account.token)
if analysis_data.get("success"):
AccountService.update_from_analysis(db, account.id, analysis_data)
db.refresh(account)
except Exception as e:
logger.warning("刷新账号用量失败 (email=%s): %s", email, e)
return {
"success": True,
"data": {
"email": account.email,
"membership_type": account.membership_type,
"account_type": account.account_type.value if account.account_type else None,
"trial_days_remaining": account.trial_days_remaining,
"usage": {
"limit": account.usage_limit,
"used": account.usage_used,
"remaining": account.usage_remaining,
"percent": float(account.usage_percent) if account.usage_percent else 0,
"totalUsageCount": account.total_requests,
"totalCostUSD": account.total_cost_usd
},
"last_analyzed_at": account.last_analyzed_at.isoformat() if account.last_analyzed_at else None
}
}
# ==================== 其他 API ====================
@router.get("/version")
async def get_version():
"""获取版本信息"""
return {
"success": True,
"version": "2.1.0",
"latest_version": "2.1.0",
"has_update": False,
"download_url": "",
"changelog": ""
}
@router.get("/announcement")
async def get_announcement(db: Session = Depends(get_db)):
"""获取公告信息"""
announcement = db.query(Announcement).filter(
Announcement.is_active == True
).order_by(Announcement.id.desc()).first()
if not announcement:
return {
"success": True,
"has_announcement": False,
"data": None
}
return {
"success": True,
"has_announcement": True,
"data": {
"is_active": announcement.is_active,
"title": announcement.title,
"content": announcement.content,
"type": announcement.type,
"created_at": announcement.created_at.strftime("%Y-%m-%d %H:%M:%S") if announcement.created_at else None
}
}
@router.get("/announcements/latest")
async def get_announcements_latest(db: Session = Depends(get_db)):
"""获取最新公告"""
return await get_announcement(db)
@router.get("/proxy-config")
async def get_proxy_config():
"""获取代理配置 (客户端本地管理)"""
return {
"success": True,
"enabled": False,
"host": "",
"port": 0
}
@router.post("/proxy-config")
async def update_proxy_config(config: dict):
"""更新代理配置 (客户端本地管理)"""
return {"success": True, "message": "代理配置已保存"}