diff --git a/backend/.env.example b/backend/.env.example index 8d9b06d..47bb81c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,15 +1,19 @@ -# 数据库配置 -DB_HOST=localhost +# ========== 数据库配置 ========== +USE_SQLITE=false +DB_HOST=127.0.0.1 DB_PORT=3306 -DB_USER=root -DB_PASSWORD=your_password +DB_USER=cursorpro +DB_PASSWORD=your_db_password_here DB_NAME=cursorpro -# JWT 配置 -JWT_SECRET_KEY=your-super-secret-key-change-this-in-production -JWT_ALGORITHM=HS256 -JWT_EXPIRE_MINUTES=1440 +# ========== JWT 配置 ========== +SECRET_KEY=change-this-to-a-random-secret-key +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 -# 管理员账号 +# ========== 管理员账号 ========== ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 +ADMIN_PASSWORD=change-this-admin-password + +# ========== 外部系统 API Token ========== +API_TOKEN=change-this-api-token diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 31be6a6..abfefb6 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -4,23 +4,31 @@ from datetime import datetime from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status, Query, Header +from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.config import settings from app.services import AccountService, KeyService, LogService, GlobalSettingsService, BatchService, authenticate_admin, create_access_token, get_current_user -from app.schemas import ( +from app.schemas.schemas import ( AccountCreate, AccountUpdate, AccountResponse, AccountImport, KeyCreate, KeyUpdate, KeyResponse, DashboardStats, Token, LoginRequest, GlobalSettingsResponse, GlobalSettingsUpdate, BatchExtendRequest, BatchExtendResponse, - ExternalBatchUpload, ExternalBatchResponse + ExternalBatchUpload, ExternalBatchResponse, + AnnouncementCreate, AnnouncementUpdate ) -from app.models import MembershipType, KeyDevice, UsageLog, ActivationKey +from app.models.models import KeyMembershipType, KeyStatus, AccountStatus, AccountType, KeyDevice, UsageLog, ActivationKey, CursorAccount, Announcement router = APIRouter(prefix="/admin", tags=["Admin API"]) +class AccountAnalyzeRequest(BaseModel): + """手动分析账号请求""" + token: Optional[str] = None + save_token: bool = False + + # ========== 认证 ========== @router.post("/login", response_model=Token) @@ -79,8 +87,8 @@ async def external_batch_upload( for item in data.accounts: try: - # 转换membership_type - mt = MembershipType.FREE if item.membership_type == "free" else MembershipType.PRO + # 转换membership_type (free/auto -> AUTO, pro -> PRO) + # 注意:mt 变量暂未使用,因为 CursorAccount 模型中 membership_type 是从 Cursor API 分析得出的 existing = AccountService.get_by_email(db, item.email) if existing: @@ -88,10 +96,10 @@ async def external_batch_upload( # 更新已存在的账号 AccountService.update( db, existing.id, + token=item.workos_session_token or item.access_token, access_token=item.access_token, refresh_token=item.refresh_token, workos_session_token=item.workos_session_token, - membership_type=mt, remark=item.remark or existing.remark ) updated += 1 @@ -100,15 +108,16 @@ async def external_batch_upload( errors.append(f"{item.email}: 账号已存在") else: # 创建新账号 - account_data = AccountCreate( + AccountService.create( + db, email=item.email, + token=item.workos_session_token or item.access_token, access_token=item.access_token, refresh_token=item.refresh_token, workos_session_token=item.workos_session_token, - membership_type=mt, + password=None, remark=item.remark ) - AccountService.create(db, account_data) created += 1 except Exception as e: failed += 1 @@ -131,11 +140,12 @@ async def external_account_stats( ): """外部系统获取账号统计""" stats = AccountService.count(db) + pro_count = db.query(CursorAccount).filter(CursorAccount.account_type == AccountType.PRO).count() return { "total": stats["total"], - "active": stats["active"], - "pro": stats["pro"], - "free": stats["total"] - stats["pro"] + "active": stats["available"] + stats["in_use"], + "pro": pro_count, + "free": stats["total"] - pro_count } @@ -181,8 +191,8 @@ async def get_dashboard( return DashboardStats( total_accounts=account_stats["total"], - active_accounts=account_stats["active"], - pro_accounts=account_stats["pro"], + active_accounts=account_stats["available"] + account_stats["in_use"], # 可用+使用中 + pro_accounts=key_stats["pro"], # Pro密钥数量 total_keys=key_stats["total"], active_keys=key_stats["active"], today_usage=today_usage @@ -212,7 +222,16 @@ async def create_account( existing = AccountService.get_by_email(db, account.email) if existing: raise HTTPException(status_code=400, detail="邮箱已存在") - return AccountService.create(db, account) + return AccountService.create( + db, + email=account.email, + token=account.token, + password=account.password, + remark=account.remark, + access_token=account.access_token, + refresh_token=account.refresh_token, + workos_session_token=account.workos_session_token + ) @router.post("/accounts/import", response_model=dict) @@ -233,13 +252,24 @@ async def import_accounts( # 更新已存在的账号 AccountService.update( db, existing.id, + token=account.token or account.workos_session_token or account.access_token, access_token=account.access_token, refresh_token=account.refresh_token, workos_session_token=account.workos_session_token, - membership_type=account.membership_type + password=account.password, + remark=account.remark ) else: - AccountService.create(db, account) + AccountService.create( + db, + email=account.email, + token=account.token or account.workos_session_token or account.access_token, + password=account.password, + remark=account.remark, + access_token=account.access_token, + refresh_token=account.refresh_token, + workos_session_token=account.workos_session_token + ) success += 1 except Exception as e: failed += 1 @@ -315,8 +345,7 @@ async def toggle_account_status( - 禁用(disabled) -> 可用(active) - 过期(expired) -> 可用(active) """ - from app.models import AccountStatus, Account - account = db.query(Account).filter(Account.id == account_id).first() + account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") @@ -324,14 +353,15 @@ async def toggle_account_status( # 根据当前状态切换 if account.status == AccountStatus.IN_USE: - account.status = AccountStatus.ACTIVE - account.current_key_id = None # 释放绑定 - elif account.status == AccountStatus.ACTIVE: + account.status = AccountStatus.AVAILABLE + account.locked_by_key_id = None # 释放绑定 + account.locked_at = None + elif account.status == AccountStatus.AVAILABLE: account.status = AccountStatus.DISABLED elif account.status == AccountStatus.DISABLED: - account.status = AccountStatus.ACTIVE - elif account.status == AccountStatus.EXPIRED: - account.status = AccountStatus.ACTIVE + account.status = AccountStatus.AVAILABLE + elif account.status == AccountStatus.EXHAUSTED: + account.status = AccountStatus.AVAILABLE db.commit() @@ -350,16 +380,16 @@ async def release_account( current_user: dict = Depends(get_current_user) ): """释放账号(从使用中变为可用)""" - from app.models import AccountStatus, Account - account = db.query(Account).filter(Account.id == account_id).first() + account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") if account.status != AccountStatus.IN_USE: return {"success": False, "message": "账号不在使用中状态"} - account.status = AccountStatus.ACTIVE - account.current_key_id = None + account.status = AccountStatus.AVAILABLE + account.locked_by_key_id = None + account.locked_at = None db.commit() return {"success": True, "message": "账号已释放"} @@ -372,15 +402,14 @@ async def batch_enable_accounts( current_user: dict = Depends(get_current_user) ): """批量启用账号""" - from app.models import AccountStatus, Account success = 0 failed = 0 for account_id in account_ids: try: - account = db.query(Account).filter(Account.id == account_id).first() + account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first() if account: - account.status = AccountStatus.ACTIVE + account.status = AccountStatus.AVAILABLE success += 1 else: failed += 1 @@ -402,13 +431,12 @@ async def batch_disable_accounts( current_user: dict = Depends(get_current_user) ): """批量禁用账号""" - from app.models import AccountStatus, Account success = 0 failed = 0 for account_id in account_ids: try: - account = db.query(Account).filter(Account.id == account_id).first() + account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first() if account: account.status = AccountStatus.DISABLED success += 1 @@ -451,15 +479,6 @@ async def batch_delete_accounts( } -@router.get("/accounts/count") -async def get_accounts_count( - db: Session = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """获取账号总数""" - stats = AccountService.count(db) - return {"total": stats["total"]} - # ========== 激活码管理 ========== @@ -475,7 +494,6 @@ async def list_keys( current_user: dict = Depends(get_current_user) ): """获取激活码列表(支持搜索和筛选)""" - from app.models import KeyStatus query = db.query(ActivationKey).order_by(ActivationKey.id.desc()) # 搜索激活码 @@ -493,9 +511,9 @@ async def list_keys( elif activated and activated == "false": query = query.filter(ActivationKey.first_activated_at == None) - # 套餐类型筛选 + # 套餐类型筛选 (free/auto -> AUTO, pro -> PRO) if membership_type: - mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE + mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO query = query.filter(ActivationKey.membership_type == mt) return query.offset(skip).limit(limit).all() @@ -511,7 +529,6 @@ async def get_keys_count( current_user: dict = Depends(get_current_user) ): """获取激活码总数(支持筛选)""" - from app.models import KeyStatus query = db.query(ActivationKey) # 搜索激活码 @@ -529,9 +546,9 @@ async def get_keys_count( elif activated and activated == "false": query = query.filter(ActivationKey.first_activated_at == None) - # 套餐类型筛选 + # 套餐类型筛选 (free/auto -> AUTO, pro -> PRO) if membership_type: - mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE + mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO query = query.filter(ActivationKey.membership_type == mt) total = query.count() @@ -545,7 +562,15 @@ async def create_keys( current_user: dict = Depends(get_current_user) ): """创建激活码""" - return KeyService.create(db, key_data) + return KeyService.create( + db, + count=key_data.count, + membership_type=key_data.membership_type, + duration_days=key_data.valid_days, + quota=key_data.quota, + max_devices=key_data.max_devices, + remark=key_data.remark + ) @router.get("/keys/{key_id}", response_model=KeyResponse) @@ -735,7 +760,6 @@ async def disable_key( current_user: dict = Depends(get_current_user) ): """禁用激活码(返回使用信息供客服参考)""" - from app.models import KeyStatus key = KeyService.get_by_id(db, key_id) if not key: raise HTTPException(status_code=404, detail="激活码不存在") @@ -770,7 +794,6 @@ async def enable_key( current_user: dict = Depends(get_current_user) ): """启用激活码""" - from app.models import KeyStatus key = KeyService.get_by_id(db, key_id) if not key: raise HTTPException(status_code=404, detail="激活码不存在") @@ -788,7 +811,6 @@ async def batch_enable_keys( current_user: dict = Depends(get_current_user) ): """批量启用激活码""" - from app.models import KeyStatus success = 0 failed = 0 @@ -818,7 +840,6 @@ async def batch_disable_keys( current_user: dict = Depends(get_current_user) ): """批量禁用激活码""" - from app.models import KeyStatus success = 0 failed = 0 @@ -877,7 +898,6 @@ async def get_keys_count( current_user: dict = Depends(get_current_user) ): """获取激活码总数(支持筛选)""" - from app.models import KeyStatus query = db.query(ActivationKey) # 搜索激活码 @@ -895,9 +915,9 @@ async def get_keys_count( elif activated and activated == "false": query = query.filter(ActivationKey.first_activated_at == None) - # 套餐类型筛选 + # 套餐类型筛选 (free/auto -> AUTO, pro -> PRO) if membership_type: - mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE + mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO query = query.filter(ActivationKey.membership_type == mt) total = query.count() @@ -962,7 +982,7 @@ async def batch_extend_keys( @router.post("/keys/batch-compensate") async def batch_compensate( - membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"), + membership_type: Optional[str] = Query(None, description="套餐类型: pro/auto"), activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"), not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"), extend_days: int = Query(0, description="延长天数"), @@ -981,11 +1001,11 @@ async def batch_compensate( - 如果卡已过期(但符合补偿条件):恢复使用,expire_at = 今天 + extend_days 例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥,延长1天 - POST /admin/keys/batch-compensate?membership_type=free&activated_before=2024-12-05¬_expired_on=2024-12-04&extend_days=1 + POST /admin/keys/batch-compensate?membership_type=auto&activated_before=2024-12-05¬_expired_on=2024-12-04&extend_days=1 """ mt = None if membership_type: - mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE + mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO activated_before_dt = datetime.strptime(activated_before, "%Y-%m-%d") if activated_before else None not_expired_on_dt = datetime.strptime(not_expired_on, "%Y-%m-%d") if not_expired_on else None @@ -1003,7 +1023,7 @@ async def batch_compensate( @router.get("/keys/preview-compensate") async def preview_compensate( - membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"), + membership_type: Optional[str] = Query(None, description="套餐类型: pro/auto"), activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"), not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"), db: Session = Depends(get_db), @@ -1012,7 +1032,7 @@ async def preview_compensate( """预览补偿 - 查看符合条件的密钥数量(不执行)""" mt = None if membership_type: - mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE + mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO activated_before_dt = datetime.strptime(activated_before, "%Y-%m-%d") if activated_before else None not_expired_on_dt = datetime.strptime(not_expired_on, "%Y-%m-%d") if not_expired_on else None @@ -1140,3 +1160,295 @@ async def get_key_logs( "message": log.message, "created_at": log.created_at.strftime("%Y-%m-%d %H:%M:%S") if log.created_at else None } for log in logs] + + +# ========== 账号分析 (Analysis) ========== + +@router.post("/accounts/{account_id}/analyze") +async def analyze_account( + account_id: int, + payload: Optional[AccountAnalyzeRequest] = None, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """手动触发单个账号分析""" + from app.services import analyze_account_from_token + + account = AccountService.get_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + override_token = payload.token.strip() if payload and payload.token else None + token_to_use = override_token or account.token + + if not token_to_use: + raise HTTPException(status_code=400, detail="账号未保存Token,请提供检测Token") + + # 执行分析 + result = await analyze_account_from_token(token_to_use) + + if result.get("success"): + # 更新账号信息 + if override_token and payload.save_token: + AccountService.update( + db, + account_id, + token=override_token, + workos_session_token=override_token + ) + AccountService.update_from_analysis(db, account_id, result) + return { + "success": True, + "message": "分析完成", + "data": result + } + else: + # 记录错误 + AccountService.update_from_analysis(db, account_id, { + "success": False, + "error": result.get("error", "分析失败") + }) + return { + "success": False, + "message": result.get("error", "分析失败") + } + + +@router.post("/accounts/batch-analyze") +async def batch_analyze_accounts( + account_ids: List[int] = None, + analyze_all_pending: bool = False, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """批量分析账号 + + 参数: + - account_ids: 指定账号ID列表 + - analyze_all_pending: 分析所有待分析的账号 + """ + from app.services import analyze_account_from_token + + accounts = [] + if analyze_all_pending: + accounts = AccountService.get_pending_accounts(db, limit=50) + elif account_ids: + for aid in account_ids: + acc = AccountService.get_by_id(db, aid) + if acc: + accounts.append(acc) + + if not accounts: + return {"success": 0, "failed": 0, "message": "没有找到要分析的账号"} + + success = 0 + failed = 0 + results = [] + + for account in accounts: + try: + result = await analyze_account_from_token(account.token) + if result.get("success"): + AccountService.update_from_analysis(db, account.id, result) + success += 1 + results.append({ + "id": account.id, + "email": account.email, + "success": True, + "account_type": result.get("account_type"), + "usage_percent": result.get("usage_percent") + }) + else: + AccountService.update_from_analysis(db, account.id, { + "success": False, + "error": result.get("error", "分析失败") + }) + failed += 1 + results.append({ + "id": account.id, + "email": account.email, + "success": False, + "error": result.get("error") + }) + except Exception as e: + failed += 1 + results.append({ + "id": account.id, + "email": account.email, + "success": False, + "error": str(e) + }) + + return { + "success": success, + "failed": failed, + "total": len(accounts), + "results": results[:20], # 只返回前20条 + "message": f"分析完成: {success} 成功, {failed} 失败" + } + + +@router.post("/settings/toggle-auto-analyze") +async def toggle_auto_analyze( + enabled: bool, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """开启/关闭自动账号分析""" + GlobalSettingsService.set(db, "auto_analyze_enabled", str(enabled).lower()) + return { + "success": True, + "auto_analyze_enabled": enabled, + "message": f"自动分析已{'开启' if enabled else '关闭'}" + } + + +@router.post("/settings/toggle-auto-switch") +async def toggle_auto_switch( + enabled: bool, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """开启/关闭自动换号""" + GlobalSettingsService.set(db, "auto_switch_enabled", str(enabled).lower()) + return { + "success": True, + "auto_switch_enabled": enabled, + "message": f"自动换号已{'开启' if enabled else '关闭'}" + } + + +@router.get("/accounts/analysis-status") +async def get_analysis_status( + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """获取账号分析状态概览""" + stats = AccountService.count(db) + auto_analyze = GlobalSettingsService.get(db, "auto_analyze_enabled") + auto_switch = GlobalSettingsService.get(db, "auto_switch_enabled") + + return { + "auto_analyze_enabled": auto_analyze == "true", + "auto_switch_enabled": auto_switch == "true", + "total_accounts": stats["total"], + "pending_analysis": stats["pending"], + "available": stats["available"], + "in_use": stats["in_use"], + "exhausted": stats["exhausted"], + "invalid": stats["invalid"] + } + + +# ========== 公告管理 ========== + +@router.get("/announcements") +async def list_announcements( + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """获取所有公告(含禁用的)""" + announcements = db.query(Announcement).order_by(Announcement.id.desc()).all() + return [ + { + "id": a.id, + "title": a.title, + "content": a.content, + "type": a.type, + "is_active": a.is_active, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + } + for a in announcements + ] + + +@router.post("/announcements") +async def create_announcement( + data: AnnouncementCreate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """创建公告""" + announcement = Announcement( + title=data.title, + content=data.content, + type=data.type, + is_active=data.is_active, + ) + db.add(announcement) + db.commit() + db.refresh(announcement) + return { + "id": announcement.id, + "title": announcement.title, + "content": announcement.content, + "type": announcement.type, + "is_active": announcement.is_active, + "created_at": announcement.created_at.isoformat() if announcement.created_at else None, + "updated_at": announcement.updated_at.isoformat() if announcement.updated_at else None, + } + + +@router.put("/announcements/{announcement_id}") +async def update_announcement( + announcement_id: int, + data: AnnouncementUpdate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """编辑公告""" + announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first() + if not announcement: + raise HTTPException(status_code=404, detail="公告不存在") + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(announcement, field, value) + + db.commit() + db.refresh(announcement) + return { + "id": announcement.id, + "title": announcement.title, + "content": announcement.content, + "type": announcement.type, + "is_active": announcement.is_active, + "created_at": announcement.created_at.isoformat() if announcement.created_at else None, + "updated_at": announcement.updated_at.isoformat() if announcement.updated_at else None, + } + + +@router.delete("/announcements/{announcement_id}") +async def delete_announcement( + announcement_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """删除公告""" + announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first() + if not announcement: + raise HTTPException(status_code=404, detail="公告不存在") + db.delete(announcement) + db.commit() + return {"success": True, "message": "公告已删除"} + + +@router.post("/announcements/{announcement_id}/toggle") +async def toggle_announcement( + announcement_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """启用/禁用公告""" + announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first() + if not announcement: + raise HTTPException(status_code=404, detail="公告不存在") + announcement.is_active = not announcement.is_active + db.commit() + db.refresh(announcement) + return { + "id": announcement.id, + "is_active": announcement.is_active, + "message": "公告已启用" if announcement.is_active else "公告已禁用" + } diff --git a/backend/app/api/client.py b/backend/app/api/client.py index 30f8451..18e5baa 100644 --- a/backend/app/api/client.py +++ b/backend/app/api/client.py @@ -1,563 +1,467 @@ """ -客户端 API - 兼容原 CursorPro 插件 +蜂鸟Pro 客户端 API v2.1 +基于系统设计文档重构 """ +import logging from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session -from typing import Optional, List +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 -from app.schemas import VerifyKeyRequest, VerifyKeyResponse, SwitchAccountRequest, SwitchAccountResponse -from app.models import AccountStatus, MembershipType +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 AccountData(BaseModel): - """账号数据 - 写入本地Cursor的数据""" - accessToken: str - refreshToken: Optional[str] = None - workosSessionToken: Optional[str] = None - email: str - membership_type: str - usage_type: Optional[str] = "default" - # 机器ID相关 (可选) - serviceMachineId: Optional[str] = None - machineId: Optional[str] = None - macMachineId: Optional[str] = None - devDeviceId: Optional[str] = None - sqmId: Optional[str] = None - machineIdFile: Optional[str] = None - # 使用统计 - requestCount: Optional[int] = 0 - usageAmount: Optional[float] = 0.0 - # 额度信息 - quota: Optional[int] = None - quotaUsed: Optional[int] = None - quotaRemaining: Optional[int] = None - expireDate: Optional[str] = None +class ActivateRequest(BaseModel): + """激活请求""" + key: str + device_id: Optional[str] = None -class ApiResponse(BaseModel): - """通用API响应""" - success: bool - message: Optional[str] = None - error: Optional[str] = None - data: Optional[AccountData] = None +class EnableSeamlessRequest(BaseModel): + """启用无感换号请求""" + key: str + device_id: str -# ========== 验证和切换 API ========== +class DisableSeamlessRequest(BaseModel): + """禁用无感换号请求""" + key: str -def build_account_data(account, key) -> AccountData: - """构建账号数据对象""" - expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None - return AccountData( - accessToken=account.access_token, - refreshToken=account.refresh_token, - workosSessionToken=account.workos_session_token, - email=account.email, - membership_type=account.membership_type.value, - quota=key.quota, - quotaUsed=key.quota_used, - quotaRemaining=key.quota - key.quota_used, - expireDate=expire_date + +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, + } -@router.post("/verify") -async def verify(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)): - """验证激活码 (前端调用的路径)""" - return await verify_key_impl(request, req, db) + # 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("/verify-key") -async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)): - """验证激活码 (兼容路径)""" - return await verify_key_impl(request, req, db) +@router.post("/enable-seamless") +async def enable_seamless(request: EnableSeamlessRequest, req: Request, db: Session = Depends(get_db)): + """ + 启用无感换号并分配账号 - -async def verify_key_impl(request: VerifyKeyRequest, req: Request, db: Session): - """验证激活码实现 - 支持密钥合并""" + 前置条件: 密钥已激活 (status=active) + 流程: + 1. 验证密钥状态 + 2. 根据密钥类型选择账号池 + 3. 分配并锁定账号 + 4. 返回账号信息 + """ key = KeyService.get_by_key(db, request.key) - if not key: - return {"success": False, "valid": False, "error": "激活码不存在"} + return {"success": False, "error": "激活码不存在", "code": "INVALID_KEY"} - # 激活密钥(支持合并) - activate_ok, activate_msg, master_key = KeyService.activate(db, key, request.device_id) - if not activate_ok: - LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=activate_msg) - return {"success": False, "valid": False, "error": activate_msg} + # 如果是子密钥,找到主密钥 + 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"} - # 使用主密钥进行后续操作 - active_key = master_key if master_key else key + # 启用无感换号 + success, message, account = KeyService.enable_seamless(db, key, request.device_id) - # 检查主密钥是否有效 - is_valid, message = KeyService.is_valid(active_key, db) - if not is_valid: - LogService.log(db, active_key.id, "verify", ip_address=req.client.host, success=False, message=message) - return {"success": False, "valid": False, "error": message} + 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"} - # 获取当前绑定的账号,或分配新账号 - account = None - if active_key.current_account_id: - account = AccountService.get_by_id(db, active_key.current_account_id) - - # 只有账号不存在或被禁用/过期才分配新的 - if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED): - account = AccountService.get_available(db, active_key.membership_type) - if not account: - LogService.log(db, active_key.id, "verify", ip_address=req.client.host, success=False, message="无可用账号") - return {"success": False, "valid": False, "error": "暂无可用账号,请稍后重试"} - - KeyService.bind_account(db, active_key, account) - AccountService.mark_used(db, account, active_key.id) - - # 只记录首次激活,不记录每次验证(减少日志量) - if "激活成功" in activate_msg or "合并" in activate_msg: - LogService.log(db, active_key.id, "activate", account.id, ip_address=req.client.host, success=True, message=activate_msg) - - # 返回格式 - expire_date = active_key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if active_key.expire_at else None - is_pro = active_key.membership_type == MembershipType.PRO + # 记录日志 + 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, - "valid": True, - "message": activate_msg, - "membership_type": active_key.membership_type.value, - "expire_date": expire_date, - "switch_remaining": active_key.quota - active_key.quota_used if is_pro else 999, - "switch_limit": active_key.quota if is_pro else 999, - "quota": active_key.quota if is_pro else None, - "quota_used": active_key.quota_used if is_pro else None, - "merged_count": active_key.merged_count, - "master_key": active_key.key[:8] + "****", # 隐藏部分密钥 - "data": build_account_data(account, active_key) + "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(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)): - """切换账号 (前端调用的路径)""" - return await switch_account_impl(request, req, db) +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(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)): - """切换账号 (兼容路径)""" - return await switch_account_impl(request, req, db) +async def switch_account_compat(request: SwitchRequest, req: Request, db: Session = Depends(get_db)): + """兼容旧版换号接口 - 重定向到 switch""" + return await switch_account(request, req, db) -async def switch_account_impl(request: SwitchAccountRequest, req: Request, db: Session): - """切换账号实现""" - key = KeyService.get_by_key(db, request.key) - - if not key: - return ApiResponse(success=False, error="激活码不存在") - - # 检查设备限制 - if request.device_id: - device_ok, device_msg = KeyService.check_device(db, key, request.device_id) - if not device_ok: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=device_msg) - return ApiResponse(success=False, error=device_msg) - - # 检查激活码是否有效 - is_valid, message = KeyService.is_valid(key, db) - if not is_valid: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=message) - return ApiResponse(success=False, error=message) - - # 检查换号频率限制 - can_switch, switch_msg = KeyService.can_switch(db, key) - if not can_switch: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=switch_msg) - return ApiResponse(success=False, error=switch_msg) - - # 释放当前账号 - if key.current_account_id: - old_account = AccountService.get_by_id(db, key.current_account_id) - if old_account: - AccountService.release(db, old_account) - - # 获取新账号 - account = AccountService.get_available(db, key.membership_type) - if not account: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message="无可用账号") - return ApiResponse(success=False, error="暂无可用账号,请稍后重试") - - # 绑定新账号并扣除额度 - KeyService.bind_account(db, key, account) - KeyService.use_switch(db, key) - AccountService.mark_used(db, account, key.id) - - LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True) - - return ApiResponse( - success=True, - message="切换成功", - data=build_account_data(account, key) - ) - - -# ========== 设备密钥信息 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"} - - keys_info = KeyService.get_device_keys(db, device_id) - - result = { - "success": True, - "device_id": device_id, - "auto": None, - "pro": None - } - - # Auto 密钥信息 - if keys_info["auto"]: - auto_data = keys_info["auto"] - master = auto_data["master"] - result["auto"] = { - "has_key": True, - "master_key": master.key[:8] + "****", - "expire_at": master.expire_at.strftime("%Y/%m/%d %H:%M:%S") if master.expire_at else None, - "merged_count": auto_data["total_keys"], - "current_account": master.current_account.email if master.current_account else None, - "status": master.status.value - } - else: - result["auto"] = {"has_key": False} - - # Pro 密钥信息 - if keys_info["pro"]: - pro_data = keys_info["pro"] - master = pro_data["master"] - result["pro"] = { - "has_key": True, - "master_key": master.key[:8] + "****", - "quota": pro_data["quota"], - "quota_used": pro_data["quota_used"], - "quota_remaining": pro_data["quota_remaining"], - "merged_count": pro_data["total_keys"], - "expire_at": master.expire_at.strftime("%Y/%m/%d %H:%M:%S") if master.expire_at else None, - "current_account": master.current_account.email if master.current_account else None, - "status": master.status.value - } - else: - result["pro"] = {"has_key": False} - - return result - - -@router.get("/device-keys/detail") -async def get_device_keys_detail(device_id: str = None, membership_type: str = None, db: Session = Depends(get_db)): - """获取设备某类型密钥的详细信息(包括所有合并的密钥)""" - if not device_id: - return {"success": False, "error": "缺少设备ID"} - - if membership_type not in ["auto", "pro", "free"]: - return {"success": False, "error": "无效的密钥类型"} - - # 映射类型 - mem_type = MembershipType.FREE if membership_type in ["auto", "free"] else MembershipType.PRO - - # 获取主密钥 - master = KeyService.get_master_key(db, device_id, mem_type) - if not master: - return {"success": True, "has_key": False, "keys": []} - - # 获取所有合并的密钥 - from app.models import ActivationKey - merged_keys = db.query(ActivationKey).filter( - ActivationKey.master_key_id == master.id - ).order_by(ActivationKey.merged_at.desc()).all() - - keys_list = [] - # 主密钥 - keys_list.append({ - "id": master.id, - "key": master.key[:8] + "****", - "is_master": True, - "status": master.status.value, - "contribution": master.quota_contribution if mem_type == MembershipType.PRO else master.duration_days, - "contribution_type": "积分" if mem_type == MembershipType.PRO else "天", - "activated_at": master.first_activated_at.strftime("%Y/%m/%d %H:%M") if master.first_activated_at else None - }) - - # 合并的密钥 - for k in merged_keys: - keys_list.append({ - "id": k.id, - "key": k.key[:8] + "****", - "is_master": False, - "status": k.status.value, - "contribution": k.quota_contribution if mem_type == MembershipType.PRO else k.duration_days, - "contribution_type": "积分" if mem_type == MembershipType.PRO else "天", - "merged_at": k.merged_at.strftime("%Y/%m/%d %H:%M") if k.merged_at else None - }) - - return { - "success": True, - "has_key": True, - "membership_type": membership_type, - "total_keys": len(keys_list), - "keys": keys_list - } - - -# ========== 版本 API ========== - -@router.get("/version") -async def get_version(): - """获取版本信息""" - return { - "success": True, - "version": "1.0.0", - "latestVersion": "1.0.0", - "updateUrl": None, - "message": None, - "forceUpdate": False - } - - -# ========== 代理配置 API ========== - -# 内存存储代理配置 (生产环境应存数据库) -proxy_config = { - "is_enabled": False, - "proxy_url": "" -} - -@router.get("/proxy-config") -async def get_proxy_config(): - """获取代理配置""" - return { - "success": True, - "data": proxy_config - } - - -@router.post("/proxy-config") -async def update_proxy_config(config: dict): - """更新代理配置""" - global proxy_config - if "is_enabled" in config: - proxy_config["is_enabled"] = config["is_enabled"] - if "proxy_url" in config: - proxy_config["proxy_url"] = config["proxy_url"] - return { - "success": True, - "message": "代理配置已更新", - "data": proxy_config - } - - -# ========== 无感换号 API ========== - -# 内存存储无感配置 (生产环境应存数据库) -seamless_config = { - "enabled": False, - "mode": "auto", - "switchThreshold": 10, - "accountPool": [], - "currentIndex": 0 -} +# ==================== 无感换号注入代码 API ==================== @router.get("/seamless/status") -async def seamless_status(db: Session = Depends(get_db)): - """获取无感换号状态""" +async def seamless_status(): + """ + 获取无感换号状态 (前端用) + 注意:实际注入状态由前端本地检测,此接口仅用于兼容 + """ return { "success": True, - "enabled": seamless_config["enabled"], - "message": "无感换号功能已就绪" if seamless_config["enabled"] else "无感换号功能未启用" - } - - -@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": "激活码不存在"} - - # 检查是否有效 - is_valid, message = KeyService.is_valid(activation_key, db) - if not is_valid: - return {"success": False, "valid": False, "error": message} - - # 获取当前绑定的账号 - locked_account = None - if activation_key.current_account_id: - account = AccountService.get_by_id(db, activation_key.current_account_id) - if account and account.status not in (AccountStatus.DISABLED, AccountStatus.EXPIRED): - locked_account = { - "email": account.email, - "membership_type": account.membership_type.value - } - - # Pro密钥:使用 quota (积分制) - # Auto密钥:使用换号次数限制 - is_pro = activation_key.membership_type == MembershipType.PRO - switch_remaining = activation_key.quota - activation_key.quota_used if is_pro else 999 # Auto不限积分 - - return { - "success": True, - "valid": True, - "switchRemaining": switch_remaining, - "canSwitch": switch_remaining > 0, - "lockedAccount": locked_account, - "membershipType": activation_key.membership_type.value, - "data": { - "canSwitch": True, - "quotaRemaining": activation_key.quota - activation_key.quota_used, - "switchCount": activation_key.switch_count - } + "is_injected": False, # 实际状态由前端本地检测 + "message": "请使用前端本地检测" } @router.get("/seamless/config") -async def get_seamless_config(): +async def get_seamless_config(db: Session = Depends(get_db)): """获取无感配置""" return { "success": True, - "data": seamless_config + "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): - """更新无感配置""" - global seamless_config - for key in ["enabled", "mode", "switchThreshold", "accountPool", "currentIndex"]: - if key in config: - seamless_config[key] = config[key] - return { - "success": True, - "message": "配置已更新", - "data": seamless_config - } - - -@router.post("/seamless/inject") -async def inject_seamless(data: dict, req: Request, db: Session = Depends(get_db)): - """注入无感模式 - 返回账号数据""" - user_key = data.get("user_key") - if not user_key: - return {"success": False, "error": "缺少user_key参数"} - - key = KeyService.get_by_key(db, user_key) - if not key: - return {"success": False, "error": "激活码不存在"} - - # 检查有效性 - is_valid, message = KeyService.is_valid(key, db) - if not is_valid: - return {"success": False, "error": message} - - # 获取账号 - account = None - if key.current_account_id: - account = AccountService.get_by_id(db, key.current_account_id) - - # 只有账号不存在或被禁用/过期才分配新的 - if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED): - account = AccountService.get_available(db, key.membership_type) - if not account: - return {"success": False, "error": "暂无可用账号"} - KeyService.bind_account(db, key, account) - AccountService.mark_used(db, account, key.id) - - LogService.log(db, key.id, "seamless_inject", account.id, ip_address=req.client.host, success=True) - - return { - "success": True, - "message": "无感模式已注入", - "data": build_account_data(account, key).model_dump() - } - - -@router.post("/seamless/restore") -async def restore_seamless(): - """恢复无感模式设置""" - global seamless_config - seamless_config["enabled"] = False - return { - "success": True, - "message": "已恢复默认设置" - } - - -@router.get("/seamless/accounts") -async def get_seamless_accounts(db: Session = Depends(get_db)): - """获取账号池列表""" - # 返回可用账号列表 (脱敏) - accounts = AccountService.get_all(db, limit=100) - account_list = [] - for acc in accounts: - if acc.status == AccountStatus.ACTIVE: - account_list.append({ - "id": acc.id, - "email": acc.email[:3] + "***" + acc.email[acc.email.index("@"):], - "membership_type": acc.membership_type.value, - "status": acc.status.value - }) - return { - "success": True, - "data": { - "accounts": account_list, - "total": len(account_list) - } - } - - -@router.post("/seamless/accounts") -async def sync_seamless_accounts(data: dict): - """同步账号池""" - # 这个接口在我们的架构中不需要实际功能 - # 账号由管理后台统一管理 - return { - "success": True, - "message": "账号由管理后台统一管理" - } - - -@router.get("/seamless/token") -async def get_seamless_token(key: str, db: Session = Depends(get_db)): - """获取无感Token""" - activation_key = KeyService.get_by_key(db, key) - if not activation_key: - return {"success": False, "error": "激活码不存在"} - - account = None - if activation_key.current_account_id: - account = AccountService.get_by_id(db, activation_key.current_account_id) - - if not account: - return {"success": False, "error": "未绑定账号"} - - return { - "success": True, - "data": build_account_data(account, activation_key).model_dump() - } + """更新无感配置 (客户端本地管理)""" + return {"success": True, "message": "配置已保存"} @router.get("/seamless/get-token") -async def get_seamless_token_v2(userKey: str = None, key: str = None, req: Request = None, db: Session = Depends(get_db)): - """获取无感Token (注入代码调用的路径,兼容 userKey 和 key 两种参数名) +async def seamless_get_token( + userKey: str = None, + key: str = None, + db: Session = Depends(get_db) +): + """ + 获取无感Token (注入代码调用) - 返回格式需要直接包含 accessToken、email 等字段,供注入代码使用 + 返回格式直接包含 token、email 等字段,供注入代码使用 """ actual_key = userKey or key if not actual_key: @@ -567,319 +471,314 @@ async def get_seamless_token_v2(userKey: str = None, key: str = None, req: Reque if not activation_key: return {"success": False, "error": "激活码不存在"} - # 检查有效性 - is_valid, message = KeyService.is_valid(activation_key, db) - if not is_valid: - return {"success": False, "error": message} + # 如果是子密钥,找到主密钥 + if activation_key.master_key_id: + activation_key = KeyService.get_by_id(db, activation_key.master_key_id) - # 获取或分配账号 - account = None - is_new = False - if activation_key.current_account_id: - account = AccountService.get_by_id(db, activation_key.current_account_id) + # 检查状态 + if activation_key.status != KeyStatus.ACTIVE: + return {"success": False, "error": "密钥未激活"} - # 只有账号不存在或被禁用/过期才分配新的 - if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED): - # Auto密钥:检查是否允许获取新账号(频率限制) - if activation_key.membership_type == MembershipType.FREE: - can_switch, switch_msg = KeyService.can_switch(db, activation_key) - if not can_switch: - return {"success": False, "error": switch_msg} + if activation_key.is_expired: + return {"success": False, "error": "密钥已过期"} - # 分配新账号 - account = AccountService.get_available(db, activation_key.membership_type) - if not account: - return {"success": False, "error": "暂无可用账号"} - KeyService.bind_account(db, activation_key, account) - AccountService.mark_used(db, account, activation_key.id) - # 记录换号(用于频率限制) - KeyService.use_switch(db, activation_key) - is_new = True + if not activation_key.seamless_enabled or not activation_key.current_account_id: + return {"success": False, "error": "未启用无感换号"} - # 只记录获取新账号的情况,不记录每次token验证 - if req and is_new: - LogService.log(db, activation_key.id, "seamless_get_token", account.id, ip_address=req.client.host, success=True, message="分配新账号") + account = AccountService.get_by_id(db, activation_key.current_account_id) + if not account: + return {"success": False, "error": "账号不存在"} - # 返回格式需要直接包含字段,供注入代码使用 - # 注入代码检查: if(d && d.accessToken) { ... } + 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": account.access_token, - "refreshToken": account.refresh_token or "", + "accessToken": access_token, + "refreshToken": refresh_token, + "workosToken": workos_token, "email": account.email, - "membership_type": account.membership_type.value, - "switchVersion": activation_key.switch_count, # 用于检测是否需要更新 - "switchRemaining": activation_key.quota - activation_key.quota_used, - "is_new": is_new, - "machineIds": None # 机器ID由客户端本地管理 + "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") -async def switch_seamless_token(data: dict, req: Request, db: Session = Depends(get_db)): - """切换无感Token""" - user_key = data.get("userKey") - if not user_key: - return {"switched": False, "message": "缺少userKey参数"} - - # 复用切换逻辑 - request = SwitchAccountRequest(key=user_key) - return await switch_token_impl(request, req, db) - - @router.post("/seamless/switch-token") -async def switch_seamless_token_v2(data: dict, req: Request, db: Session = Depends(get_db)): - """切换无感Token (client.js 调用的路径)""" +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 = SwitchAccountRequest(key=user_key) - return await switch_token_impl(request, req, db) + request = SwitchRequest(key=user_key) + result = await switch_account(request, req, db) + if not result.get("success"): + return {"switched": False, "message": result.get("error")} -async def switch_token_impl(request: SwitchAccountRequest, req: Request, db: Session): - """切换Token实现 - 返回插件期望的格式""" - key = KeyService.get_by_key(db, request.key) + 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") - if not key: - return {"switched": False, "message": "激活码不存在"} - - # 检查设备限制 - if request.device_id: - device_ok, device_msg = KeyService.check_device(db, key, request.device_id) - if not device_ok: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=device_msg) - return {"switched": False, "message": device_msg} - - # 检查激活码是否有效 - is_valid, message = KeyService.is_valid(key, db) - if not is_valid: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=message) - return {"switched": False, "message": message} - - # 检查换号频率限制 - can_switch, switch_msg = KeyService.can_switch(db, key) - if not can_switch: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=switch_msg) - return {"switched": False, "message": switch_msg} - - # 释放当前账号 - if key.current_account_id: - old_account = AccountService.get_by_id(db, key.current_account_id) - if old_account: - AccountService.release(db, old_account) - - # 获取新账号 - account = AccountService.get_available(db, key.membership_type) - if not account: - LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message="无可用账号") - return {"switched": False, "message": "暂无可用账号,请稍后重试"} - - # 绑定新账号并扣除额度 - KeyService.bind_account(db, key, account) - KeyService.use_switch(db, key) - AccountService.mark_used(db, account, key.id) - - LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True) + # 构建前端 _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": key.quota - key.quota_used, - "email": account.email, - "data": build_account_data(account, key) + "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 使用 } -# ========== 代理配置 API ========== +@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": "缺少激活码参数"} -@router.get("/proxy-config") -async def get_proxy_config(db: Session = Depends(get_db)): - """获取代理配置""" - return { - "success": True, - "enabled": False, - "host": "", - "port": 0, - "username": "", - "password": "" - } + 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) -@router.put("/proxy-config") -async def update_proxy_config(config: dict, db: Session = Depends(get_db)): - """更新代理配置 (客户端本地使用,后端仅返回成功)""" - return {"success": True, "message": "代理配置已保存"} + # 检查是否有效 + if activation_key.status != KeyStatus.ACTIVE: + return {"success": False, "valid": False, "error": "密钥未激活"} + if activation_key.is_expired: + return {"success": False, "valid": False, "error": "密钥已过期"} -# ========== 无感换号配置 API ========== + # 获取当前账号信息 + 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 + } -@router.get("/seamless/status") -async def get_seamless_status(db: Session = Depends(get_db)): - """获取无感换号全局状态""" - settings = GlobalSettingsService.get_all(db) - account_stats = AccountService.count(db) - return { - "success": True, - "enabled": True, - "available_accounts": account_stats.get("active", 0), - "total_accounts": account_stats.get("total", 0), - "switch_interval_minutes": settings.auto_switch_interval_minutes, - "max_switches_per_day": settings.auto_max_switches_per_day - } + # 计算剩余次数 + is_pro = activation_key.membership_type == KeyMembershipType.PRO + switch_remaining = activation_key.quota_remaining if is_pro else 999 - -@router.get("/seamless/config") -async def get_seamless_config(db: Session = Depends(get_db)): - """获取无感换号配置""" - settings = GlobalSettingsService.get_all(db) - return { - "success": True, - "auto_switch": True, - "switch_interval_minutes": settings.auto_switch_interval_minutes, - "max_switches_per_day": settings.auto_max_switches_per_day, - "pro_quota_cost": settings.pro_quota_cost - } - - -@router.post("/seamless/config") -async def update_seamless_config(config: dict, db: Session = Depends(get_db)): - """更新无感换号配置""" - # 从请求中提取配置 - updates = {} - if "switch_interval_minutes" in config: - updates["auto_switch_interval_minutes"] = config["switch_interval_minutes"] - if "max_switches_per_day" in config: - updates["auto_max_switches_per_day"] = config["max_switches_per_day"] - if "pro_quota_cost" in config: - updates["pro_quota_cost"] = config["pro_quota_cost"] - - if updates: - GlobalSettingsService.update_all(db, **updates) - - return {"success": True, "message": "配置已更新"} - - -@router.post("/seamless/inject") -async def seamless_inject(data: dict, db: Session = Depends(get_db)): - """注入无感换号代码 (客户端本地操作,后端仅记录)""" - user_key = data.get("userKey") - if user_key: - key = KeyService.get_by_key(db, user_key) - if key: - LogService.log(db, key.id, "seamless_inject", success=True, message="启用无感换号") - return {"success": True, "message": "无感换号已启用"} - - -@router.post("/seamless/restore") -async def seamless_restore(data: dict = None, db: Session = Depends(get_db)): - """恢复原始代码 (客户端本地操作,后端仅记录)""" - if data and data.get("userKey"): - key = KeyService.get_by_key(db, data["userKey"]) - if key: - LogService.log(db, key.id, "seamless_restore", success=True, message="禁用无感换号") - return {"success": True, "message": "已恢复原始状态"} - - -@router.get("/seamless/accounts") -async def get_seamless_accounts(userKey: str = None, db: Session = Depends(get_db)): - """获取可用账号列表 (供管理使用)""" - if not userKey: - return {"success": False, "error": "缺少 userKey 参数"} - - key = KeyService.get_by_key(db, userKey) - if not key: - return {"success": False, "error": "激活码不存在"} - - # 获取该激活码类型的可用账号数量 - accounts = AccountService.get_all(db, limit=100) - available_count = sum(1 for a in accounts if a.status == AccountStatus.ACTIVE and a.membership_type == key.membership_type) + # 计算下次可换号时间 (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, - "available_count": available_count, - "membership_type": key.membership_type.value + "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 + } } -@router.post("/seamless/sync-accounts") -async def sync_seamless_accounts(data: dict, db: Session = Depends(get_db)): - """同步账号 (客户端上报账号信息)""" - # 客户端可能上报当前使用的账号状态 - return {"success": True, "message": "同步成功"} +# ==================== 设备密钥信息 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 ========== +# ==================== 账号用量查询 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, - "current_version": "0.4.5", - "latest_version": "0.4.5", + "version": "2.1.0", + "latest_version": "2.1.0", "has_update": False, "download_url": "", "changelog": "" } -# ========== 公告 API ========== - @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": True, - "title": "欢迎使用蜂鸟Pro", - "content": "感谢使用蜂鸟Pro!\n\n如有问题请联系客服。", - "type": "info", - "created_at": "2024-12-17 00:00:00" + "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, - "data": { - "is_active": True, - "title": "欢迎使用蜂鸟Pro", - "content": "感谢使用蜂鸟Pro!\n\n如有问题请联系客服。", - "type": "info", - "created_at": "2024-12-17 00:00:00" - } + "enabled": False, + "host": "", + "port": 0 } -# ========== 用量查询 API ========== - -@router.get("/usage") -async def get_usage(userKey: str = None, db: Session = Depends(get_db)): - """获取用量信息""" - if not userKey: - return {"success": False, "error": "缺少 userKey 参数"} - - key = KeyService.get_by_key(db, userKey) - if not key: - return {"success": False, "error": "激活码不存在"} - - account = None - if key.current_account_id: - account = AccountService.get_by_id(db, key.current_account_id) - - return { - "success": True, - "membership_type": key.membership_type.value, - "quota": key.quota, - "quota_used": key.quota_used, - "quota_remaining": key.quota - key.quota_used, - "switch_count": key.switch_count, - "expire_at": key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None, - "current_email": account.email if account else None - } +@router.post("/proxy-config") +async def update_proxy_config(config: dict): + """更新代理配置 (客户端本地管理)""" + return {"success": True, "message": "代理配置已保存"} diff --git a/backend/app/config.py b/backend/app/config.py index 786a8e4..9114bbc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,20 +8,20 @@ class Settings(BaseSettings): DB_HOST: str = "127.0.0.1" DB_PORT: int = 3306 DB_USER: str = "cursorpro" - DB_PASSWORD: str = "jf6BntYBPz6KH6Pw" + DB_PASSWORD: str = "" DB_NAME: str = "cursorpro" # JWT配置 - SECRET_KEY: str = "hb8x2kF9mNpQ3rT7vY1zA4cE6gJ0lO5sU8wB2dH4" + SECRET_KEY: str = "" # Must be set via .env ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天 # 管理员账号 ADMIN_USERNAME: str = "admin" - ADMIN_PASSWORD: str = "Hb@2024Pro!" + ADMIN_PASSWORD: str = "" # 外部系统API Token (用于批量上传账号等) - API_TOKEN: str = "hb-ext-9kX2mP5nQ8rT1vY4zA7c" + API_TOKEN: str = "" @property def DATABASE_URL(self) -> str: diff --git a/backend/app/database.py b/backend/app/database.py index 2c94ad1..d767ce5 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,6 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, DeclarativeBase from app.config import settings # SQLite 不支持某些连接池选项 @@ -19,7 +18,8 @@ else: ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() +class Base(DeclarativeBase): + pass def get_db(): db = SessionLocal() diff --git a/backend/app/main.py b/backend/app/main.py index edf1754..e071bed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,23 +4,40 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, FileResponse from contextlib import asynccontextmanager import os +import logging from app.database import engine, Base from app.api import client_router, admin_router +from app.tasks import start_scheduler, stop_scheduler, run_startup_tasks + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s" +) @asynccontextmanager async def lifespan(app: FastAPI): # 启动时创建数据库表 Base.metadata.create_all(bind=engine) + + # 启动后台任务调度器 + start_scheduler() + + # 运行启动任务 + await run_startup_tasks() + yield - # 关闭时清理 + + # 关闭时停止调度器 + stop_scheduler() app = FastAPI( - title="CursorPro 管理后台", - description="Cursor 账号管理系统 API", - version="1.0.0", + title="蜂鸟Pro 管理后台", + description="蜂鸟Pro 账号管理系统 API v2.1", + version="2.1.0", lifespan=lifespan ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d70b927..ae2a1f8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,4 @@ -from app.models.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings, MembershipType, AccountStatus, KeyStatus +from app.models.models import ( + CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings, Announcement, + AccountStatus, AccountType, KeyMembershipType, KeyStatus +) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 109a118..761a765 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,93 +1,218 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Enum +""" +蜂鸟Pro 数据模型 v2.1 +基于系统设计文档重构 +""" +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Enum, JSON, DECIMAL, BigInteger from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base import enum -class MembershipType(str, enum.Enum): - FREE = "free" - PRO = "pro" + +# ==================== 枚举类型 ==================== class AccountStatus(str, enum.Enum): - ACTIVE = "active" # 可用 - IN_USE = "in_use" # 使用中 - DISABLED = "disabled" # 禁用 - EXPIRED = "expired" # 过期 + """账号状态""" + PENDING = "pending" # 待分析 + ANALYZING = "analyzing" # 分析中 + AVAILABLE = "available" # 可用 + IN_USE = "in_use" # 使用中 + EXHAUSTED = "exhausted" # 已耗尽 + INVALID = "invalid" # Token无效 + DISABLED = "disabled" # 已禁用 + + +class AccountType(str, enum.Enum): + """账号类型 (从Cursor API分析得出)""" + FREE_TRIAL = "free_trial" # 免费试用 + PRO = "pro" # Pro会员 + FREE = "free" # 免费版 + BUSINESS = "business" # 商业版 + UNKNOWN = "unknown" # 未知 + + +class KeyMembershipType(str, enum.Enum): + """密钥套餐类型""" + AUTO = "auto" # Auto池 - 按时间计费,无限换号 + PRO = "pro" # Pro池 - 按积分计费 + class KeyStatus(str, enum.Enum): + """密钥状态""" UNUSED = "unused" # 未使用 - ACTIVE = "active" # 已激活(主密钥) - MERGED = "merged" # 已合并到主密钥 - REVOKED = "revoked" # 已撤销 - DISABLED = "disabled" # 禁用 - EXPIRED = "expired" # 过期 + ACTIVE = "active" # 已激活 + EXPIRED = "expired" # 已过期 + DISABLED = "disabled" # 已禁用 +# ==================== 数据模型 ==================== + class CursorAccount(Base): - """Cursor 账号池""" + """ + Cursor 账号表 + 存储从Cursor API获取的账号信息和用量数据 + """ __tablename__ = "cursor_accounts" id = Column(Integer, primary_key=True, autoincrement=True) - email = Column(String(255), unique=True, nullable=False, comment="邮箱") - access_token = Column(Text, nullable=False, comment="访问令牌") - refresh_token = Column(Text, nullable=True, comment="刷新令牌") - workos_session_token = Column(Text, nullable=True, comment="WorkOS会话令牌") - membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="会员类型") - status = Column(Enum(AccountStatus), default=AccountStatus.ACTIVE, comment="状态") + email = Column(String(255), nullable=False, comment="账号邮箱") + token = Column(Text, nullable=False, comment="认证Token (user_id::jwt)") + password = Column(String(255), nullable=True, comment="账号密码(可选)") + access_token = Column(Text, nullable=True, comment="Access Token (GraphQL/API)") + refresh_token = Column(Text, nullable=True, comment="Refresh Token") + workos_session_token = Column(Text, nullable=True, comment="Workos Session Token") - # 使用统计 - usage_count = Column(Integer, default=0, comment="使用次数") - last_used_at = Column(DateTime, nullable=True, comment="最后使用时间") - current_key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=True, comment="当前使用的激活码") + # 状态管理 + status = Column( + Enum(AccountStatus), + default=AccountStatus.PENDING, + index=True, + comment="账号状态" + ) - # 备注 + # 账号类型 (从Cursor API自动分析得出) + account_type = Column( + Enum(AccountType), + default=AccountType.UNKNOWN, + index=True, + comment="账号类型" + ) + + # 用量信息 (从Cursor API获取) + membership_type = Column(String(50), nullable=True, comment="会员类型原始值") + billing_cycle_start = Column(DateTime, nullable=True, comment="计费周期开始") + billing_cycle_end = Column(DateTime, nullable=True, comment="计费周期结束") + trial_days_remaining = Column(Integer, default=0, comment="试用剩余天数") + + # 用量统计 + usage_limit = Column(Integer, default=0, comment="用量上限") + usage_used = Column(Integer, default=0, comment="已用用量") + usage_remaining = Column(Integer, default=0, comment="剩余用量") + usage_percent = Column(DECIMAL(5, 2), default=0, comment="用量百分比") + + # 详细用量 (从聚合API获取) + total_requests = Column(Integer, default=0, comment="总请求次数") + total_input_tokens = Column(BigInteger, default=0, comment="总输入Token") + total_output_tokens = Column(BigInteger, default=0, comment="总输出Token") + total_cost_cents = Column(DECIMAL(10, 2), default=0, comment="总花费(美分)") + + # 锁定信息 + locked_by_key_id = Column( + Integer, + ForeignKey("activation_keys.id"), + nullable=True, + index=True, + comment="被哪个激活码锁定" + ) + locked_at = Column(DateTime, nullable=True, comment="锁定时间") + + # 分析信息 + last_analyzed_at = Column(DateTime, nullable=True, comment="最后分析时间") + analyze_error = Column(String(500), nullable=True, comment="分析错误信息") + + # 元数据 remark = Column(String(500), nullable=True, comment="备注") - created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + # 关系 + locked_by_key = relationship("ActivationKey", foreign_keys=[locked_by_key_id]) + + @property + def total_cost_usd(self): + """总花费(美元)""" + if self.total_cost_cents: + return float(self.total_cost_cents) / 100 + return 0.0 + + def to_dict(self): + """转换为字典""" + return { + "id": self.id, + "email": self.email, + "status": self.status.value if self.status else None, + "account_type": self.account_type.value if self.account_type else None, + "membership_type": self.membership_type, + "trial_days_remaining": self.trial_days_remaining, + "usage_limit": self.usage_limit, + "usage_used": self.usage_used, + "usage_remaining": self.usage_remaining, + "usage_percent": float(self.usage_percent) if self.usage_percent else 0, + "total_requests": self.total_requests, + "total_cost_usd": self.total_cost_usd, + "last_analyzed_at": self.last_analyzed_at.isoformat() if self.last_analyzed_at else None, + "remark": self.remark + } + class ActivationKey(Base): - """激活码""" + """ + 激活码表 + 支持Auto/Pro双池,密钥合并,无感换号 + """ __tablename__ = "activation_keys" id = Column(Integer, primary_key=True, autoincrement=True) key = Column(String(64), unique=True, nullable=False, index=True, comment="激活码") - status = Column(Enum(KeyStatus), default=KeyStatus.UNUSED, comment="状态") + + # 状态 + status = Column( + Enum(KeyStatus), + default=KeyStatus.UNUSED, + index=True, + comment="状态" + ) # 套餐类型 - membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="套餐类型: free=Auto池, pro=Pro池") + membership_type = Column( + Enum(KeyMembershipType), + default=KeyMembershipType.PRO, + index=True, + comment="套餐类型: auto/pro" + ) - # 密钥合并关系 - master_key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=True, comment="主密钥ID(如果已合并)") + # 密钥合并 (支持多密钥合并到主密钥) + master_key_id = Column( + Integer, + ForeignKey("activation_keys.id"), + nullable=True, + index=True, + comment="主密钥ID (如果已合并到其他密钥)" + ) + merged_count = Column(Integer, default=0, comment="已合并的子密钥数量") + merged_at = Column(DateTime, nullable=True, comment="合并时间") + + # 设备绑定 device_id = Column(String(255), nullable=True, index=True, comment="绑定的设备ID") - # 该密钥贡献的资源 (创建时设置,不变) - duration_days = Column(Integer, default=30, comment="Auto: 该密钥贡献的天数") - quota_contribution = Column(Integer, default=500, comment="Pro: 该密钥贡献的积分") + # ===== Auto密钥专属字段 ===== + duration_days = Column(Integer, default=30, comment="该密钥贡献的天数") + expire_at = Column(DateTime, nullable=True, comment="到期时间 (首次激活时计算)") - # 额度系统 (仅主密钥使用,累计值) - quota = Column(Integer, default=500, comment="Pro主密钥: 总额度(累加)") - quota_used = Column(Integer, default=0, comment="Pro主密钥: 已用额度") + # ===== Pro密钥专属字段 ===== + quota_contribution = Column(Integer, default=500, comment="该密钥贡献的积分") + quota = Column(Integer, default=500, comment="总积分 (主密钥累加值)") + quota_used = Column(Integer, default=0, comment="已用积分") - # 有效期 (仅主密钥使用) - expire_at = Column(DateTime, nullable=True, comment="Auto主密钥: 到期时间(累加)") + # ===== 无感换号 ===== + seamless_enabled = Column(Boolean, default=False, comment="是否启用无感换号") + current_account_id = Column( + Integer, + ForeignKey("cursor_accounts.id"), + nullable=True, + comment="当前使用的账号ID" + ) + + # ===== 统计 ===== + switch_count = Column(Integer, default=0, comment="总换号次数") + last_switch_at = Column(DateTime, nullable=True, comment="最后换号时间") + + # ===== 设备限制 ===== + max_devices = Column(Integer, default=2, comment="最大设备数") # 激活信息 first_activated_at = Column(DateTime, nullable=True, comment="首次激活时间") - merged_at = Column(DateTime, nullable=True, comment="合并时间") - - # 设备限制 (可换设备,此字段保留但不强制) - max_devices = Column(Integer, default=3, comment="最大设备数(可换设备)") - - # 当前绑定的账号 (仅主密钥使用) - current_account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True) - current_account = relationship("CursorAccount", foreign_keys=[current_account_id]) - - # 统计 (仅主密钥使用) - switch_count = Column(Integer, default=0, comment="总换号次数") - last_switch_at = Column(DateTime, nullable=True, comment="最后换号时间") - merged_count = Column(Integer, default=0, comment="已合并的密钥数量") + last_active_at = Column(DateTime, nullable=True, comment="最后活跃时间") # 备注 remark = Column(String(500), nullable=True, comment="备注") @@ -97,52 +222,174 @@ class ActivationKey(Base): # 关系 master_key = relationship("ActivationKey", remote_side=[id], foreign_keys=[master_key_id]) + current_account = relationship("CursorAccount", foreign_keys=[current_account_id]) @property def valid_days(self): """兼容旧API: duration_days的别名""" return self.duration_days or 0 + @property + def quota_remaining(self): + """剩余积分""" + return max(0, (self.quota or 0) - (self.quota_used or 0)) + + @property + def is_expired(self): + """是否已过期""" + from datetime import datetime + if self.membership_type == KeyMembershipType.AUTO: + if self.expire_at: + return datetime.now() > self.expire_at + return False + elif self.membership_type == KeyMembershipType.PRO: + return self.quota_remaining <= 0 + return False + + def to_dict(self, include_account=False): + """转换为字典""" + from datetime import datetime + + data = { + "id": self.id, + "key": self.key, + "status": self.status.value if self.status else None, + "membership_type": self.membership_type.value if self.membership_type else None, + "seamless_enabled": self.seamless_enabled, + "switch_count": self.switch_count, + "first_activated_at": self.first_activated_at.isoformat() if self.first_activated_at else None, + "last_active_at": self.last_active_at.isoformat() if self.last_active_at else None, + } + + # Auto密钥信息 + if self.membership_type == KeyMembershipType.AUTO: + data["expire_at"] = self.expire_at.isoformat() if self.expire_at else None + if self.expire_at: + delta = self.expire_at - datetime.now() + data["days_remaining"] = max(0, delta.days) + else: + data["days_remaining"] = self.duration_days + + # Pro密钥信息 + if self.membership_type == KeyMembershipType.PRO: + data["quota"] = self.quota + data["quota_used"] = self.quota_used + data["quota_remaining"] = self.quota_remaining + + # 当前账号信息 + if include_account and self.current_account: + data["current_account"] = self.current_account.to_dict() + + return data + class KeyDevice(Base): - """激活码绑定的设备""" + """ + 设备绑定表 + 记录激活码绑定的所有设备 + """ __tablename__ = "key_devices" id = Column(Integer, primary_key=True, autoincrement=True) key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False) device_id = Column(String(255), nullable=False, comment="设备标识") device_name = Column(String(255), nullable=True, comment="设备名称") + platform = Column(String(50), nullable=True, comment="平台: windows/macos/linux") last_active_at = Column(DateTime, nullable=True, comment="最后活跃时间") created_at = Column(DateTime, server_default=func.now()) + # 关系 key = relationship("ActivationKey") + class Meta: + unique_together = [("key_id", "device_id")] + + +class UsageLog(Base): + """ + 使用日志表 + 记录所有操作 + """ + __tablename__ = "usage_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False, index=True) + account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True) + + action = Column( + String(50), + nullable=False, + index=True, + comment="操作类型: activate/verify/enable_seamless/disable_seamless/switch/auto_switch/release/merge" + ) + + success = Column(Boolean, default=True, comment="是否成功") + message = Column(String(500), nullable=True, comment="消息") + + # 请求信息 + ip_address = Column(String(50), nullable=True) + user_agent = Column(String(500), nullable=True) + device_id = Column(String(255), nullable=True) + + # 用量快照 (换号时记录) + usage_snapshot = Column(JSON, nullable=True, comment="用量快照") + + created_at = Column(DateTime, server_default=func.now(), index=True) + + # 关系 + key = relationship("ActivationKey") + account = relationship("CursorAccount") + class GlobalSettings(Base): - """全局设置""" + """ + 全局设置表 + 存储系统配置 + """ __tablename__ = "global_settings" id = Column(Integer, primary_key=True, autoincrement=True) key = Column(String(100), unique=True, nullable=False, comment="设置键") value = Column(String(500), nullable=False, comment="设置值") + value_type = Column(String(20), default="string", comment="值类型: string/int/float/bool/json") description = Column(String(500), nullable=True, comment="描述") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + @classmethod + def get_default_settings(cls): + """默认设置""" + return [ + # ===== 密钥策略 ===== + {"key": "key_max_devices", "value": "2", "value_type": "int", "description": "主密钥最大设备数"}, + {"key": "auto_merge_enabled", "value": "true", "value_type": "bool", "description": "是否启用同类型密钥自动合并"}, + # ===== 自动检测开关 ===== + {"key": "auto_analyze_enabled", "value": "false", "value_type": "bool", "description": "是否启用自动账号分析"}, + {"key": "auto_switch_enabled", "value": "true", "value_type": "bool", "description": "是否启用自动换号"}, + # ===== 账号分析设置 ===== + {"key": "account_analyze_interval", "value": "300", "value_type": "int", "description": "账号分析间隔(秒)"}, + {"key": "account_analyze_batch_size", "value": "10", "value_type": "int", "description": "每批分析账号数量"}, + # ===== 换号阈值 ===== + {"key": "auto_switch_threshold", "value": "98", "value_type": "int", "description": "Auto池自动换号阈值(用量百分比)"}, + {"key": "pro_switch_threshold", "value": "98", "value_type": "int", "description": "Pro池自动换号阈值(用量百分比)"}, + # ===== 换号限制 ===== + {"key": "max_switch_per_day", "value": "50", "value_type": "int", "description": "每日最大换号次数"}, + {"key": "auto_daily_switches", "value": "999", "value_type": "int", "description": "Auto密钥每日换号次数限制"}, + {"key": "auto_switch_interval", "value": "0", "value_type": "int", "description": "Auto密钥换号冷却时间(分钟), 0表示无限制"}, + {"key": "pro_quota_per_switch", "value": "1", "value_type": "int", "description": "Pro密钥每次换号消耗积分"}, + ] -class UsageLog(Base): - """使用日志""" - __tablename__ = "usage_logs" + +class Announcement(Base): + """ + 公告表 + 管理员发布的系统公告 + """ + __tablename__ = "announcements" id = Column(Integer, primary_key=True, autoincrement=True) - key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False) - account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True) - action = Column(String(50), nullable=False, comment="操作类型: verify/switch/seamless") - ip_address = Column(String(50), nullable=True) - user_agent = Column(String(500), nullable=True) - success = Column(Boolean, default=True) - message = Column(String(500), nullable=True) - + title = Column(String(200), nullable=False, comment="公告标题") + content = Column(Text, nullable=False, comment="公告内容") + type = Column(String(20), default="info", comment="公告类型: info/warning/error/success") + is_active = Column(Boolean, default=True, comment="是否启用") created_at = Column(DateTime, server_default=func.now()) - - key = relationship("ActivationKey") - account = relationship("CursorAccount") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 27f293a..f752f43 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,37 +1,67 @@ -from pydantic import BaseModel, EmailStr -from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, model_validator +from typing import Optional, List, Any from datetime import datetime -from app.models.models import MembershipType, AccountStatus, KeyStatus +from app.models.models import KeyMembershipType, AccountStatus, KeyStatus # ========== 账号相关 ========== class AccountBase(BaseModel): + """账号基础信息 (用于创建/更新)""" email: str - access_token: str - refresh_token: Optional[str] = None - workos_session_token: Optional[str] = None - membership_type: MembershipType = MembershipType.PRO + token: Optional[str] = Field(None, description="兼容旧字段: user_id::jwt") + access_token: Optional[str] = Field(None, description="Access Token") + refresh_token: Optional[str] = Field(None, description="Refresh Token") + workos_session_token: Optional[str] = Field(None, description="WorkosCursorSessionToken") + password: Optional[str] = None remark: Optional[str] = None class AccountCreate(AccountBase): - pass + """创建账号 (兼容旧字段)""" + + @model_validator(mode='before') + @classmethod + def ensure_token(cls, data: Any) -> Any: + """确保至少提供一个 Token""" + if isinstance(data, dict): + if not data.get('token'): + for field in ("workos_session_token", "access_token"): + if data.get(field): + data['token'] = data[field] + break + return data class AccountUpdate(BaseModel): email: Optional[str] = None + token: Optional[str] = None access_token: Optional[str] = None refresh_token: Optional[str] = None workos_session_token: Optional[str] = None - membership_type: Optional[MembershipType] = None + password: Optional[str] = None status: Optional[AccountStatus] = None remark: Optional[str] = None -class AccountResponse(AccountBase): +class AccountResponse(BaseModel): + """账号响应 (匹配 CursorAccount 模型)""" id: int + email: str + token: str + access_token: Optional[str] = None + refresh_token: Optional[str] = None + workos_session_token: Optional[str] = None + password: Optional[str] = None status: AccountStatus - usage_count: int - last_used_at: Optional[datetime] = None - current_key_id: Optional[int] = None + account_type: Optional[str] = None + membership_type: Optional[str] = None + trial_days_remaining: int = 0 + usage_limit: int = 0 + usage_used: int = 0 + usage_remaining: int = 0 + usage_percent: float = 0 + total_requests: int = 0 + locked_by_key_id: Optional[int] = None + last_analyzed_at: Optional[datetime] = None + remark: Optional[str] = None created_at: datetime updated_at: datetime @@ -51,7 +81,7 @@ class ExternalAccountItem(BaseModel): access_token: str refresh_token: Optional[str] = None workos_session_token: Optional[str] = None - membership_type: Optional[str] = "free" # free/pro, 默认free(auto账号) + membership_type: Optional[str] = "free" # Cursor账号类型: free/free_trial/pro/business remark: Optional[str] = None class ExternalBatchUpload(BaseModel): @@ -72,7 +102,7 @@ class ExternalBatchResponse(BaseModel): # ========== 激活码相关 ========== class KeyBase(BaseModel): - membership_type: MembershipType = MembershipType.PRO # pro=高级模型, free=无限auto + membership_type: KeyMembershipType = KeyMembershipType.PRO # pro=高级模型, auto=无限换号 quota: int = 500 # 总额度 (仅Pro有效) valid_days: int = 30 # 有效天数,0表示永久 (仅Auto有效) max_devices: int = 2 # 最大设备数 @@ -83,7 +113,7 @@ class KeyCreate(KeyBase): count: int = 1 # 批量生成数量 class KeyUpdate(BaseModel): - membership_type: Optional[MembershipType] = None + membership_type: Optional[KeyMembershipType] = None quota: Optional[int] = None valid_days: Optional[int] = None max_devices: Optional[int] = None @@ -98,15 +128,15 @@ class KeyResponse(BaseModel): id: int key: str status: KeyStatus - membership_type: MembershipType + membership_type: KeyMembershipType quota: int quota_used: int quota_remaining: Optional[int] = None # 剩余额度(计算字段) - valid_days: int + valid_days: int = 30 # 有效天数 (映射自 duration_days) first_activated_at: Optional[datetime] = None expire_at: Optional[datetime] = None max_devices: int - switch_count: int + switch_count: int = 0 last_switch_at: Optional[datetime] = None current_account_id: Optional[int] = None remark: Optional[str] = None @@ -184,17 +214,43 @@ class LoginRequest(BaseModel): class GlobalSettingsResponse(BaseModel): """全局设置响应""" - # Auto密钥设置 - auto_switch_interval_minutes: int = 20 # 换号最小间隔(分钟) - auto_max_switches_per_day: int = 50 # 每天最大换号次数 - # Pro密钥设置 - pro_quota_cost: int = 50 # 每次换号扣除额度 + # ===== 密钥策略 ===== + key_max_devices: int = 2 # 主密钥最大设备数 + auto_merge_enabled: bool = True # 是否启用同类型密钥自动合并 + # ===== 自动检测开关 ===== + auto_analyze_enabled: bool = False # 是否启用自动账号分析 + auto_switch_enabled: bool = True # 是否启用自动换号 + # ===== 账号分析设置 ===== + account_analyze_interval: int = 300 # 账号分析间隔(秒) + account_analyze_batch_size: int = 10 # 每批分析账号数量 + # ===== 换号阈值 ===== + auto_switch_threshold: int = 98 # Auto池自动换号阈值(用量百分比) + pro_switch_threshold: int = 98 # Pro池自动换号阈值(用量百分比) + # ===== 换号限制 ===== + max_switch_per_day: int = 50 # 每日最大换号次数 + auto_daily_switches: int = 999 # Auto密钥每日换号次数限制 + auto_switch_interval: int = 0 # Auto密钥换号冷却时间(分钟), 0表示无限制 + pro_quota_per_switch: int = 1 # Pro密钥每次换号消耗积分 class GlobalSettingsUpdate(BaseModel): """更新全局设置""" - auto_switch_interval_minutes: Optional[int] = None - auto_max_switches_per_day: Optional[int] = None - pro_quota_cost: Optional[int] = None + # ===== 密钥策略 ===== + key_max_devices: Optional[int] = None + auto_merge_enabled: Optional[bool] = None + # ===== 自动检测开关 ===== + auto_analyze_enabled: Optional[bool] = None + auto_switch_enabled: Optional[bool] = None + # ===== 账号分析设置 ===== + account_analyze_interval: Optional[int] = None + account_analyze_batch_size: Optional[int] = None + # ===== 换号阈值 ===== + auto_switch_threshold: Optional[int] = None + pro_switch_threshold: Optional[int] = None + # ===== 换号限制 ===== + max_switch_per_day: Optional[int] = None + auto_daily_switches: Optional[int] = None + auto_switch_interval: Optional[int] = None + pro_quota_per_switch: Optional[int] = None # ========== 批量操作相关 ========== @@ -210,3 +266,20 @@ class BatchExtendResponse(BaseModel): success: int failed: int errors: List[str] = [] + + +# ========== 公告相关 ========== + +class AnnouncementCreate(BaseModel): + """创建公告""" + title: str + content: str + type: str = "info" # info/warning/error/success + is_active: bool = True + +class AnnouncementUpdate(BaseModel): + """更新公告""" + title: Optional[str] = None + content: Optional[str] = None + type: Optional[str] = None + is_active: Optional[bool] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 71c4be4..51f82f4 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,4 +1,6 @@ -from app.services.account_service import AccountService, KeyService, LogService, GlobalSettingsService, BatchService +from app.services.account_service import ( + AccountService, KeyService, LogService, GlobalSettingsService, BatchService, generate_key +) from app.services.auth_service import authenticate_admin, create_access_token, get_current_user from app.services.cursor_usage_service import ( CursorUsageService, @@ -7,5 +9,9 @@ from app.services.cursor_usage_service import ( check_account_valid, get_account_usage, batch_check_accounts, - check_and_classify_account + check_and_classify_account, + analyze_account_from_token, + quick_validate_token, + map_membership_to_account_type, + calculate_usage_percent ) diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py index 89c5f53..ca81e89 100644 --- a/backend/app/services/account_service.py +++ b/backend/app/services/account_service.py @@ -1,11 +1,19 @@ +""" +蜂鸟Pro 账号服务 v2.1 +基于系统设计文档重构 +""" import secrets import string from datetime import datetime, timedelta -from typing import Optional, List, Tuple +from typing import Optional, List, Tuple, Dict, Any from sqlalchemy.orm import Session from sqlalchemy import func, and_, or_ -from app.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, MembershipType, AccountStatus, KeyStatus, GlobalSettings -from app.schemas import AccountCreate, KeyCreate +from decimal import Decimal + +from app.models.models import ( + CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings, + AccountStatus, AccountType, KeyMembershipType, KeyStatus +) def generate_key(length: int = 32) -> str: @@ -14,19 +22,38 @@ def generate_key(length: int = 32) -> str: return ''.join(secrets.choice(chars) for _ in range(length)) +# ==================== 账号服务 ==================== + class AccountService: - """账号管理服务""" + """Cursor 账号管理服务""" @staticmethod - def create(db: Session, account: AccountCreate) -> CursorAccount: - """创建账号""" + def create( + db: Session, + email: str, + token: Optional[str] = None, + password: str = None, + remark: str = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + workos_session_token: Optional[str] = None + ) -> CursorAccount: + """ + 创建账号 (状态为 pending,等待后台分析) + """ + resolved_token = workos_session_token or token or access_token + if not resolved_token: + raise ValueError("需要至少提供一个 Token (Workos 或 Access)") + db_account = CursorAccount( - email=account.email, - access_token=account.access_token, - refresh_token=account.refresh_token, - workos_session_token=account.workos_session_token, - membership_type=account.membership_type, - remark=account.remark + email=email, + token=resolved_token, + access_token=access_token, + refresh_token=refresh_token, + workos_session_token=workos_session_token or token, + password=password, + status=AccountStatus.PENDING, + remark=remark ) db.add(db_account) db.commit() @@ -42,17 +69,109 @@ class AccountService: return db.query(CursorAccount).filter(CursorAccount.email == email).first() @staticmethod - def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[CursorAccount]: - return db.query(CursorAccount).offset(skip).limit(limit).all() + def get_all(db: Session, skip: int = 0, limit: int = 100, status: AccountStatus = None) -> List[CursorAccount]: + query = db.query(CursorAccount) + if status: + query = query.filter(CursorAccount.status == status) + return query.order_by(CursorAccount.id.desc()).offset(skip).limit(limit).all() @staticmethod - def get_available(db: Session, membership_type: MembershipType = None) -> Optional[CursorAccount]: - """获取一个可用账号""" - query = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE) - if membership_type: - query = query.filter(CursorAccount.membership_type == membership_type) - # 优先选择使用次数少的 - return query.order_by(CursorAccount.usage_count.asc()).first() + def get_available_for_key(db: Session, key: ActivationKey) -> Optional[CursorAccount]: + """ + 为密钥分配一个可用账号 + 根据密钥类型选择合适的账号池 + """ + query = db.query(CursorAccount).filter( + CursorAccount.status == AccountStatus.AVAILABLE + ) + + # 根据密钥类型选择账号 + if key.membership_type == KeyMembershipType.AUTO: + # Auto密钥优先选择 free_trial 类型账号 + query = query.filter( + CursorAccount.account_type.in_([AccountType.FREE_TRIAL, AccountType.FREE]) + ) + else: + # Pro密钥优先选择 pro 类型账号 + query = query.filter( + CursorAccount.account_type.in_([AccountType.PRO, AccountType.FREE_TRIAL, AccountType.BUSINESS]) + ) + + # 按用量百分比升序排列(选择用量最低的) + return query.order_by(CursorAccount.usage_percent.asc()).first() + + @staticmethod + def get_pending_accounts(db: Session, limit: int = 10) -> List[CursorAccount]: + """获取待分析的账号""" + return db.query(CursorAccount).filter( + CursorAccount.status.in_([AccountStatus.PENDING, AccountStatus.AVAILABLE]), + or_( + CursorAccount.last_analyzed_at == None, + CursorAccount.last_analyzed_at < datetime.now() - timedelta(minutes=30) + ) + ).limit(limit).all() + + @staticmethod + def update_from_analysis(db: Session, account_id: int, analysis_data: Dict[str, Any]) -> Optional[CursorAccount]: + """ + 根据 Cursor API 分析结果更新账号信息 + """ + account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first() + if not account: + return None + + # 更新分析结果 + if analysis_data.get("success"): + account.status = AccountStatus[analysis_data["status"].upper()] + account.account_type = AccountType[analysis_data["account_type"].upper()] + account.membership_type = analysis_data.get("membership_type") + account.billing_cycle_start = analysis_data.get("billing_cycle_start") + account.billing_cycle_end = analysis_data.get("billing_cycle_end") + account.trial_days_remaining = analysis_data.get("trial_days_remaining", 0) + account.usage_limit = analysis_data.get("usage_limit", 0) + account.usage_used = analysis_data.get("usage_used", 0) + account.usage_remaining = analysis_data.get("usage_remaining", 0) + account.usage_percent = analysis_data.get("usage_percent", Decimal("0")) + account.total_requests = analysis_data.get("total_requests", 0) + account.total_input_tokens = analysis_data.get("total_input_tokens", 0) + account.total_output_tokens = analysis_data.get("total_output_tokens", 0) + account.total_cost_cents = analysis_data.get("total_cost_cents", Decimal("0")) + account.last_analyzed_at = analysis_data.get("last_analyzed_at", datetime.now()) + account.analyze_error = None + else: + # 分析失败 + account.status = AccountStatus.INVALID + account.analyze_error = analysis_data.get("error", "Unknown error") + account.last_analyzed_at = datetime.now() + + db.commit() + db.refresh(account) + return account + + @staticmethod + def lock_account(db: Session, account: CursorAccount, key_id: int) -> CursorAccount: + """锁定账号给指定密钥使用""" + account.status = AccountStatus.IN_USE + account.locked_by_key_id = key_id + account.locked_at = datetime.now() + db.commit() + db.refresh(account) + return account + + @staticmethod + def release_account(db: Session, account: CursorAccount) -> CursorAccount: + """释放账号""" + # 根据用量决定状态(使用配置阈值,默认98%) + threshold = GlobalSettingsService.get_int(db, "auto_switch_threshold") or 98 + if account.usage_percent and float(account.usage_percent) >= threshold: + account.status = AccountStatus.EXHAUSTED + else: + account.status = AccountStatus.AVAILABLE + account.locked_by_key_id = None + account.locked_at = None + db.commit() + db.refresh(account) + return account @staticmethod def update(db: Session, account_id: int, **kwargs) -> Optional[CursorAccount]: @@ -74,69 +193,137 @@ class AccountService: return True return False - @staticmethod - def mark_used(db: Session, account: CursorAccount, key_id: int = None): - """标记账号被使用""" - account.usage_count += 1 - account.last_used_at = datetime.now() - account.status = AccountStatus.IN_USE - if key_id: - account.current_key_id = key_id - db.commit() - - @staticmethod - def release(db: Session, account: CursorAccount): - """释放账号""" - account.status = AccountStatus.ACTIVE - account.current_key_id = None - db.commit() - @staticmethod def count(db: Session) -> dict: """统计账号数量""" - total = db.query(CursorAccount).count() - active = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE).count() - pro = db.query(CursorAccount).filter(CursorAccount.membership_type == MembershipType.PRO).count() - return {"total": total, "active": active, "pro": pro} + rows = db.query( + CursorAccount.status, func.count(CursorAccount.id) + ).group_by(CursorAccount.status).all() + counts = {status.value: cnt for status, cnt in rows} + total = sum(counts.values()) + return { + "total": total, + "available": counts.get(AccountStatus.AVAILABLE.value, 0), + "in_use": counts.get(AccountStatus.IN_USE.value, 0), + "exhausted": counts.get(AccountStatus.EXHAUSTED.value, 0), + "pending": counts.get(AccountStatus.PENDING.value, 0), + "invalid": counts.get(AccountStatus.INVALID.value, 0), + } +# ==================== 激活码服务 ==================== + class KeyService: """激活码管理服务""" @staticmethod - def create(db: Session, key_data: KeyCreate) -> List[ActivationKey]: + def _get_bool_setting(db: Session, key: str, default: bool = False) -> bool: + value = GlobalSettingsService.get(db, key) + if value is None: + return default + return str(value).strip().lower() in ("true", "1", "yes", "y", "on") + + @staticmethod + def _get_key_max_devices(db: Session) -> int: + limit = GlobalSettingsService.get_int(db, "key_max_devices") + return limit if limit > 0 else 2 + + @staticmethod + def _is_device_bound(db: Session, key_id: int, device_id: str) -> bool: + if not device_id: + return False + return db.query(KeyDevice).filter( + KeyDevice.key_id == key_id, + KeyDevice.device_id == device_id + ).first() is not None + + @staticmethod + def _ensure_device_bound(db: Session, key: ActivationKey, device_id: str) -> Tuple[bool, str]: + if not device_id: + return True, "" + + existing = db.query(KeyDevice).filter( + KeyDevice.key_id == key.id, + KeyDevice.device_id == device_id + ).first() + now = datetime.now() + if existing: + existing.last_active_at = now + return True, "" + + limit = KeyService._get_key_max_devices(db) + count = db.query(KeyDevice).filter(KeyDevice.key_id == key.id).count() + if count >= limit: + return False, f"设备数量已达上限({limit}),请先解绑旧设备" + + db.add(KeyDevice( + key_id=key.id, + device_id=device_id, + last_active_at=now + )) + return True, "" + + @staticmethod + def _find_master_for_device(db: Session, membership_type: KeyMembershipType, device_id: str) -> Optional[ActivationKey]: + if not device_id: + return None + + # 新逻辑:以 key_devices 为准 + master = db.query(ActivationKey).join( + KeyDevice, KeyDevice.key_id == ActivationKey.id + ).filter( + KeyDevice.device_id == device_id, + ActivationKey.membership_type == membership_type, + ActivationKey.status != KeyStatus.DISABLED, + ActivationKey.master_key_id == None + ).order_by(ActivationKey.id.desc()).first() + if master: + return master + + # 兼容旧数据:activation_keys.device_id 绑定 + return db.query(ActivationKey).filter( + ActivationKey.device_id == device_id, + ActivationKey.membership_type == membership_type, + ActivationKey.status != KeyStatus.DISABLED, + ActivationKey.master_key_id == None + ).order_by(ActivationKey.id.desc()).first() + + @staticmethod + def create( + db: Session, + count: int = 1, + membership_type: KeyMembershipType = KeyMembershipType.PRO, + duration_days: int = 30, + quota: int = 500, + max_devices: int = 2, + remark: str = None + ) -> List[ActivationKey]: """创建激活码(支持批量)""" keys = [] - max_retries = 5 # 最大重试次数 + max_retries = 5 - for _ in range(key_data.count): - # 生成唯一的key,如果冲突则重试 + for _ in range(count): for retry in range(max_retries): - key_str = key_data.key if key_data.key and key_data.count == 1 else generate_key() - - # 检查key是否已存在 + key_str = generate_key() existing = db.query(ActivationKey).filter(ActivationKey.key == key_str).first() if not existing: break if retry == max_retries - 1: - raise ValueError(f"无法生成唯一激活码,请重试") + raise ValueError("无法生成唯一激活码,请重试") - # 根据类型设置默认值 - is_pro = key_data.membership_type == MembershipType.PRO db_key = ActivationKey( key=key_str, - status=KeyStatus.UNUSED, # 新密钥默认未使用 - membership_type=key_data.membership_type, - # 该密钥贡献的资源 - duration_days=key_data.valid_days if not is_pro else 0, # Auto贡献天数 - quota_contribution=key_data.quota if is_pro else 0, # Pro贡献积分 - # 主密钥初始值(激活时使用) - quota=key_data.quota if is_pro else 0, - max_devices=key_data.max_devices, - remark=key_data.remark + status=KeyStatus.UNUSED, + membership_type=membership_type, + duration_days=duration_days if membership_type == KeyMembershipType.AUTO else 0, + quota_contribution=quota if membership_type == KeyMembershipType.PRO else 0, + quota=quota if membership_type == KeyMembershipType.PRO else 0, + max_devices=max_devices, + remark=remark ) db.add(db_key) keys.append(db_key) + db.commit() for k in keys: db.refresh(k) @@ -154,6 +341,250 @@ class KeyService: def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[ActivationKey]: return db.query(ActivationKey).order_by(ActivationKey.id.desc()).offset(skip).limit(limit).all() + @staticmethod + def activate(db: Session, key: ActivationKey, device_id: str = None) -> Tuple[bool, str, Optional[ActivationKey]]: + """ + 激活密钥 (不分配账号!) + 返回: (成功, 消息, 密钥) + """ + now = datetime.now() + + # 已合并的子密钥:默认禁止继续使用 + if key.master_key_id: + master_key = KeyService.get_by_id(db, key.master_key_id) + if not master_key: + return False, "该密钥已合并,但主密钥不存在,请联系管理员", None + + # 仅当当前设备已绑定到主密钥时,允许自动“跳转到主密钥”(用于同设备迁移/兼容) + if device_id and KeyService._is_device_bound(db, master_key.id, device_id): + ok, bind_msg = KeyService._ensure_device_bound(db, master_key, device_id) + if not ok: + return False, bind_msg, None + master_key.device_id = device_id + master_key.last_active_at = now + db.commit() + return True, "该密钥已合并,已为你切换到主密钥", master_key + + return False, "该密钥已合并,请使用主密钥", None + + # 检查密钥状态 + if key.status == KeyStatus.DISABLED: + return False, "该密钥已被禁用", None + if key.status == KeyStatus.EXPIRED: + return False, "该密钥已过期", None + + # 已激活的密钥 + if key.status == KeyStatus.ACTIVE: + # 检查是否过期 + if key.is_expired: + key.status = KeyStatus.EXPIRED + db.commit() + return False, "该密钥已过期", None + + # 更新设备ID和活跃时间 + if device_id: + ok, bind_msg = KeyService._ensure_device_bound(db, key, device_id) + if not ok: + return False, bind_msg, None + key.device_id = device_id + key.last_active_at = now + db.commit() + return True, "密钥已激活", key + + # 查找该设备同类型的主密钥(用于合并) + master_key = None + auto_merge_enabled = KeyService._get_bool_setting(db, "auto_merge_enabled", default=True) + if auto_merge_enabled and device_id: + master_key = KeyService._find_master_for_device(db, key.membership_type, device_id) + + if master_key: + # 合并到现有主密钥 + key.master_key_id = master_key.id + key.merged_at = now + key.device_id = device_id + key.status = KeyStatus.ACTIVE # 合并密钥标记为已使用(通过 master_key_id 判定不可直接使用) + + # 叠加资源 + if key.membership_type == KeyMembershipType.PRO: + master_key.quota += key.quota_contribution + else: + base = master_key.expire_at if master_key.expire_at and master_key.expire_at > now else now + master_key.expire_at = base + timedelta(days=key.duration_days) + + # 合并后主密钥可能从“过期”恢复为可用 + if master_key.status in (KeyStatus.EXPIRED, KeyStatus.UNUSED): + master_key.status = KeyStatus.ACTIVE + + master_key.merged_count += 1 + if device_id: + ok, bind_msg = KeyService._ensure_device_bound(db, master_key, device_id) + if not ok: + return False, bind_msg, None + master_key.device_id = device_id + master_key.last_active_at = now + db.commit() + return True, f"密钥已合并,{'积分' if key.membership_type == KeyMembershipType.PRO else '时长'}已叠加", master_key + else: + # 成为主密钥 + key.status = KeyStatus.ACTIVE + key.device_id = device_id + key.first_activated_at = now + key.last_active_at = now + + # 设置初始到期时间(Auto) + if key.membership_type == KeyMembershipType.AUTO and key.duration_days > 0: + key.expire_at = now + timedelta(days=key.duration_days) + + if device_id: + ok, bind_msg = KeyService._ensure_device_bound(db, key, device_id) + if not ok: + return False, bind_msg, None + + db.commit() + return True, "激活成功", key + + @staticmethod + def enable_seamless(db: Session, key: ActivationKey, device_id: str) -> Tuple[bool, str, Optional[CursorAccount]]: + """ + 启用无感换号并分配账号 + 返回: (成功, 消息, 账号) + """ + # 检查密钥状态 + if key.status != KeyStatus.ACTIVE: + return False, "密钥未激活", None + + if key.is_expired: + key.status = KeyStatus.EXPIRED + db.commit() + return False, "密钥已过期", None + + # 如果已启用且有账号,直接返回 + if key.seamless_enabled and key.current_account_id: + account = AccountService.get_by_id(db, key.current_account_id) + if account and account.status == AccountStatus.IN_USE: + return True, "无感换号已启用", account + + # 分配账号 + account = AccountService.get_available_for_key(db, key) + if not account: + return False, "无可用账号", None + + # 锁定账号 + AccountService.lock_account(db, account, key.id) + + # 更新密钥 + key.seamless_enabled = True + key.current_account_id = account.id + key.device_id = device_id + key.last_active_at = datetime.now() + db.commit() + + return True, "无感换号已启用", account + + @staticmethod + def disable_seamless(db: Session, key: ActivationKey) -> Tuple[bool, str]: + """禁用无感换号""" + if key.current_account_id: + account = AccountService.get_by_id(db, key.current_account_id) + if account: + AccountService.release_account(db, account) + + key.seamless_enabled = False + key.current_account_id = None + db.commit() + + return True, "无感换号已禁用" + + @staticmethod + def switch_account(db: Session, key: ActivationKey) -> Tuple[bool, str, Optional[CursorAccount]]: + """ + 换号 + 返回: (成功, 消息, 新账号) + """ + # 检查状态 + if key.status != KeyStatus.ACTIVE: + return False, "密钥未激活", None + + if not key.seamless_enabled: + return False, "未启用无感换号", None + + if key.is_expired: + key.status = KeyStatus.EXPIRED + db.commit() + return False, "密钥已过期", None + + # Pro密钥检查积分 + if key.membership_type == KeyMembershipType.PRO: + quota_cost = GlobalSettingsService.get_int(db, "pro_quota_per_switch") + if key.quota_remaining < quota_cost: + return False, f"积分不足,需要{quota_cost},剩余{key.quota_remaining}", None + + # Auto密钥检查换号冷却时间 + 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: + elapsed = (datetime.now() - key.last_switch_at).total_seconds() + remaining_seconds = interval_minutes * 60 - elapsed + if remaining_seconds > 0: + remaining_minutes = int(remaining_seconds / 60) + 1 + return False, f"换号冷却中,请{remaining_minutes}分钟后再试", None + + # 先查找新账号,再释放旧账号(避免竞态条件) + old_account = None + if key.current_account_id: + old_account = AccountService.get_by_id(db, key.current_account_id) + + # 分配新账号(排除当前账号) + new_account = AccountService.get_available_for_key(db, key) + if not new_account: + return False, "无可用账号", None + + # 确认有新账号后,再释放旧账号 + if old_account: + AccountService.release_account(db, old_account) + + # 锁定新账号 + AccountService.lock_account(db, new_account, key.id) + + # 更新密钥 + key.current_account_id = new_account.id + + # Pro扣除积分 + if key.membership_type == KeyMembershipType.PRO: + quota_cost = GlobalSettingsService.get_int(db, "pro_quota_per_switch") + key.quota_used += quota_cost + + key.switch_count += 1 + key.last_switch_at = datetime.now() + db.commit() + + # 记录日志 + LogService.log( + db, key.id, "switch", + account_id=new_account.id, + success=True, + message=f"从 {old_account.email if old_account else 'N/A'} 切换到 {new_account.email}", + usage_snapshot={ + "old_account": old_account.to_dict() if old_account else None, + "new_account": new_account.to_dict() + } + ) + + return True, "换号成功", new_account + + @staticmethod + def get_status(db: Session, key: ActivationKey) -> Dict[str, Any]: + """获取密钥完整状态""" + data = key.to_dict(include_account=True) + + # 添加账号信息 + if key.seamless_enabled and key.current_account_id: + account = AccountService.get_by_id(db, key.current_account_id) + if account: + data["account_info"] = account.to_dict() + + return data + @staticmethod def update(db: Session, key_id: int, **kwargs) -> Optional[ActivationKey]: key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first() @@ -169,339 +600,80 @@ class KeyService: def delete(db: Session, key_id: int) -> bool: key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first() if key: - # 删除关联的设备记录 + # 释放关联账号 + if key.current_account_id: + account = AccountService.get_by_id(db, key.current_account_id) + if account: + AccountService.release_account(db, account) + # 删除关联设备 db.query(KeyDevice).filter(KeyDevice.key_id == key_id).delete() db.delete(key) db.commit() return True return False - @staticmethod - def activate(db: Session, key: ActivationKey, device_id: str = None) -> Tuple[bool, str, Optional[ActivationKey]]: - """ - 激活密钥 - - 如果设备已有同类型主密钥,则合并(叠加时长/积分) - - 否则,该密钥成为主密钥 - 返回: (成功, 消息, 主密钥) - """ - now = datetime.now() - - # 检查密钥状态 - if key.status == KeyStatus.MERGED: - return False, "该密钥已被合并使用", None - if key.status == KeyStatus.REVOKED: - return False, "该密钥已被撤销", None - if key.status == KeyStatus.DISABLED: - return False, "该密钥已被禁用", None - if key.status == KeyStatus.ACTIVE: - # 已激活的密钥,检查是否是同设备 - if device_id and key.device_id and key.device_id != device_id: - # 换设备激活,更新设备ID - key.device_id = device_id - db.commit() - return True, "密钥已激活", key - - # 查找该设备同类型的主密钥 - master_key = None - if device_id: - master_key = db.query(ActivationKey).filter( - ActivationKey.device_id == device_id, - ActivationKey.membership_type == key.membership_type, - ActivationKey.status == KeyStatus.ACTIVE, - ActivationKey.master_key_id == None # 是主密钥 - ).first() - - if master_key: - # 合并到现有主密钥 - key.status = KeyStatus.MERGED - key.master_key_id = master_key.id - key.merged_at = now - key.device_id = device_id - - # 叠加资源到主密钥 - if key.membership_type == MembershipType.PRO: - # Pro: 叠加积分 - master_key.quota += key.quota_contribution - else: - # Auto: 叠加时长 - if master_key.expire_at: - master_key.expire_at += timedelta(days=key.duration_days) - else: - master_key.expire_at = now + timedelta(days=key.duration_days) - - master_key.merged_count += 1 - db.commit() - return True, f"密钥已合并,{'积分' if key.membership_type == MembershipType.PRO else '时长'}已叠加", master_key - else: - # 该密钥成为主密钥 - key.status = KeyStatus.ACTIVE - key.device_id = device_id - key.first_activated_at = now - - # 设置初始到期时间(Auto) - if key.membership_type == MembershipType.FREE and key.duration_days > 0: - key.expire_at = now + timedelta(days=key.duration_days) - - db.commit() - return True, "激活成功", key - - @staticmethod - def get_master_key(db: Session, device_id: str, membership_type: MembershipType) -> Optional[ActivationKey]: - """获取设备的主密钥""" - return db.query(ActivationKey).filter( - ActivationKey.device_id == device_id, - ActivationKey.membership_type == membership_type, - ActivationKey.status == KeyStatus.ACTIVE, - ActivationKey.master_key_id == None - ).first() - - @staticmethod - def get_device_keys(db: Session, device_id: str) -> dict: - """获取设备的所有密钥信息""" - result = {"auto": None, "pro": None} - - # 获取Auto主密钥 - auto_master = KeyService.get_master_key(db, device_id, MembershipType.FREE) - if auto_master: - # 获取合并的密钥 - merged_keys = db.query(ActivationKey).filter( - ActivationKey.master_key_id == auto_master.id - ).all() - result["auto"] = { - "master": auto_master, - "merged_keys": merged_keys, - "total_keys": 1 + len(merged_keys), - "expire_at": auto_master.expire_at - } - - # 获取Pro主密钥 - pro_master = KeyService.get_master_key(db, device_id, MembershipType.PRO) - if pro_master: - merged_keys = db.query(ActivationKey).filter( - ActivationKey.master_key_id == pro_master.id - ).all() - result["pro"] = { - "master": pro_master, - "merged_keys": merged_keys, - "total_keys": 1 + len(merged_keys), - "quota": pro_master.quota, - "quota_used": pro_master.quota_used, - "quota_remaining": pro_master.quota - pro_master.quota_used - } - - return result - - @staticmethod - def is_valid(key: ActivationKey, db: Session) -> Tuple[bool, str]: - """检查激活码是否有效(仅检查主密钥)""" - # 状态检查 - if key.status == KeyStatus.UNUSED: - return False, "激活码未激活" - if key.status == KeyStatus.MERGED: - return False, "该密钥已合并,请使用主密钥" - if key.status == KeyStatus.REVOKED: - return False, "激活码已被撤销" - if key.status == KeyStatus.DISABLED: - return False, "激活码已禁用" - if key.status == KeyStatus.EXPIRED: - return False, "激活码已过期" - if key.status != KeyStatus.ACTIVE: - return False, "激活码状态异常" - - # 检查是否已过期(只有激活后才检查) - if key.expire_at and key.expire_at < datetime.now(): - return False, "激活码已过期" - - # Pro套餐检查额度 - if key.membership_type == MembershipType.PRO: - quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost") - if key.quota_used + quota_cost > key.quota: - return False, f"额度不足,需要{quota_cost},剩余{key.quota - key.quota_used}" - - return True, "有效" - - @staticmethod - def can_switch(db: Session, key: ActivationKey) -> Tuple[bool, str]: - """检查是否可以换号 - - Auto: 检查换号间隔 + 每天最大次数(全局设置) - - Pro: 无频率限制(只检查额度,在is_valid中) - """ - # Pro密钥无频率限制 - if key.membership_type == MembershipType.PRO: - return True, "可以换号" - - # === Auto密钥频率检查 === - now = datetime.now() - - # 1. 检查换号间隔 - interval_minutes = GlobalSettingsService.get_int(db, "auto_switch_interval_minutes") - if key.last_switch_at: - minutes_since_last = (now - key.last_switch_at).total_seconds() / 60 - if minutes_since_last < interval_minutes: - wait_minutes = int(interval_minutes - minutes_since_last) - return False, f"换号太频繁,请等待{wait_minutes}分钟" - - # 2. 检查今日换号次数 - max_per_day = GlobalSettingsService.get_int(db, "auto_max_switches_per_day") - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - today_count = db.query(UsageLog).filter( - UsageLog.key_id == key.id, - UsageLog.action == "switch", - UsageLog.success == True, - UsageLog.created_at >= today_start - ).count() - - if today_count >= max_per_day: - return False, f"今日换号次数已达上限({max_per_day}次)" - - return True, "可以换号" - - @staticmethod - def check_device(db: Session, key: ActivationKey, device_id: str) -> Tuple[bool, str]: - """检查设备限制""" - if not device_id: - return True, "无设备ID" - - # 查找现有设备 - device = db.query(KeyDevice).filter( - KeyDevice.key_id == key.id, - KeyDevice.device_id == device_id - ).first() - - if device: - # 更新最后活跃时间 - device.last_active_at = datetime.now() - db.commit() - return True, "设备已绑定" - - # 检查设备数量 - device_count = db.query(KeyDevice).filter(KeyDevice.key_id == key.id).count() - if device_count >= key.max_devices: - return False, f"设备数量已达上限({key.max_devices}个)" - - # 添加新设备 - new_device = KeyDevice(key_id=key.id, device_id=device_id, last_active_at=datetime.now()) - db.add(new_device) - db.commit() - return True, "新设备已绑定" - - @staticmethod - def use_switch(db: Session, key: ActivationKey): - """使用一次换号(Pro扣除额度,Free不扣)""" - if key.membership_type == MembershipType.PRO: - quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost") - key.quota_used += quota_cost - # Free不扣额度 - key.switch_count += 1 - key.last_switch_at = datetime.now() - db.commit() - - @staticmethod - def get_quota_cost(db: Session, membership_type: MembershipType) -> int: - """获取换号消耗的额度""" - if membership_type == MembershipType.PRO: - return GlobalSettingsService.get_int(db, "pro_quota_cost") - return 0 # Free不消耗额度 - - @staticmethod - def add_quota(db: Session, key: ActivationKey, add_quota: int): - """叠加额度(只加额度不加时间,仅Pro有效)""" - key.quota += add_quota - db.commit() - - @staticmethod - def bind_account(db: Session, key: ActivationKey, account: CursorAccount): - """绑定账号""" - key.current_account_id = account.id - db.commit() - - @staticmethod - def revoke_key(db: Session, key_id: int) -> Tuple[bool, str]: - """ - 撤销密钥 - - 如果是主密钥:不允许直接撤销(需要先撤销所有合并的密钥) - - 如果是合并的密钥:从主密钥扣除贡献的资源 - """ - key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first() - if not key: - return False, "密钥不存在" - - if key.status == KeyStatus.REVOKED: - return False, "密钥已被撤销" - - if key.status == KeyStatus.ACTIVE and key.master_key_id is None: - # 是主密钥,检查是否有合并的密钥 - merged_count = db.query(ActivationKey).filter( - ActivationKey.master_key_id == key.id, - ActivationKey.status == KeyStatus.MERGED - ).count() - if merged_count > 0: - return False, f"该密钥有{merged_count}个合并密钥,请先撤销合并的密钥" - - # 主密钥没有合并密钥,可以直接撤销 - key.status = KeyStatus.REVOKED - db.commit() - return True, "主密钥已撤销" - - elif key.status == KeyStatus.MERGED: - # 是合并的密钥,从主密钥扣除资源 - master = db.query(ActivationKey).filter(ActivationKey.id == key.master_key_id).first() - if not master: - return False, "找不到主密钥" - - if key.membership_type == MembershipType.PRO: - # Pro: 检查扣除后是否会导致已用超额 - new_quota = master.quota - key.quota_contribution - if master.quota_used > new_quota: - return False, f"无法撤销:撤销后剩余额度({new_quota})小于已用额度({master.quota_used})" - master.quota = new_quota - else: - # Auto: 扣除时长 - if master.expire_at: - master.expire_at -= timedelta(days=key.duration_days) - # 检查扣除后是否已过期 - if master.expire_at < datetime.now(): - return False, "无法撤销:撤销后密钥将立即过期" - - master.merged_count -= 1 - key.status = KeyStatus.REVOKED - key.master_key_id = None # 解除关联 - db.commit() - return True, "合并密钥已撤销,资源已扣除" - - else: - # 其他状态(UNUSED, DISABLED 等) - key.status = KeyStatus.REVOKED - db.commit() - return True, "密钥已撤销" - @staticmethod def count(db: Session) -> dict: """统计激活码数量""" - total = db.query(ActivationKey).count() - active = db.query(ActivationKey).filter(ActivationKey.status == KeyStatus.ACTIVE).count() - return {"total": total, "active": active} + status_rows = db.query( + ActivationKey.status, func.count(ActivationKey.id) + ).group_by(ActivationKey.status).all() + sc = {s.value: c for s, c in status_rows} + type_rows = db.query( + ActivationKey.membership_type, func.count(ActivationKey.id) + ).group_by(ActivationKey.membership_type).all() + tc = {t.value: c for t, c in type_rows} + + total = sum(sc.values()) + return { + "total": total, + "unused": sc.get(KeyStatus.UNUSED.value, 0), + "active": sc.get(KeyStatus.ACTIVE.value, 0), + "expired": sc.get(KeyStatus.EXPIRED.value, 0), + "auto": tc.get(KeyMembershipType.AUTO.value, 0), + "pro": tc.get(KeyMembershipType.PRO.value, 0), + } + + +# ==================== 日志服务 ==================== class LogService: """日志服务""" @staticmethod - def log(db: Session, key_id: int, action: str, account_id: int = None, - ip_address: str = None, user_agent: str = None, - success: bool = True, message: str = None): + def log( + db: Session, + key_id: int, + action: str, + account_id: int = None, + ip_address: str = None, + user_agent: str = None, + device_id: str = None, + success: bool = True, + message: str = None, + usage_snapshot: Dict = None + ): log = UsageLog( key_id=key_id, account_id=account_id, action=action, ip_address=ip_address, user_agent=user_agent, + device_id=device_id, success=success, - message=message + message=message, + usage_snapshot=usage_snapshot ) db.add(log) db.commit() + @staticmethod + def get_by_key(db: Session, key_id: int, limit: int = 50) -> List[UsageLog]: + return db.query(UsageLog).filter( + UsageLog.key_id == key_id + ).order_by(UsageLog.created_at.desc()).limit(limit).all() + @staticmethod def get_today_count(db: Session) -> int: today = datetime.now().date() @@ -510,48 +682,37 @@ class LogService: ).count() +# ==================== 全局设置服务 ==================== + class GlobalSettingsService: """全局设置服务""" - # 默认设置 - DEFAULT_SETTINGS = { - # Auto密钥设置 - "auto_switch_interval_minutes": ("20", "Auto换号最小间隔(分钟)"), - "auto_max_switches_per_day": ("50", "Auto每天最大换号次数"), - # Pro密钥设置 - "pro_quota_cost": ("50", "Pro每次换号扣除额度"), - } - @staticmethod def init_settings(db: Session): """初始化默认设置""" - for key, (value, desc) in GlobalSettingsService.DEFAULT_SETTINGS.items(): - existing = db.query(GlobalSettings).filter(GlobalSettings.key == key).first() + defaults = GlobalSettings.get_default_settings() + for setting in defaults: + existing = db.query(GlobalSettings).filter(GlobalSettings.key == setting["key"]).first() if not existing: - setting = GlobalSettings(key=key, value=value, description=desc) - db.add(setting) + db.add(GlobalSettings(**setting)) db.commit() @staticmethod def get(db: Session, key: str) -> Optional[str]: - """获取单个设置""" setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first() if setting: return setting.value # 返回默认值 - if key in GlobalSettingsService.DEFAULT_SETTINGS: - return GlobalSettingsService.DEFAULT_SETTINGS[key][0] - return None + defaults = {s["key"]: s["value"] for s in GlobalSettings.get_default_settings()} + return defaults.get(key) @staticmethod def get_int(db: Session, key: str) -> int: - """获取整数设置""" value = GlobalSettingsService.get(db, key) return int(value) if value else 0 @staticmethod def set(db: Session, key: str, value: str, description: str = None): - """设置单个配置""" setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first() if setting: setting.value = value @@ -564,12 +725,18 @@ class GlobalSettingsService: @staticmethod def get_all(db: Session) -> dict: - """获取所有设置""" - return { - "auto_switch_interval_minutes": GlobalSettingsService.get_int(db, "auto_switch_interval_minutes"), - "auto_max_switches_per_day": GlobalSettingsService.get_int(db, "auto_max_switches_per_day"), - "pro_quota_cost": GlobalSettingsService.get_int(db, "pro_quota_cost"), - } + settings = db.query(GlobalSettings).all() + result = {} + for s in settings: + if s.value_type == "int": + result[s.key] = int(s.value) + elif s.value_type == "float": + result[s.key] = float(s.value) + elif s.value_type == "bool": + result[s.key] = s.value.lower() in ("true", "1", "yes") + else: + result[s.key] = s.value + return result @staticmethod def update_all(db: Session, **kwargs): @@ -579,14 +746,22 @@ class GlobalSettingsService: GlobalSettingsService.set(db, key, str(value)) +# ==================== 批量操作服务 ==================== + class BatchService: - """批量操作服务""" + """批量操作服务 - 用于管理后台批量处理密钥""" @staticmethod - def extend_keys(db: Session, key_ids: List[int], extend_days: int = 0, add_quota: int = 0) -> dict: - """批量延长密钥 - - Auto密钥:只能延长到期时间 - - Pro密钥:可以延长到期时间 + 增加额度 + def extend_keys( + db: Session, + key_ids: List[int], + extend_days: int = 0, + add_quota: int = 0 + ) -> dict: + """ + 批量延长密钥 + - extend_days: 延长天数 (Auto和Pro都可用) + - add_quota: 增加额度 (仅Pro有效) """ success = 0 failed = 0 @@ -600,65 +775,69 @@ class BatchService: errors.append(f"ID {key_id}: 密钥不存在") continue - # 延长到期时间 + # 延长时间 if extend_days > 0: if key.expire_at: - # 已激活:在当前到期时间基础上延长 - key.expire_at = key.expire_at + timedelta(days=extend_days) + # 如果已过期,从今天开始算 + if key.expire_at < datetime.now(): + key.expire_at = datetime.now() + timedelta(days=extend_days) + else: + key.expire_at = key.expire_at + timedelta(days=extend_days) else: - # 未激活:增加有效天数 - key.valid_days += extend_days + # 首次设置过期时间 + key.expire_at = datetime.now() + timedelta(days=extend_days) - # 增加额度(仅Pro有效) - if add_quota > 0 and key.membership_type == MembershipType.PRO: - key.quota += add_quota + # 增加额度 (仅Pro) + if add_quota > 0 and key.membership_type == KeyMembershipType.PRO: + key.quota = (key.quota or 0) + add_quota - db.commit() success += 1 except Exception as e: failed += 1 errors.append(f"ID {key_id}: {str(e)}") - return {"success": success, "failed": failed, "errors": errors[:10]} + db.commit() + + return { + "success": success, + "failed": failed, + "errors": errors[:20] + } @staticmethod def get_keys_for_compensation( db: Session, - membership_type: MembershipType = None, + membership_type: KeyMembershipType = None, activated_before: datetime = None, - not_expired_on: datetime = None, + not_expired_on: datetime = None ) -> List[ActivationKey]: - """获取符合补偿条件的密钥列表 - - membership_type: 筛选套餐类型 (pro/free) - - activated_before: 在此日期之前激活的 (first_activated_at < activated_before) - - not_expired_on: 在此日期时还未过期的 (expire_at > not_expired_on) - - 例如:补偿12月4号之前激活、且12月4号还没过期的用户 - activated_before = 2024-12-05 (12月4号之前,即<12月5号0点) - not_expired_on = 2024-12-04 (12月4号还没过期,即expire_at > 12月4号) """ - query = db.query(ActivationKey) + 获取符合补偿条件的密钥列表 + 参数: + - membership_type: 筛选密钥类型 (AUTO/PRO) + - activated_before: 在此日期之前激活 + - not_expired_on: 在此日期时还未过期 + """ + query = db.query(ActivationKey).filter( + ActivationKey.first_activated_at != None # 必须已激活 + ) + + # 类型筛选 if membership_type: query = query.filter(ActivationKey.membership_type == membership_type) - # 只选择状态为active的 - query = query.filter(ActivationKey.status == KeyStatus.ACTIVE) - - # 只选择已激活的(有激活时间的) - query = query.filter(ActivationKey.first_activated_at != None) - + # 激活时间筛选 if activated_before: - # 在指定日期之前激活的 query = query.filter(ActivationKey.first_activated_at < activated_before) + # 在指定日期时未过期 (expire_at > not_expired_on 或 expire_at 为空) if not_expired_on: - # 在指定日期时还未过期的 (expire_at > 指定日期 或 永久卡) query = query.filter( or_( - ActivationKey.expire_at == None, # 永久卡 - ActivationKey.expire_at > not_expired_on # 在那天还没过期 + ActivationKey.expire_at > not_expired_on, + ActivationKey.expire_at == None ) ) @@ -667,64 +846,79 @@ class BatchService: @staticmethod def batch_compensate( db: Session, - membership_type: MembershipType = None, + membership_type: KeyMembershipType = None, activated_before: datetime = None, not_expired_on: datetime = None, extend_days: int = 0, add_quota: int = 0 ) -> dict: - """批量补偿 - 根据条件筛选并补偿 - - 补偿逻辑: - - 如果卡当前还没过期:expire_at += extend_days - - 如果卡已过期(但符合补偿条件):expire_at = 今天 + extend_days(恢复使用) - - 例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥,延长1天 """ + 批量补偿密钥 + + 补偿逻辑: + - 如果密钥当前未过期: expire_at += extend_days + - 如果密钥已过期(但符合补偿条件): expire_at = 今天 + extend_days + + 参数: + - membership_type: 筛选密钥类型 + - activated_before: 在此日期之前激活 + - not_expired_on: 在此日期时还未过期 + - extend_days: 延长天数 + - add_quota: 增加额度 (仅Pro) + """ + # 获取符合条件的密钥 keys = BatchService.get_keys_for_compensation( - db, - membership_type=membership_type, - activated_before=activated_before, - not_expired_on=not_expired_on + db, membership_type, activated_before, not_expired_on ) if not keys: - return {"success": 0, "failed": 0, "total_matched": 0, "recovered": 0, "errors": ["没有符合条件的密钥"]} + return { + "success": 0, + "failed": 0, + "total_matched": 0, + "message": "没有找到符合条件的密钥" + } success = 0 failed = 0 - recovered = 0 # 恢复使用的数量 errors = [] now = datetime.now() for key in keys: try: - # 延长到期时间 - if extend_days > 0 and key.expire_at: - if key.expire_at > now: - # 还没过期:在当前过期时间上加天数 - key.expire_at = key.expire_at + timedelta(days=extend_days) + # 延长时间 + if extend_days > 0: + if key.expire_at: + if key.expire_at < now: + # 已过期,从今天开始 + key.expire_at = now + timedelta(days=extend_days) + # 恢复状态 + if key.status == KeyStatus.EXPIRED: + key.status = KeyStatus.ACTIVE + else: + # 未过期,在原基础上延长 + key.expire_at = key.expire_at + timedelta(days=extend_days) else: - # 已过期:恢复使用,设为今天+补偿天数 key.expire_at = now + timedelta(days=extend_days) - recovered += 1 - # 增加额度(仅Pro有效) - if add_quota > 0 and key.membership_type == MembershipType.PRO: - key.quota += add_quota + # 增加额度 (仅Pro) + if add_quota > 0 and key.membership_type == KeyMembershipType.PRO: + key.quota = (key.quota or 0) + add_quota - db.commit() success += 1 except Exception as e: failed += 1 - errors.append(f"ID {key.id}: {str(e)}") + errors.append(f"密钥 {key.key[:8]}...: {str(e)}") + + db.commit() return { "success": success, "failed": failed, "total_matched": len(keys), - "recovered": recovered, - "errors": errors[:10] + "extend_days": extend_days, + "add_quota": add_quota, + "errors": errors[:20], + "message": f"成功补偿 {success} 个密钥" + (f",{failed} 个失败" if failed > 0 else "") } - diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 0d485c2..1690834 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext @@ -21,9 +21,9 @@ def get_password_hash(password: str) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt diff --git a/backend/app/services/cursor_usage_service.py b/backend/app/services/cursor_usage_service.py index f1716fb..ccc4415 100644 --- a/backend/app/services/cursor_usage_service.py +++ b/backend/app/services/cursor_usage_service.py @@ -1,5 +1,5 @@ """ -Cursor 官方用量 API 服务 +Cursor 官方用量 API 服务 v2.1 用于验证账号有效性和查询用量信息 """ import httpx @@ -7,6 +7,7 @@ import asyncio from typing import Optional, Dict, Any, Tuple, List from dataclasses import dataclass from datetime import datetime +from decimal import Decimal @dataclass @@ -289,27 +290,30 @@ async def get_account_usage(token: str) -> Dict[str, Any]: } -async def batch_check_accounts(tokens: List[str]) -> List[Dict[str, Any]]: +async def batch_check_accounts(tokens: List[str], max_concurrency: int = 5) -> List[Dict[str, Any]]: """ - 批量检查多个账号 + 批量检查多个账号(并发,带限流) """ - results = [] - for token in tokens: - info = await cursor_usage_service.validate_and_get_usage(token) - results.append({ - "token": token[:20] + "...", # 脱敏 - "is_valid": info.is_valid, - "is_usable": info.is_usable, - "pool_type": info.pool_type, # pro/auto - "membership_type": info.membership_type if info.is_valid else None, - "days_remaining_on_trial": info.days_remaining_on_trial, - "plan_used": info.plan_used if info.is_valid else 0, - "plan_limit": info.plan_limit if info.is_valid else 0, - "plan_remaining": info.plan_remaining if info.is_valid else 0, - "total_requests": info.total_requests if info.is_valid else 0, - "error": info.error_message - }) - return results + semaphore = asyncio.Semaphore(max_concurrency) + + async def _check_one(token: str) -> Dict[str, Any]: + async with semaphore: + info = await cursor_usage_service.validate_and_get_usage(token) + return { + "token": token[:20] + "...", + "is_valid": info.is_valid, + "is_usable": info.is_usable, + "pool_type": info.pool_type, + "membership_type": info.membership_type if info.is_valid else None, + "days_remaining_on_trial": info.days_remaining_on_trial, + "plan_used": info.plan_used if info.is_valid else 0, + "plan_limit": info.plan_limit if info.is_valid else 0, + "plan_remaining": info.plan_remaining if info.is_valid else 0, + "total_requests": info.total_requests if info.is_valid else 0, + "error": info.error_message + } + + return await asyncio.gather(*[_check_one(t) for t in tokens]) async def check_and_classify_account(token: str) -> Dict[str, Any]: @@ -335,3 +339,95 @@ async def check_and_classify_account(token: str) -> Dict[str, Any]: "total_requests": info.total_requests, "recommendation": f"建议放入 {'Pro' if info.pool_type == 'pro' else 'Auto'} 号池" } + + +# ============ 数据库集成函数 ============ + +def map_membership_to_account_type(membership_type: str) -> str: + """ + 将 Cursor API 的 membershipType 映射到 AccountType + """ + mapping = { + "free_trial": "free_trial", + "pro": "pro", + "free": "free", + "business": "business", + "enterprise": "business", + } + return mapping.get(membership_type, "unknown") + + +def calculate_usage_percent(used: int, limit: int) -> Decimal: + """计算用量百分比""" + if limit <= 0: + return Decimal("0") + return Decimal(str(round(used / limit * 100, 2))) + + +async def analyze_account_from_token(token: str) -> Dict[str, Any]: + """ + 分析账号Token,返回所有需要更新到数据库的字段 + 用于后台分析任务 + """ + info = await cursor_usage_service.validate_and_get_usage(token) + + if not info.is_valid: + return { + "success": False, + "error": info.error_message, + "status": "invalid" + } + + # 计算用量百分比 + usage_percent = calculate_usage_percent(info.plan_used, info.plan_limit) + + # 确定账号状态 + if usage_percent >= Decimal("95"): + status = "exhausted" + else: + status = "available" + + # 解析计费周期时间 + billing_start = None + billing_end = None + try: + if info.billing_cycle_start: + billing_start = datetime.fromisoformat(info.billing_cycle_start.replace('Z', '+00:00')) + if info.billing_cycle_end: + billing_end = datetime.fromisoformat(info.billing_cycle_end.replace('Z', '+00:00')) + except: + pass + + return { + "success": True, + "status": status, + "account_type": map_membership_to_account_type(info.membership_type), + "membership_type": info.membership_type, + "billing_cycle_start": billing_start, + "billing_cycle_end": billing_end, + "trial_days_remaining": info.days_remaining_on_trial or 0, + "usage_limit": info.plan_limit, + "usage_used": info.plan_used, + "usage_remaining": info.plan_remaining, + "usage_percent": usage_percent, + "total_requests": info.total_requests, + "total_input_tokens": info.total_input_tokens, + "total_output_tokens": info.total_output_tokens, + "total_cost_cents": Decimal(str(info.total_cost_cents)), + "last_analyzed_at": datetime.now(), + "analyze_error": None + } + + +async def quick_validate_token(token: str) -> Tuple[bool, Optional[str]]: + """ + 快速验证Token有效性(仅调用usage-summary) + 用于激活时的快速检查 + """ + try: + resp = await cursor_usage_service.get_usage_summary(token) + if resp["success"]: + return True, None + return False, resp.get("error", "验证失败") + except Exception as e: + return False, str(e) diff --git a/backend/app/tasks.py b/backend/app/tasks.py new file mode 100644 index 0000000..fc3d60d --- /dev/null +++ b/backend/app/tasks.py @@ -0,0 +1,294 @@ +""" +蜂鸟Pro 后台定时任务 v2.1 +- 账号分析任务:定期从 Cursor API 获取账号用量数据 +- 自动换号任务:检查用量超阈值的账号并自动换号 +""" +import asyncio +import logging +from datetime import datetime, timedelta +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.database import SessionLocal +from app.services import ( + AccountService, KeyService, GlobalSettingsService, + analyze_account_from_token +) +from app.models.models import AccountStatus, KeyStatus, KeyMembershipType + +# 配置日志 +logger = logging.getLogger("tasks") +logger.setLevel(logging.INFO) + +# 调度器 +scheduler = AsyncIOScheduler() + + +async def analyze_accounts_task(): + """ + 账号分析任务 + 定期扫描 pending/available 状态的账号,从 Cursor API 获取最新用量数据 + + 执行频率:每 5 分钟 + """ + db = SessionLocal() + try: + # 检查开关 + enabled = GlobalSettingsService.get(db, "auto_analyze_enabled") + if not enabled or str(enabled).strip().lower() not in ("true", "1", "yes", "y", "on"): + logger.debug("[账号分析] 自动分析已关闭,跳过") + return + + logger.info("[账号分析] 开始执行...") + + # 获取需要分析的账号 + accounts = AccountService.get_pending_accounts(db, limit=10) + + if not accounts: + logger.info("[账号分析] 无需分析的账号") + return + + logger.info(f"[账号分析] 发现 {len(accounts)} 个待分析账号") + + success_count = 0 + fail_count = 0 + + for account in accounts: + try: + # 调用 Cursor API 分析账号 + analysis_data = await analyze_account_from_token(account.token) + + # 更新账号信息 + AccountService.update_from_analysis(db, account.id, analysis_data) + + if analysis_data.get("success"): + success_count += 1 + logger.info( + f"[账号分析] {account.email} 分析成功: " + f"类型={analysis_data.get('account_type')}, " + f"用量={analysis_data.get('usage_percent')}%" + ) + else: + fail_count += 1 + logger.warning( + f"[账号分析] {account.email} 分析失败: {analysis_data.get('error')}" + ) + + # 避免请求过于频繁 + await asyncio.sleep(1) + + except Exception as e: + fail_count += 1 + logger.error(f"[账号分析] {account.email} 异常: {str(e)}") + + logger.info(f"[账号分析] 完成: 成功 {success_count}, 失败 {fail_count}") + + except Exception as e: + logger.error(f"[账号分析] 任务异常: {str(e)}") + finally: + db.close() + + +async def auto_switch_task(): + """ + 自动换号任务 + 检查已启用无感换号的密钥,如果当前账号用量超阈值则自动换号 + + 执行频率:每 10 分钟 + """ + db = SessionLocal() + try: + # 检查开关 + enabled = GlobalSettingsService.get(db, "auto_switch_enabled") + if not enabled or str(enabled).strip().lower() not in ("true", "1", "yes", "y", "on"): + logger.debug("[自动换号] 自动换号已关闭,跳过") + return + + logger.info("[自动换号] 开始执行...") + + # 获取阈值设置 + auto_threshold = GlobalSettingsService.get_int(db, "auto_switch_threshold") or 98 + pro_threshold = GlobalSettingsService.get_int(db, "pro_switch_threshold") or 98 + + # 查找已启用无感的活跃密钥 + from app.models.models import ActivationKey, CursorAccount + + active_keys = db.query(ActivationKey).filter( + ActivationKey.status == KeyStatus.ACTIVE, + ActivationKey.seamless_enabled == True, + ActivationKey.current_account_id != None, + ActivationKey.master_key_id == None # 只处理主密钥 + ).all() + + if not active_keys: + logger.info("[自动换号] 无需处理的密钥") + return + + logger.info(f"[自动换号] 检查 {len(active_keys)} 个密钥") + + switch_count = 0 + + for key in active_keys: + try: + # 获取当前账号 + account = AccountService.get_by_id(db, key.current_account_id) + if not account: + continue + + # 确定阈值 + threshold = auto_threshold if key.membership_type == KeyMembershipType.AUTO else pro_threshold + + # 检查是否需要换号 + usage_percent = float(account.usage_percent) if account.usage_percent else 0 + if usage_percent < threshold: + continue + + logger.info( + f"[自动换号] 密钥 {key.key[:8]}**** 账号 {account.email} " + f"用量 {usage_percent}% >= {threshold}%, 触发换号" + ) + + # 执行换号 + success, message, new_account = KeyService.switch_account(db, key) + + if success: + switch_count += 1 + logger.info( + f"[自动换号] 换号成功: {account.email} -> {new_account.email}" + ) + + # 记录日志 + from app.services import LogService + LogService.log( + db, key.id, "auto_switch", + account_id=new_account.id, + success=True, + message=f"自动换号: {account.email} -> {new_account.email}", + usage_snapshot={ + "old_account": account.to_dict(), + "new_account": new_account.to_dict(), + "trigger_usage_percent": usage_percent, + "threshold": threshold + } + ) + else: + logger.warning(f"[自动换号] 换号失败: {message}") + + except Exception as e: + logger.error(f"[自动换号] 密钥 {key.key[:8]}**** 处理异常: {str(e)}") + + logger.info(f"[自动换号] 完成: 换号 {switch_count} 次") + + except Exception as e: + logger.error(f"[自动换号] 任务异常: {str(e)}") + finally: + db.close() + + +async def cleanup_expired_keys_task(): + """ + 清理过期密钥任务 + 将过期的密钥状态更新为 expired,释放关联的账号 + + 执行频率:每小时 + """ + db = SessionLocal() + try: + logger.info("[清理过期] 开始执行...") + + from app.models.models import ActivationKey + + # 查找需要检查的活跃密钥 + active_keys = db.query(ActivationKey).filter( + ActivationKey.status == KeyStatus.ACTIVE + ).all() + + expired_count = 0 + + for key in active_keys: + if key.is_expired: + # 释放账号 + if key.current_account_id: + account = AccountService.get_by_id(db, key.current_account_id) + if account: + AccountService.release_account(db, account) + + # 更新状态 + key.status = KeyStatus.EXPIRED + key.seamless_enabled = False + key.current_account_id = None + db.commit() + + expired_count += 1 + logger.info(f"[清理过期] 密钥 {key.key[:8]}**** 已过期") + + logger.info(f"[清理过期] 完成: 处理 {expired_count} 个过期密钥") + + except Exception as e: + logger.error(f"[清理过期] 任务异常: {str(e)}") + finally: + db.close() + + +async def init_global_settings_task(): + """ + 初始化全局设置任务 + 确保所有默认设置都存在 + + 执行频率:启动时执行一次 + """ + db = SessionLocal() + try: + logger.info("[初始化设置] 开始执行...") + GlobalSettingsService.init_settings(db) + logger.info("[初始化设置] 完成") + except Exception as e: + logger.error(f"[初始化设置] 任务异常: {str(e)}") + finally: + db.close() + + +def start_scheduler(): + """启动调度器""" + # 添加定时任务 + scheduler.add_job( + analyze_accounts_task, + trigger=IntervalTrigger(minutes=5), + id="analyze_accounts", + name="账号分析任务", + replace_existing=True + ) + + scheduler.add_job( + auto_switch_task, + trigger=IntervalTrigger(minutes=10), + id="auto_switch", + name="自动换号任务", + replace_existing=True + ) + + scheduler.add_job( + cleanup_expired_keys_task, + trigger=IntervalTrigger(hours=1), + id="cleanup_expired", + name="清理过期密钥任务", + replace_existing=True + ) + + # 启动调度器 + scheduler.start() + logger.info("[调度器] 后台任务调度器已启动") + + +def stop_scheduler(): + """停止调度器""" + if scheduler.running: + scheduler.shutdown() + logger.info("[调度器] 后台任务调度器已停止") + + +async def run_startup_tasks(): + """运行启动任务""" + await init_global_settings_task() + # 启动后立即执行一次账号分析 + await analyze_accounts_task() diff --git a/backend/migrations/upgrade_v2.sql b/backend/migrations/upgrade_v2.sql new file mode 100644 index 0000000..1ab60c0 --- /dev/null +++ b/backend/migrations/upgrade_v2.sql @@ -0,0 +1,202 @@ +-- 蜂鸟Pro v2.1 数据库迁移脚本 +-- 从旧版本迁移到新的数据模型 + +-- ==================== cursor_accounts 表迁移 ==================== + +-- 1. 重命名 access_token 为 token (如果存在旧列名) +-- 注意: 如果已经是 token 列名则跳过 +SET @exist_access_token := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'cursor_accounts' + AND COLUMN_NAME = 'access_token' +); + +SET @sql = IF(@exist_access_token > 0, + 'ALTER TABLE cursor_accounts CHANGE COLUMN access_token token TEXT NOT NULL COMMENT "认证Token (user_id::jwt)"', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. 添加新列 (如果不存在) + +-- account_type 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'account_type'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN account_type ENUM("free_trial", "pro", "free", "business", "unknown") DEFAULT "unknown" COMMENT "账号类型"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- membership_type 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'membership_type'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN membership_type VARCHAR(50) NULL COMMENT "会员类型原始值"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- billing_cycle_start 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'billing_cycle_start'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_start DATETIME NULL COMMENT "计费周期开始"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- billing_cycle_end 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'billing_cycle_end'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_end DATETIME NULL COMMENT "计费周期结束"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- trial_days_remaining 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'trial_days_remaining'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN trial_days_remaining INT DEFAULT 0 COMMENT "试用剩余天数"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- usage_limit 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_limit'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_limit INT DEFAULT 0 COMMENT "用量上限"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- usage_used 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_used'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_used INT DEFAULT 0 COMMENT "已用用量"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- usage_remaining 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_remaining'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_remaining INT DEFAULT 0 COMMENT "剩余用量"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- usage_percent 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_percent'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_percent DECIMAL(5,2) DEFAULT 0 COMMENT "用量百分比"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- total_requests 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_requests'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_requests INT DEFAULT 0 COMMENT "总请求次数"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- total_input_tokens 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_input_tokens'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_input_tokens BIGINT DEFAULT 0 COMMENT "总输入Token"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- total_output_tokens 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_output_tokens'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_output_tokens BIGINT DEFAULT 0 COMMENT "总输出Token"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- total_cost_cents 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_cost_cents'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_cost_cents DECIMAL(10,2) DEFAULT 0 COMMENT "总花费(美分)"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- locked_by_key_id 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'locked_by_key_id'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN locked_by_key_id INT NULL COMMENT "被哪个激活码锁定"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- locked_at 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'locked_at'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN locked_at DATETIME NULL COMMENT "锁定时间"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- last_analyzed_at 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'last_analyzed_at'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN last_analyzed_at DATETIME NULL COMMENT "最后分析时间"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- analyze_error 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'analyze_error'); +SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN analyze_error VARCHAR(500) NULL COMMENT "分析错误信息"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 3. 更新状态枚举值 (旧的 active -> available, expired -> exhausted) +UPDATE cursor_accounts SET status = 'available' WHERE status = 'active'; +UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired'; + +-- 4. 修改 status 列的枚举类型 +ALTER TABLE cursor_accounts MODIFY COLUMN status ENUM('pending', 'analyzing', 'available', 'in_use', 'exhausted', 'invalid', 'disabled') DEFAULT 'pending' COMMENT '账号状态'; + +-- 5. 添加索引 +CREATE INDEX IF NOT EXISTS idx_cursor_accounts_status ON cursor_accounts(status); +CREATE INDEX IF NOT EXISTS idx_cursor_accounts_account_type ON cursor_accounts(account_type); +CREATE INDEX IF NOT EXISTS idx_cursor_accounts_locked_by_key_id ON cursor_accounts(locked_by_key_id); + +-- ==================== activation_keys 表迁移 ==================== + +-- 添加新列 (如果不存在) + +-- master_key_id 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'master_key_id'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN master_key_id INT NULL COMMENT "主密钥ID"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- merged_count 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'merged_count'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN merged_count INT DEFAULT 0 COMMENT "已合并的子密钥数量"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- merged_at 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'merged_at'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN merged_at DATETIME NULL COMMENT "合并时间"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- duration_days 列 (旧版可能是 valid_days) +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'duration_days'); +SET @exist_valid_days := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'valid_days'); +SET @sql = IF(@exist = 0 AND @exist_valid_days > 0, + 'ALTER TABLE activation_keys CHANGE COLUMN valid_days duration_days INT DEFAULT 30 COMMENT "该密钥贡献的天数"', + IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN duration_days INT DEFAULT 30 COMMENT "该密钥贡献的天数"', 'SELECT 1') +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- quota_contribution 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'quota_contribution'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN quota_contribution INT DEFAULT 500 COMMENT "该密钥贡献的积分"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- seamless_enabled 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'seamless_enabled'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN seamless_enabled BOOLEAN DEFAULT FALSE COMMENT "是否启用无感换号"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- last_active_at 列 +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'last_active_at'); +SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN last_active_at DATETIME NULL COMMENT "最后活跃时间"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 更新密钥状态枚举 +ALTER TABLE activation_keys MODIFY COLUMN status ENUM('unused', 'active', 'expired', 'disabled') DEFAULT 'unused' COMMENT '状态'; + +-- 更新套餐类型枚举 (free -> auto) +UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free'; +ALTER TABLE activation_keys MODIFY COLUMN membership_type ENUM('auto', 'pro') DEFAULT 'pro' COMMENT '套餐类型'; + +-- 添加索引 +CREATE INDEX IF NOT EXISTS idx_activation_keys_master_key_id ON activation_keys(master_key_id); +CREATE INDEX IF NOT EXISTS idx_activation_keys_device_id ON activation_keys(device_id); + +-- ==================== global_settings 表 - 添加自动检测开关设置 ==================== + +-- 确保 global_settings 表存在 +CREATE TABLE IF NOT EXISTS global_settings ( + id INT PRIMARY KEY AUTO_INCREMENT, + `key` VARCHAR(100) UNIQUE NOT NULL COMMENT '设置键', + value VARCHAR(500) NOT NULL COMMENT '设置值', + value_type VARCHAR(20) DEFAULT 'string' COMMENT '值类型', + description VARCHAR(500) NULL COMMENT '描述', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 添加自动检测开关设置 +INSERT INTO global_settings (`key`, value, value_type, description) VALUES + ('auto_analyze_enabled', 'false', 'bool', '是否启用自动账号分析'), + ('auto_switch_enabled', 'true', 'bool', '是否启用自动换号'), + ('account_analyze_interval', '300', 'int', '账号分析间隔(秒)'), + ('account_analyze_batch_size', '10', 'int', '每批分析账号数量'), + ('auto_switch_threshold', '98', 'int', 'Auto池自动换号阈值(用量百分比)'), + ('pro_switch_threshold', '98', 'int', 'Pro池自动换号阈值(用量百分比)'), + ('max_switch_per_day', '50', 'int', '每日最大换号次数'), + ('auto_daily_switches', '999', 'int', 'Auto密钥每日换号次数限制'), + ('pro_quota_per_switch', '1', 'int', 'Pro密钥每次换号消耗积分') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +-- ==================== 完成 ==================== +SELECT '数据库迁移完成!' AS message; diff --git a/backend/requirements.txt b/backend/requirements.txt index 3760eae..a53551e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,7 @@ pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 aiosqlite==0.19.0 +httpx==0.27.0 +passlib[bcrypt]==1.7.4 +apscheduler==3.10.4 +pymysql==1.1.0 diff --git a/backend/run_migration.py b/backend/run_migration.py new file mode 100644 index 0000000..45693d3 --- /dev/null +++ b/backend/run_migration.py @@ -0,0 +1,69 @@ +""" +数据库迁移脚本 +执行: python run_migration.py +""" +import os +import sys + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import text +from app.database import engine, Base +from app.models.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings + +def run_migration(): + """执行数据库迁移""" + print("=" * 50) + print("蜂鸟Pro v2.1 数据库迁移") + print("=" * 50) + + # 方式1: 使用 SQLAlchemy 自动创建/更新表结构 + print("\n[1/3] 创建/更新表结构...") + try: + Base.metadata.create_all(bind=engine) + print("✓ 表结构更新完成") + except Exception as e: + print(f"✗ 表结构更新失败: {e}") + return False + + # 方式2: 执行自定义迁移 SQL + print("\n[2/3] 执行数据迁移...") + migration_sqls = [ + # 更新旧的状态值 + "UPDATE cursor_accounts SET status = 'available' WHERE status = 'active'", + "UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired'", + # 更新旧的套餐类型 + "UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free'", + ] + + with engine.connect() as conn: + for sql in migration_sqls: + try: + conn.execute(text(sql)) + conn.commit() + print(f"✓ {sql[:50]}...") + except Exception as e: + # 忽略不存在的列/值错误 + if "Unknown column" not in str(e) and "Data truncated" not in str(e): + print(f"⚠ 跳过: {e}") + + # 方式3: 初始化默认设置 + print("\n[3/3] 初始化全局设置...") + try: + from sqlalchemy.orm import Session + with Session(engine) as db: + from app.services import GlobalSettingsService + GlobalSettingsService.init_settings(db) + print("✓ 全局设置初始化完成") + except Exception as e: + print(f"⚠ 设置初始化: {e}") + + print("\n" + "=" * 50) + print("迁移完成!") + print("=" * 50) + print("\n现在可以启动后端: uvicorn app.main:app --reload") + return True + +if __name__ == "__main__": + run_migration() diff --git a/backend/run_migration_v2.py b/backend/run_migration_v2.py new file mode 100644 index 0000000..aab7112 --- /dev/null +++ b/backend/run_migration_v2.py @@ -0,0 +1,112 @@ +""" +数据库迁移脚本 v2 - 直接执行 ALTER TABLE +""" +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import text +from app.database import engine + +def run_migration(): + print("=" * 50) + print("蜂鸟Pro v2.1 数据库迁移 (ALTER TABLE)") + print("=" * 50) + + # 需要添加的列 + alter_statements = [ + # ===== cursor_accounts 表 ===== + # 重命名 access_token -> token (如果存在) + ("cursor_accounts", "ALTER TABLE cursor_accounts CHANGE COLUMN access_token token TEXT NOT NULL"), + # 添加新列 + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN account_type VARCHAR(20) DEFAULT 'unknown'"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN membership_type VARCHAR(50) NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_start DATETIME NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_end DATETIME NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN trial_days_remaining INT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_limit INT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_used INT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_remaining INT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_percent DECIMAL(5,2) DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_requests INT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_input_tokens BIGINT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_output_tokens BIGINT DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_cost_cents DECIMAL(10,2) DEFAULT 0"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN locked_by_key_id INT NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN locked_at DATETIME NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN last_analyzed_at DATETIME NULL"), + ("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN analyze_error VARCHAR(500) NULL"), + + # ===== activation_keys 表 ===== + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN master_key_id INT NULL"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN merged_count INT DEFAULT 0"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN merged_at DATETIME NULL"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN duration_days INT DEFAULT 30"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN quota_contribution INT DEFAULT 500"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN seamless_enabled BOOLEAN DEFAULT FALSE"), + ("activation_keys", "ALTER TABLE activation_keys ADD COLUMN last_active_at DATETIME NULL"), + + # ===== global_settings 表 ===== + ("global_settings", "ALTER TABLE global_settings ADD COLUMN value_type VARCHAR(20) DEFAULT 'string'"), + ("global_settings", "ALTER TABLE global_settings ADD COLUMN description VARCHAR(500) NULL"), + ] + + print("\n[1/3] 添加缺失的列...") + with engine.connect() as conn: + for table, sql in alter_statements: + try: + conn.execute(text(sql)) + conn.commit() + print(f"✓ [{table}] 成功") + except Exception as e: + err = str(e) + if "Duplicate column" in err or "Unknown column" in err: + print(f"⊘ [{table}] 跳过 (列已存在或源列不存在)") + else: + print(f"⚠ [{table}] {err[:60]}") + + print("\n[2/3] 更新状态枚举值...") + update_sqls = [ + "UPDATE cursor_accounts SET status = 'available' WHERE status = 'active'", + "UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired'", + "UPDATE cursor_accounts SET status = 'pending' WHERE status IS NULL OR status = ''", + "UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free'", + ] + with engine.connect() as conn: + for sql in update_sqls: + try: + conn.execute(text(sql)) + conn.commit() + print(f"✓ {sql[:50]}...") + except Exception as e: + print(f"⚠ 跳过: {str(e)[:50]}") + + print("\n[3/3] 初始化全局设置...") + settings_sql = """ + INSERT IGNORE INTO global_settings (`key`, value, value_type, description) VALUES + ('key_max_devices', '2', 'int', '主密钥最大设备数'), + ('auto_merge_enabled', 'true', 'bool', '是否启用同类型密钥自动合并'), + ('auto_analyze_enabled', 'false', 'bool', '是否启用自动账号分析'), + ('auto_switch_enabled', 'true', 'bool', '是否启用自动换号'), + ('account_analyze_interval', '300', 'int', '账号分析间隔(秒)'), + ('account_analyze_batch_size', '10', 'int', '每批分析账号数量'), + ('auto_switch_threshold', '98', 'int', 'Auto池自动换号阈值'), + ('pro_switch_threshold', '98', 'int', 'Pro池自动换号阈值'), + ('max_switch_per_day', '50', 'int', '每日最大换号次数'), + ('auto_daily_switches', '999', 'int', 'Auto密钥每日换号次数限制'), + ('pro_quota_per_switch', '1', 'int', 'Pro密钥每次换号消耗积分') + """ + with engine.connect() as conn: + try: + conn.execute(text(settings_sql)) + conn.commit() + print("✓ 全局设置初始化完成") + except Exception as e: + print(f"⚠ {e}") + + print("\n" + "=" * 50) + print("迁移完成! 请重启后端服务") + print("=" * 50) + +if __name__ == "__main__": + run_migration() diff --git a/backend/templates/index.html b/backend/templates/index.html index 81ea128..4b3ba28 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -88,6 +88,11 @@ class="px-3 py-2 font-medium text-sm rounded-md"> 批量补偿 + - @@ -207,7 +212,7 @@