backend v2.1: 公告管理功能 + 系统重构
- 新增 Announcement 数据模型,支持公告的增删改查 - 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用) - 客户端 /api/announcement 改为从数据库读取 - 账号服务重构,新增无感换号、自动分析等功能 - 新增后台任务调度器、数据库迁移脚本 - Schema/Service/Config 全面升级至 v2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "公告已禁用"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
294
backend/app/tasks.py
Normal file
294
backend/app/tasks.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user