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"无法生成唯一激活码,请重试") db_key = ActivationKey( key=key_str, 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, 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): """首次激活:设置激活时间和过期时间""" 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) db.commit() @staticmethod def is_valid(key: ActivationKey, db: Session) -> Tuple[bool, str]: """检查激活码是否有效""" if key.status != KeyStatus.ACTIVE: return False, "激活码已禁用" # 检查是否已过期(只有激活后才检查) if key.first_activated_at and 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 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] }