Files
cursornew2026/backend/app/services/account_service.py
ccdojox-crypto 73a71f198f 蜂鸟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>
2025-12-18 11:21:52 +08:00

731 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from app.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, MembershipType, AccountStatus, KeyStatus, GlobalSettings
from app.schemas import AccountCreate, KeyCreate
def generate_key(length: int = 32) -> str:
"""生成随机激活码"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
class AccountService:
"""账号管理服务"""
@staticmethod
def create(db: Session, account: AccountCreate) -> CursorAccount:
"""创建账号"""
db_account = CursorAccount(
email=account.email,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token,
membership_type=account.membership_type,
remark=account.remark
)
db.add(db_account)
db.commit()
db.refresh(db_account)
return db_account
@staticmethod
def get_by_id(db: Session, account_id: int) -> Optional[CursorAccount]:
return db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
@staticmethod
def get_by_email(db: Session, email: str) -> Optional[CursorAccount]:
return db.query(CursorAccount).filter(CursorAccount.email == email).first()
@staticmethod
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[CursorAccount]:
return db.query(CursorAccount).offset(skip).limit(limit).all()
@staticmethod
def get_available(db: Session, membership_type: MembershipType = None) -> Optional[CursorAccount]:
"""获取一个可用账号"""
query = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE)
if membership_type:
query = query.filter(CursorAccount.membership_type == membership_type)
# 优先选择使用次数少的
return query.order_by(CursorAccount.usage_count.asc()).first()
@staticmethod
def update(db: Session, account_id: int, **kwargs) -> Optional[CursorAccount]:
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
for key, value in kwargs.items():
if hasattr(account, key) and value is not None:
setattr(account, key, value)
db.commit()
db.refresh(account)
return account
@staticmethod
def delete(db: Session, account_id: int) -> bool:
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
db.delete(account)
db.commit()
return True
return False
@staticmethod
def mark_used(db: Session, account: CursorAccount, key_id: int = None):
"""标记账号被使用"""
account.usage_count += 1
account.last_used_at = datetime.now()
account.status = AccountStatus.IN_USE
if key_id:
account.current_key_id = key_id
db.commit()
@staticmethod
def release(db: Session, account: CursorAccount):
"""释放账号"""
account.status = AccountStatus.ACTIVE
account.current_key_id = None
db.commit()
@staticmethod
def count(db: Session) -> dict:
"""统计账号数量"""
total = db.query(CursorAccount).count()
active = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE).count()
pro = db.query(CursorAccount).filter(CursorAccount.membership_type == MembershipType.PRO).count()
return {"total": total, "active": active, "pro": pro}
class KeyService:
"""激活码管理服务"""
@staticmethod
def create(db: Session, key_data: KeyCreate) -> List[ActivationKey]:
"""创建激活码(支持批量)"""
keys = []
max_retries = 5 # 最大重试次数
for _ in range(key_data.count):
# 生成唯一的key如果冲突则重试
for retry in range(max_retries):
key_str = key_data.key if key_data.key and key_data.count == 1 else generate_key()
# 检查key是否已存在
existing = db.query(ActivationKey).filter(ActivationKey.key == key_str).first()
if not existing:
break
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,
# 该密钥贡献的资源
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
)
db.add(db_key)
keys.append(db_key)
db.commit()
for k in keys:
db.refresh(k)
return keys
@staticmethod
def get_by_key(db: Session, key: str) -> Optional[ActivationKey]:
return db.query(ActivationKey).filter(ActivationKey.key == key).first()
@staticmethod
def get_by_id(db: Session, key_id: int) -> Optional[ActivationKey]:
return db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
@staticmethod
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[ActivationKey]:
return db.query(ActivationKey).order_by(ActivationKey.id.desc()).offset(skip).limit(limit).all()
@staticmethod
def update(db: Session, key_id: int, **kwargs) -> Optional[ActivationKey]:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if key:
for k, v in kwargs.items():
if hasattr(key, k) and v is not None:
setattr(key, k, v)
db.commit()
db.refresh(key)
return key
@staticmethod
def delete(db: Session, key_id: int) -> bool:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if key:
# 删除关联的设备记录
db.query(KeyDevice).filter(KeyDevice.key_id == key_id).delete()
db.delete(key)
db.commit()
return True
return False
@staticmethod
def activate(db: Session, key: ActivationKey, device_id: str = None) -> Tuple[bool, str, Optional[ActivationKey]]:
"""
激活密钥
- 如果设备已有同类型主密钥,则合并(叠加时长/积分)
- 否则,该密钥成为主密钥
返回: (成功, 消息, 主密钥)
"""
now = datetime.now()
# 检查密钥状态
if key.status == KeyStatus.MERGED:
return False, "该密钥已被合并使用", None
if key.status == KeyStatus.REVOKED:
return False, "该密钥已被撤销", None
if key.status == KeyStatus.DISABLED:
return False, "该密钥已被禁用", None
if key.status == KeyStatus.ACTIVE:
# 已激活的密钥,检查是否是同设备
if device_id and key.device_id and key.device_id != device_id:
# 换设备激活更新设备ID
key.device_id = device_id
db.commit()
return True, "密钥已激活", key
# 查找该设备同类型的主密钥
master_key = None
if device_id:
master_key = db.query(ActivationKey).filter(
ActivationKey.device_id == device_id,
ActivationKey.membership_type == key.membership_type,
ActivationKey.status == KeyStatus.ACTIVE,
ActivationKey.master_key_id == None # 是主密钥
).first()
if master_key:
# 合并到现有主密钥
key.status = KeyStatus.MERGED
key.master_key_id = master_key.id
key.merged_at = now
key.device_id = device_id
# 叠加资源到主密钥
if key.membership_type == MembershipType.PRO:
# Pro: 叠加积分
master_key.quota += key.quota_contribution
else:
# Auto: 叠加时长
if master_key.expire_at:
master_key.expire_at += timedelta(days=key.duration_days)
else:
master_key.expire_at = now + timedelta(days=key.duration_days)
master_key.merged_count += 1
db.commit()
return True, f"密钥已合并,{'积分' if key.membership_type == MembershipType.PRO else '时长'}已叠加", master_key
else:
# 该密钥成为主密钥
key.status = KeyStatus.ACTIVE
key.device_id = device_id
key.first_activated_at = now
# 设置初始到期时间Auto
if key.membership_type == MembershipType.FREE and key.duration_days > 0:
key.expire_at = now + timedelta(days=key.duration_days)
db.commit()
return True, "激活成功", key
@staticmethod
def get_master_key(db: Session, device_id: str, membership_type: MembershipType) -> Optional[ActivationKey]:
"""获取设备的主密钥"""
return db.query(ActivationKey).filter(
ActivationKey.device_id == device_id,
ActivationKey.membership_type == membership_type,
ActivationKey.status == KeyStatus.ACTIVE,
ActivationKey.master_key_id == None
).first()
@staticmethod
def get_device_keys(db: Session, device_id: str) -> dict:
"""获取设备的所有密钥信息"""
result = {"auto": None, "pro": None}
# 获取Auto主密钥
auto_master = KeyService.get_master_key(db, device_id, MembershipType.FREE)
if auto_master:
# 获取合并的密钥
merged_keys = db.query(ActivationKey).filter(
ActivationKey.master_key_id == auto_master.id
).all()
result["auto"] = {
"master": auto_master,
"merged_keys": merged_keys,
"total_keys": 1 + len(merged_keys),
"expire_at": auto_master.expire_at
}
# 获取Pro主密钥
pro_master = KeyService.get_master_key(db, device_id, MembershipType.PRO)
if pro_master:
merged_keys = db.query(ActivationKey).filter(
ActivationKey.master_key_id == pro_master.id
).all()
result["pro"] = {
"master": pro_master,
"merged_keys": merged_keys,
"total_keys": 1 + len(merged_keys),
"quota": pro_master.quota,
"quota_used": pro_master.quota_used,
"quota_remaining": pro_master.quota - pro_master.quota_used
}
return result
@staticmethod
def is_valid(key: ActivationKey, db: Session) -> Tuple[bool, str]:
"""检查激活码是否有效(仅检查主密钥)"""
# 状态检查
if key.status == KeyStatus.UNUSED:
return False, "激活码未激活"
if key.status == KeyStatus.MERGED:
return False, "该密钥已合并,请使用主密钥"
if key.status == KeyStatus.REVOKED:
return False, "激活码已被撤销"
if key.status == KeyStatus.DISABLED:
return False, "激活码已禁用"
if key.status == KeyStatus.EXPIRED:
return False, "激活码已过期"
if key.status != KeyStatus.ACTIVE:
return False, "激活码状态异常"
# 检查是否已过期(只有激活后才检查)
if key.expire_at and key.expire_at < datetime.now():
return False, "激活码已过期"
# Pro套餐检查额度
if key.membership_type == MembershipType.PRO:
quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost")
if key.quota_used + quota_cost > key.quota:
return False, f"额度不足,需要{quota_cost},剩余{key.quota - key.quota_used}"
return True, "有效"
@staticmethod
def can_switch(db: Session, key: ActivationKey) -> Tuple[bool, str]:
"""检查是否可以换号
- Auto: 检查换号间隔 + 每天最大次数(全局设置)
- Pro: 无频率限制只检查额度在is_valid中
"""
# Pro密钥无频率限制
if key.membership_type == MembershipType.PRO:
return True, "可以换号"
# === Auto密钥频率检查 ===
now = datetime.now()
# 1. 检查换号间隔
interval_minutes = GlobalSettingsService.get_int(db, "auto_switch_interval_minutes")
if key.last_switch_at:
minutes_since_last = (now - key.last_switch_at).total_seconds() / 60
if minutes_since_last < interval_minutes:
wait_minutes = int(interval_minutes - minutes_since_last)
return False, f"换号太频繁,请等待{wait_minutes}分钟"
# 2. 检查今日换号次数
max_per_day = GlobalSettingsService.get_int(db, "auto_max_switches_per_day")
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = db.query(UsageLog).filter(
UsageLog.key_id == key.id,
UsageLog.action == "switch",
UsageLog.success == True,
UsageLog.created_at >= today_start
).count()
if today_count >= max_per_day:
return False, f"今日换号次数已达上限({max_per_day}次)"
return True, "可以换号"
@staticmethod
def check_device(db: Session, key: ActivationKey, device_id: str) -> Tuple[bool, str]:
"""检查设备限制"""
if not device_id:
return True, "无设备ID"
# 查找现有设备
device = db.query(KeyDevice).filter(
KeyDevice.key_id == key.id,
KeyDevice.device_id == device_id
).first()
if device:
# 更新最后活跃时间
device.last_active_at = datetime.now()
db.commit()
return True, "设备已绑定"
# 检查设备数量
device_count = db.query(KeyDevice).filter(KeyDevice.key_id == key.id).count()
if device_count >= key.max_devices:
return False, f"设备数量已达上限({key.max_devices}个)"
# 添加新设备
new_device = KeyDevice(key_id=key.id, device_id=device_id, last_active_at=datetime.now())
db.add(new_device)
db.commit()
return True, "新设备已绑定"
@staticmethod
def use_switch(db: Session, key: ActivationKey):
"""使用一次换号Pro扣除额度Free不扣"""
if key.membership_type == MembershipType.PRO:
quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost")
key.quota_used += quota_cost
# Free不扣额度
key.switch_count += 1
key.last_switch_at = datetime.now()
db.commit()
@staticmethod
def get_quota_cost(db: Session, membership_type: MembershipType) -> int:
"""获取换号消耗的额度"""
if membership_type == MembershipType.PRO:
return GlobalSettingsService.get_int(db, "pro_quota_cost")
return 0 # Free不消耗额度
@staticmethod
def add_quota(db: Session, key: ActivationKey, add_quota: int):
"""叠加额度只加额度不加时间仅Pro有效"""
key.quota += add_quota
db.commit()
@staticmethod
def bind_account(db: Session, key: ActivationKey, account: CursorAccount):
"""绑定账号"""
key.current_account_id = account.id
db.commit()
@staticmethod
def revoke_key(db: Session, key_id: int) -> Tuple[bool, str]:
"""
撤销密钥
- 如果是主密钥:不允许直接撤销(需要先撤销所有合并的密钥)
- 如果是合并的密钥:从主密钥扣除贡献的资源
"""
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if not key:
return False, "密钥不存在"
if key.status == KeyStatus.REVOKED:
return False, "密钥已被撤销"
if key.status == KeyStatus.ACTIVE and key.master_key_id is None:
# 是主密钥,检查是否有合并的密钥
merged_count = db.query(ActivationKey).filter(
ActivationKey.master_key_id == key.id,
ActivationKey.status == KeyStatus.MERGED
).count()
if merged_count > 0:
return False, f"该密钥有{merged_count}个合并密钥,请先撤销合并的密钥"
# 主密钥没有合并密钥,可以直接撤销
key.status = KeyStatus.REVOKED
db.commit()
return True, "主密钥已撤销"
elif key.status == KeyStatus.MERGED:
# 是合并的密钥,从主密钥扣除资源
master = db.query(ActivationKey).filter(ActivationKey.id == key.master_key_id).first()
if not master:
return False, "找不到主密钥"
if key.membership_type == MembershipType.PRO:
# Pro: 检查扣除后是否会导致已用超额
new_quota = master.quota - key.quota_contribution
if master.quota_used > new_quota:
return False, f"无法撤销:撤销后剩余额度({new_quota})小于已用额度({master.quota_used})"
master.quota = new_quota
else:
# Auto: 扣除时长
if master.expire_at:
master.expire_at -= timedelta(days=key.duration_days)
# 检查扣除后是否已过期
if master.expire_at < datetime.now():
return False, "无法撤销:撤销后密钥将立即过期"
master.merged_count -= 1
key.status = KeyStatus.REVOKED
key.master_key_id = None # 解除关联
db.commit()
return True, "合并密钥已撤销,资源已扣除"
else:
# 其他状态UNUSED, DISABLED 等)
key.status = KeyStatus.REVOKED
db.commit()
return True, "密钥已撤销"
@staticmethod
def count(db: Session) -> dict:
"""统计激活码数量"""
total = db.query(ActivationKey).count()
active = db.query(ActivationKey).filter(ActivationKey.status == KeyStatus.ACTIVE).count()
return {"total": total, "active": active}
class LogService:
"""日志服务"""
@staticmethod
def log(db: Session, key_id: int, action: str, account_id: int = None,
ip_address: str = None, user_agent: str = None,
success: bool = True, message: str = None):
log = UsageLog(
key_id=key_id,
account_id=account_id,
action=action,
ip_address=ip_address,
user_agent=user_agent,
success=success,
message=message
)
db.add(log)
db.commit()
@staticmethod
def get_today_count(db: Session) -> int:
today = datetime.now().date()
return db.query(UsageLog).filter(
func.date(UsageLog.created_at) == today
).count()
class GlobalSettingsService:
"""全局设置服务"""
# 默认设置
DEFAULT_SETTINGS = {
# Auto密钥设置
"auto_switch_interval_minutes": ("20", "Auto换号最小间隔(分钟)"),
"auto_max_switches_per_day": ("50", "Auto每天最大换号次数"),
# Pro密钥设置
"pro_quota_cost": ("50", "Pro每次换号扣除额度"),
}
@staticmethod
def init_settings(db: Session):
"""初始化默认设置"""
for key, (value, desc) in GlobalSettingsService.DEFAULT_SETTINGS.items():
existing = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if not existing:
setting = GlobalSettings(key=key, value=value, description=desc)
db.add(setting)
db.commit()
@staticmethod
def get(db: Session, key: str) -> Optional[str]:
"""获取单个设置"""
setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if setting:
return setting.value
# 返回默认值
if key in GlobalSettingsService.DEFAULT_SETTINGS:
return GlobalSettingsService.DEFAULT_SETTINGS[key][0]
return None
@staticmethod
def get_int(db: Session, key: str) -> int:
"""获取整数设置"""
value = GlobalSettingsService.get(db, key)
return int(value) if value else 0
@staticmethod
def set(db: Session, key: str, value: str, description: str = None):
"""设置单个配置"""
setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if setting:
setting.value = value
if description:
setting.description = description
else:
setting = GlobalSettings(key=key, value=value, description=description)
db.add(setting)
db.commit()
@staticmethod
def get_all(db: Session) -> dict:
"""获取所有设置"""
return {
"auto_switch_interval_minutes": GlobalSettingsService.get_int(db, "auto_switch_interval_minutes"),
"auto_max_switches_per_day": GlobalSettingsService.get_int(db, "auto_max_switches_per_day"),
"pro_quota_cost": GlobalSettingsService.get_int(db, "pro_quota_cost"),
}
@staticmethod
def update_all(db: Session, **kwargs):
"""批量更新设置"""
for key, value in kwargs.items():
if value is not None:
GlobalSettingsService.set(db, key, str(value))
class BatchService:
"""批量操作服务"""
@staticmethod
def extend_keys(db: Session, key_ids: List[int], extend_days: int = 0, add_quota: int = 0) -> dict:
"""批量延长密钥
- Auto密钥只能延长到期时间
- Pro密钥可以延长到期时间 + 增加额度
"""
success = 0
failed = 0
errors = []
for key_id in key_ids:
try:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if not key:
failed += 1
errors.append(f"ID {key_id}: 密钥不存在")
continue
# 延长到期时间
if extend_days > 0:
if key.expire_at:
# 已激活:在当前到期时间基础上延长
key.expire_at = key.expire_at + timedelta(days=extend_days)
else:
# 未激活:增加有效天数
key.valid_days += extend_days
# 增加额度仅Pro有效
if add_quota > 0 and key.membership_type == MembershipType.PRO:
key.quota += add_quota
db.commit()
success += 1
except Exception as e:
failed += 1
errors.append(f"ID {key_id}: {str(e)}")
return {"success": success, "failed": failed, "errors": errors[:10]}
@staticmethod
def get_keys_for_compensation(
db: Session,
membership_type: MembershipType = None,
activated_before: datetime = None,
not_expired_on: datetime = None,
) -> List[ActivationKey]:
"""获取符合补偿条件的密钥列表
- membership_type: 筛选套餐类型 (pro/free)
- activated_before: 在此日期之前激活的 (first_activated_at < activated_before)
- not_expired_on: 在此日期时还未过期的 (expire_at > not_expired_on)
例如补偿12月4号之前激活、且12月4号还没过期的用户
activated_before = 2024-12-05 (12月4号之前即<12月5号0点)
not_expired_on = 2024-12-04 (12月4号还没过期即expire_at > 12月4号)
"""
query = db.query(ActivationKey)
if membership_type:
query = query.filter(ActivationKey.membership_type == membership_type)
# 只选择状态为active的
query = query.filter(ActivationKey.status == KeyStatus.ACTIVE)
# 只选择已激活的(有激活时间的)
query = query.filter(ActivationKey.first_activated_at != None)
if activated_before:
# 在指定日期之前激活的
query = query.filter(ActivationKey.first_activated_at < activated_before)
if not_expired_on:
# 在指定日期时还未过期的 (expire_at > 指定日期 或 永久卡)
query = query.filter(
or_(
ActivationKey.expire_at == None, # 永久卡
ActivationKey.expire_at > not_expired_on # 在那天还没过期
)
)
return query.all()
@staticmethod
def batch_compensate(
db: Session,
membership_type: MembershipType = None,
activated_before: datetime = None,
not_expired_on: datetime = None,
extend_days: int = 0,
add_quota: int = 0
) -> dict:
"""批量补偿 - 根据条件筛选并补偿
补偿逻辑:
- 如果卡当前还没过期expire_at += extend_days
- 如果卡已过期但符合补偿条件expire_at = 今天 + extend_days恢复使用
例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥延长1天
"""
keys = BatchService.get_keys_for_compensation(
db,
membership_type=membership_type,
activated_before=activated_before,
not_expired_on=not_expired_on
)
if not keys:
return {"success": 0, "failed": 0, "total_matched": 0, "recovered": 0, "errors": ["没有符合条件的密钥"]}
success = 0
failed = 0
recovered = 0 # 恢复使用的数量
errors = []
now = datetime.now()
for key in keys:
try:
# 延长到期时间
if extend_days > 0 and key.expire_at:
if key.expire_at > now:
# 还没过期:在当前过期时间上加天数
key.expire_at = key.expire_at + timedelta(days=extend_days)
else:
# 已过期:恢复使用,设为今天+补偿天数
key.expire_at = now + timedelta(days=extend_days)
recovered += 1
# 增加额度仅Pro有效
if add_quota > 0 and key.membership_type == MembershipType.PRO:
key.quota += add_quota
db.commit()
success += 1
except Exception as e:
failed += 1
errors.append(f"ID {key.id}: {str(e)}")
return {
"success": success,
"failed": failed,
"total_matched": len(keys),
"recovered": recovered,
"errors": errors[:10]
}