蜂鸟Pro v2.0.1 - 基础框架版本 (待完善)

## 当前状态
- 插件界面已完成重命名 (cursorpro → hummingbird)
- 双账号池 UI 已实现 (Auto/Pro 卡片)
- 后端已切换到 MySQL 数据库
- 添加了 Cursor 官方用量 API 文档

## 已知问题 (待修复)
1. 激活时检查账号导致无账号时激活失败
2. 未启用无感换号时不应获取账号
3. 账号用量模块不显示 (seamless 未启用时应隐藏)
4. 积分显示为 0 (后端未正确返回)
5. Auto/Pro 双密钥逻辑混乱,状态不同步
6. 账号添加后无自动分析功能

## 下一版本计划
- 重构数据模型,优化账号状态管理
- 实现 Cursor API 自动分析账号
- 修复激活流程,不依赖账号
- 启用无感时才分配账号
- 完善账号用量实时显示

## 文件说明
- docs/系统设计文档.md - 完整架构设计
- cursor 官方用量接口.md - Cursor API 文档
- 参考计费/ - Vibeviewer 开源项目参考

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ccdojox-crypto
2025-12-18 11:21:52 +08:00
parent f310ca7b97
commit 73a71f198f
202 changed files with 19142 additions and 252 deletions

View File

@@ -1,2 +1,11 @@
from app.services.account_service import AccountService, KeyService, LogService, GlobalSettingsService, BatchService
from app.services.auth_service import authenticate_admin, create_access_token, get_current_user
from app.services.cursor_usage_service import (
CursorUsageService,
CursorUsageInfo,
cursor_usage_service,
check_account_valid,
get_account_usage,
batch_check_accounts,
check_and_classify_account
)

View File

@@ -121,11 +121,17 @@ class KeyService:
if retry == max_retries - 1:
raise ValueError(f"无法生成唯一激活码,请重试")
# 根据类型设置默认值
is_pro = key_data.membership_type == MembershipType.PRO
db_key = ActivationKey(
key=key_str,
status=KeyStatus.UNUSED, # 新密钥默认未使用
membership_type=key_data.membership_type,
quota=key_data.quota if key_data.membership_type == MembershipType.PRO else 0, # Free不需要额度
valid_days=key_data.valid_days,
# 该密钥贡献的资源
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
)
@@ -171,22 +177,139 @@ class KeyService:
return False
@staticmethod
def activate(db: Session, key: ActivationKey):
"""首次激活:设置激活时间和过期时间"""
if key.first_activated_at is None:
key.first_activated_at = datetime.now()
if key.valid_days > 0:
key.expire_at = key.first_activated_at + timedelta(days=key.valid_days)
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.ACTIVE:
"""检查激活码是否有效(仅检查主密钥)"""
# 状态检查
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.first_activated_at and key.expire_at and key.expire_at < datetime.now():
if key.expire_at and key.expire_at < datetime.now():
return False, "激活码已过期"
# Pro套餐检查额度
@@ -292,6 +415,66 @@ class KeyService:
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:
"""统计激活码数量"""

View File

@@ -0,0 +1,337 @@
"""
Cursor 官方用量 API 服务
用于验证账号有效性和查询用量信息
"""
import httpx
import asyncio
from typing import Optional, Dict, Any, Tuple, List
from dataclasses import dataclass
from datetime import datetime
@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]) -> 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
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'} 号池"
}