""" 蜂鸟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": "代理配置已保存"}