- 新增 Announcement 数据模型,支持公告的增删改查 - 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用) - 客户端 /api/announcement 改为从数据库读取 - 账号服务重构,新增无感换号、自动分析等功能 - 新增后台任务调度器、数据库迁移脚本 - Schema/Service/Config 全面升级至 v2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
434 lines
14 KiB
Python
434 lines
14 KiB
Python
"""
|
||
Cursor 官方用量 API 服务 v2.1
|
||
用于验证账号有效性和查询用量信息
|
||
"""
|
||
import httpx
|
||
import asyncio
|
||
from typing import Optional, Dict, Any, Tuple, List
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
|
||
@dataclass
|
||
class CursorUsageInfo:
|
||
"""Cursor 用量信息"""
|
||
is_valid: bool = False # 账号是否有效
|
||
error_message: Optional[str] = None # 错误信息
|
||
|
||
# 用户信息
|
||
user_id: Optional[int] = None
|
||
email: Optional[str] = None
|
||
team_id: Optional[int] = None
|
||
is_enterprise: bool = False
|
||
|
||
# 会员信息
|
||
membership_type: str = "free" # free, free_trial, pro, business
|
||
billing_cycle_start: Optional[str] = None
|
||
billing_cycle_end: Optional[str] = None
|
||
days_remaining_on_trial: Optional[int] = None # 试用剩余天数 (free_trial)
|
||
|
||
# 套餐用量
|
||
plan_used: int = 0
|
||
plan_limit: int = 0
|
||
plan_remaining: int = 0
|
||
|
||
# Token 用量
|
||
total_input_tokens: int = 0
|
||
total_output_tokens: int = 0
|
||
total_cache_read_tokens: int = 0
|
||
total_cost_cents: float = 0.0
|
||
|
||
# 请求次数
|
||
total_requests: int = 0 # totalUsageEventsCount
|
||
|
||
@property
|
||
def pool_type(self) -> str:
|
||
"""
|
||
判断账号应归入哪个号池
|
||
- 'pro': Pro池 (free_trial, pro, business)
|
||
- 'auto': Auto池 (free)
|
||
"""
|
||
if self.membership_type in ('free_trial', 'pro', 'business'):
|
||
return 'pro'
|
||
return 'auto'
|
||
|
||
@property
|
||
def is_pro_trial(self) -> bool:
|
||
"""是否为 Pro 试用账号"""
|
||
return self.membership_type == 'free_trial'
|
||
|
||
@property
|
||
def is_usable(self) -> bool:
|
||
"""账号是否可用 (有效且有剩余额度)"""
|
||
if not self.is_valid:
|
||
return False
|
||
# Pro试用和Pro需要检查剩余额度
|
||
if self.pool_type == 'pro':
|
||
return self.plan_remaining > 0
|
||
# Auto池 free账号始终可用
|
||
return True
|
||
|
||
|
||
class CursorUsageService:
|
||
"""Cursor 用量查询服务"""
|
||
|
||
BASE_URL = "https://cursor.com"
|
||
TIMEOUT = 15.0
|
||
|
||
def __init__(self):
|
||
self.headers = {
|
||
"accept": "*/*",
|
||
"content-type": "application/json",
|
||
"origin": "https://cursor.com",
|
||
"referer": "https://cursor.com/dashboard",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
}
|
||
|
||
def _get_cookie_header(self, token: str) -> str:
|
||
"""构造 Cookie Header"""
|
||
# 支持直接传 token 或完整 cookie
|
||
if token.startswith("WorkosCursorSessionToken="):
|
||
return token
|
||
return f"WorkosCursorSessionToken={token}"
|
||
|
||
async def _request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
token: str,
|
||
json_data: Optional[Dict] = None
|
||
) -> Dict[str, Any]:
|
||
"""发送请求"""
|
||
headers = {**self.headers, "Cookie": self._get_cookie_header(token)}
|
||
|
||
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
||
if method == "GET":
|
||
resp = await client.get(f"{self.BASE_URL}{path}", headers=headers)
|
||
else:
|
||
resp = await client.post(
|
||
f"{self.BASE_URL}{path}",
|
||
headers=headers,
|
||
json=json_data or {}
|
||
)
|
||
|
||
if resp.status_code == 200:
|
||
return {"success": True, "data": resp.json()}
|
||
elif resp.status_code in [401, 403]:
|
||
return {"success": False, "error": "认证失败,Token 无效或已过期"}
|
||
else:
|
||
return {"success": False, "error": f"请求失败: {resp.status_code}"}
|
||
|
||
async def get_usage_summary(self, token: str) -> Dict[str, Any]:
|
||
"""获取用量摘要"""
|
||
return await self._request("GET", "/api/usage-summary", token)
|
||
|
||
async def get_billing_cycle(self, token: str) -> Dict[str, Any]:
|
||
"""获取当前计费周期"""
|
||
return await self._request("POST", "/api/dashboard/get-current-billing-cycle", token, {})
|
||
|
||
async def get_filtered_usage(
|
||
self,
|
||
token: str,
|
||
start_date_ms: str,
|
||
end_date_ms: str,
|
||
page: int = 1,
|
||
page_size: int = 100
|
||
) -> Dict[str, Any]:
|
||
"""获取过滤后的使用事件"""
|
||
return await self._request(
|
||
"POST",
|
||
"/api/dashboard/get-filtered-usage-events",
|
||
token,
|
||
{
|
||
"startDate": start_date_ms,
|
||
"endDate": end_date_ms,
|
||
"page": page,
|
||
"pageSize": page_size
|
||
}
|
||
)
|
||
|
||
async def get_aggregated_usage(
|
||
self,
|
||
token: str,
|
||
start_date_ms: int,
|
||
team_id: Optional[int] = None
|
||
) -> Dict[str, Any]:
|
||
"""获取聚合使用事件"""
|
||
data = {"startDate": start_date_ms}
|
||
if team_id:
|
||
data["teamId"] = team_id
|
||
return await self._request(
|
||
"POST",
|
||
"/api/dashboard/get-aggregated-usage-events",
|
||
token,
|
||
data
|
||
)
|
||
|
||
async def validate_and_get_usage(self, token: str) -> CursorUsageInfo:
|
||
"""
|
||
验证账号并获取完整用量信息
|
||
这是主要的对外接口
|
||
"""
|
||
result = CursorUsageInfo()
|
||
|
||
try:
|
||
# 1. 获取用量摘要 (验证 token 有效性)
|
||
summary_resp = await self.get_usage_summary(token)
|
||
if not summary_resp["success"]:
|
||
result.error_message = summary_resp["error"]
|
||
return result
|
||
|
||
summary = summary_resp["data"]
|
||
result.is_valid = True
|
||
result.membership_type = summary.get("membershipType", "free")
|
||
result.billing_cycle_start = summary.get("billingCycleStart")
|
||
result.billing_cycle_end = summary.get("billingCycleEnd")
|
||
result.days_remaining_on_trial = summary.get("daysRemainingOnTrial") # 试用剩余天数
|
||
|
||
# 套餐用量
|
||
individual = summary.get("individualUsage", {})
|
||
plan = individual.get("plan", {})
|
||
result.plan_used = plan.get("used", 0)
|
||
result.plan_limit = plan.get("limit", 0)
|
||
result.plan_remaining = plan.get("remaining", 0)
|
||
|
||
# 2. 获取计费周期
|
||
billing_resp = await self.get_billing_cycle(token)
|
||
if billing_resp["success"]:
|
||
billing = billing_resp["data"]
|
||
start_ms = billing.get("startDateEpochMillis", "0")
|
||
end_ms = billing.get("endDateEpochMillis", "0")
|
||
|
||
# 3. 获取请求次数 (totalUsageEventsCount)
|
||
filtered_resp = await self.get_filtered_usage(token, start_ms, end_ms, 1, 1)
|
||
if filtered_resp["success"]:
|
||
filtered = filtered_resp["data"]
|
||
result.total_requests = filtered.get("totalUsageEventsCount", 0)
|
||
|
||
# 4. 获取 Token 用量
|
||
aggregated_resp = await self.get_aggregated_usage(token, int(start_ms))
|
||
if aggregated_resp["success"]:
|
||
agg = aggregated_resp["data"]
|
||
result.total_input_tokens = int(agg.get("totalInputTokens", "0"))
|
||
result.total_output_tokens = int(agg.get("totalOutputTokens", "0"))
|
||
result.total_cache_read_tokens = int(agg.get("totalCacheReadTokens", "0"))
|
||
result.total_cost_cents = agg.get("totalCostCents", 0.0)
|
||
|
||
return result
|
||
|
||
except httpx.TimeoutException:
|
||
result.error_message = "请求超时"
|
||
return result
|
||
except Exception as e:
|
||
result.error_message = f"请求异常: {str(e)}"
|
||
return result
|
||
|
||
def validate_and_get_usage_sync(self, token: str) -> CursorUsageInfo:
|
||
"""同步版本的验证和获取用量"""
|
||
return asyncio.run(self.validate_and_get_usage(token))
|
||
|
||
|
||
# 单例
|
||
cursor_usage_service = CursorUsageService()
|
||
|
||
|
||
# ============ 便捷函数 ============
|
||
|
||
async def check_account_valid(token: str) -> Tuple[bool, Optional[str]]:
|
||
"""
|
||
检查账号是否有效
|
||
返回: (是否有效, 错误信息)
|
||
"""
|
||
try:
|
||
resp = await cursor_usage_service.get_usage_summary(token)
|
||
if resp["success"]:
|
||
return True, None
|
||
return False, resp["error"]
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
async def get_account_usage(token: str) -> Dict[str, Any]:
|
||
"""
|
||
获取账号用量信息
|
||
返回格式化的用量数据
|
||
"""
|
||
info = await cursor_usage_service.validate_and_get_usage(token)
|
||
|
||
if not info.is_valid:
|
||
return {
|
||
"success": False,
|
||
"error": info.error_message
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"membership_type": info.membership_type,
|
||
"pool_type": info.pool_type, # 号池类型: pro/auto
|
||
"is_pro_trial": info.is_pro_trial, # 是否Pro试用
|
||
"is_usable": info.is_usable, # 是否可用
|
||
"days_remaining_on_trial": info.days_remaining_on_trial,
|
||
"billing_cycle": {
|
||
"start": info.billing_cycle_start,
|
||
"end": info.billing_cycle_end
|
||
},
|
||
"plan_usage": {
|
||
"used": info.plan_used,
|
||
"limit": info.plan_limit,
|
||
"remaining": info.plan_remaining
|
||
},
|
||
"token_usage": {
|
||
"input_tokens": info.total_input_tokens,
|
||
"output_tokens": info.total_output_tokens,
|
||
"cache_read_tokens": info.total_cache_read_tokens,
|
||
"total_cost_usd": round(info.total_cost_cents / 100, 4)
|
||
},
|
||
"total_requests": info.total_requests
|
||
}
|
||
}
|
||
|
||
|
||
async def batch_check_accounts(tokens: List[str], max_concurrency: int = 5) -> List[Dict[str, Any]]:
|
||
"""
|
||
批量检查多个账号(并发,带限流)
|
||
"""
|
||
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]:
|
||
"""
|
||
检查账号并分类到对应号池
|
||
返回账号信息和推荐的号池
|
||
"""
|
||
info = await cursor_usage_service.validate_and_get_usage(token)
|
||
|
||
if not info.is_valid:
|
||
return {
|
||
"success": False,
|
||
"error": info.error_message
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"pool_type": info.pool_type, # 'pro' 或 'auto'
|
||
"is_usable": info.is_usable, # 是否可用
|
||
"membership_type": info.membership_type,
|
||
"is_pro_trial": info.is_pro_trial,
|
||
"plan_remaining": info.plan_remaining,
|
||
"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)
|