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:
2026-03-09 19:58:05 +08:00
parent 73a71f198f
commit ac19d029da
20 changed files with 3341 additions and 1440 deletions

View File

@@ -1,15 +1,19 @@
# 数据库配置
DB_HOST=localhost
# ========== 数据库配置 ==========
USE_SQLITE=false
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_USER=cursorpro
DB_PASSWORD=your_db_password_here
DB_NAME=cursorpro
# JWT 配置
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# ========== JWT 配置 ==========
SECRET_KEY=change-this-to-a-random-secret-key
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080
# 管理员账号
# ========== 管理员账号 ==========
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=change-this-admin-password
# ========== 外部系统 API Token ==========
API_TOKEN=change-this-api-token

View File

@@ -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&not_expired_on=2024-12-04&extend_days=1
POST /admin/keys/batch-compensate?membership_type=auto&activated_before=2024-12-05&not_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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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" # 可用
"""账号状态"""
PENDING = "pending" # 待分析
ANALYZING = "analyzing" # 分析中
AVAILABLE = "available" # 可用
IN_USE = "in_use" # 使用中
DISABLED = "disabled" # 禁用
EXPIRED = "expired" # 过期
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())

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,18 +290,20 @@ 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:
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)
results.append({
"token": token[:20] + "...", # 脱敏
return {
"token": token[:20] + "...",
"is_valid": info.is_valid,
"is_usable": info.is_usable,
"pool_type": info.pool_type, # pro/auto
"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,
@@ -308,8 +311,9 @@ async def batch_check_accounts(tokens: List[str]) -> List[Dict[str, Any]]:
"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
}
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
View 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()

View File

@@ -0,0 +1,202 @@
-- 蜂鸟Pro v2.1 数据库迁移脚本
-- 从旧版本迁移到新的数据模型
-- ==================== cursor_accounts 表迁移 ====================
-- 1. 重命名 access_token 为 token (如果存在旧列名)
-- 注意: 如果已经是 token 列名则跳过
SET @exist_access_token := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'cursor_accounts'
AND COLUMN_NAME = 'access_token'
);
SET @sql = IF(@exist_access_token > 0,
'ALTER TABLE cursor_accounts CHANGE COLUMN access_token token TEXT NOT NULL COMMENT "认证Token (user_id::jwt)"',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 添加新列 (如果不存在)
-- account_type 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'account_type');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN account_type ENUM("free_trial", "pro", "free", "business", "unknown") DEFAULT "unknown" COMMENT "账号类型"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- membership_type 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'membership_type');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN membership_type VARCHAR(50) NULL COMMENT "会员类型原始值"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- billing_cycle_start 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'billing_cycle_start');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_start DATETIME NULL COMMENT "计费周期开始"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- billing_cycle_end 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'billing_cycle_end');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_end DATETIME NULL COMMENT "计费周期结束"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- trial_days_remaining 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'trial_days_remaining');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN trial_days_remaining INT DEFAULT 0 COMMENT "试用剩余天数"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- usage_limit 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_limit');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_limit INT DEFAULT 0 COMMENT "用量上限"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- usage_used 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_used');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_used INT DEFAULT 0 COMMENT "已用用量"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- usage_remaining 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_remaining');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_remaining INT DEFAULT 0 COMMENT "剩余用量"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- usage_percent 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'usage_percent');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN usage_percent DECIMAL(5,2) DEFAULT 0 COMMENT "用量百分比"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- total_requests 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_requests');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_requests INT DEFAULT 0 COMMENT "总请求次数"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- total_input_tokens 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_input_tokens');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_input_tokens BIGINT DEFAULT 0 COMMENT "总输入Token"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- total_output_tokens 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_output_tokens');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_output_tokens BIGINT DEFAULT 0 COMMENT "总输出Token"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- total_cost_cents 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'total_cost_cents');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN total_cost_cents DECIMAL(10,2) DEFAULT 0 COMMENT "总花费(美分)"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- locked_by_key_id 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'locked_by_key_id');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN locked_by_key_id INT NULL COMMENT "被哪个激活码锁定"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- locked_at 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'locked_at');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN locked_at DATETIME NULL COMMENT "锁定时间"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- last_analyzed_at 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'last_analyzed_at');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN last_analyzed_at DATETIME NULL COMMENT "最后分析时间"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- analyze_error 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cursor_accounts' AND COLUMN_NAME = 'analyze_error');
SET @sql = IF(@exist = 0, 'ALTER TABLE cursor_accounts ADD COLUMN analyze_error VARCHAR(500) NULL COMMENT "分析错误信息"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. 更新状态枚举值 (旧的 active -> available, expired -> exhausted)
UPDATE cursor_accounts SET status = 'available' WHERE status = 'active';
UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired';
-- 4. 修改 status 列的枚举类型
ALTER TABLE cursor_accounts MODIFY COLUMN status ENUM('pending', 'analyzing', 'available', 'in_use', 'exhausted', 'invalid', 'disabled') DEFAULT 'pending' COMMENT '账号状态';
-- 5. 添加索引
CREATE INDEX IF NOT EXISTS idx_cursor_accounts_status ON cursor_accounts(status);
CREATE INDEX IF NOT EXISTS idx_cursor_accounts_account_type ON cursor_accounts(account_type);
CREATE INDEX IF NOT EXISTS idx_cursor_accounts_locked_by_key_id ON cursor_accounts(locked_by_key_id);
-- ==================== activation_keys 表迁移 ====================
-- 添加新列 (如果不存在)
-- master_key_id 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'master_key_id');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN master_key_id INT NULL COMMENT "主密钥ID"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- merged_count 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'merged_count');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN merged_count INT DEFAULT 0 COMMENT "已合并的子密钥数量"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- merged_at 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'merged_at');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN merged_at DATETIME NULL COMMENT "合并时间"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- duration_days 列 (旧版可能是 valid_days)
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'duration_days');
SET @exist_valid_days := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'valid_days');
SET @sql = IF(@exist = 0 AND @exist_valid_days > 0,
'ALTER TABLE activation_keys CHANGE COLUMN valid_days duration_days INT DEFAULT 30 COMMENT "该密钥贡献的天数"',
IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN duration_days INT DEFAULT 30 COMMENT "该密钥贡献的天数"', 'SELECT 1')
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- quota_contribution 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'quota_contribution');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN quota_contribution INT DEFAULT 500 COMMENT "该密钥贡献的积分"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- seamless_enabled 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'seamless_enabled');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN seamless_enabled BOOLEAN DEFAULT FALSE COMMENT "是否启用无感换号"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- last_active_at 列
SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activation_keys' AND COLUMN_NAME = 'last_active_at');
SET @sql = IF(@exist = 0, 'ALTER TABLE activation_keys ADD COLUMN last_active_at DATETIME NULL COMMENT "最后活跃时间"', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 更新密钥状态枚举
ALTER TABLE activation_keys MODIFY COLUMN status ENUM('unused', 'active', 'expired', 'disabled') DEFAULT 'unused' COMMENT '状态';
-- 更新套餐类型枚举 (free -> auto)
UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free';
ALTER TABLE activation_keys MODIFY COLUMN membership_type ENUM('auto', 'pro') DEFAULT 'pro' COMMENT '套餐类型';
-- 添加索引
CREATE INDEX IF NOT EXISTS idx_activation_keys_master_key_id ON activation_keys(master_key_id);
CREATE INDEX IF NOT EXISTS idx_activation_keys_device_id ON activation_keys(device_id);
-- ==================== global_settings 表 - 添加自动检测开关设置 ====================
-- 确保 global_settings 表存在
CREATE TABLE IF NOT EXISTS global_settings (
id INT PRIMARY KEY AUTO_INCREMENT,
`key` VARCHAR(100) UNIQUE NOT NULL COMMENT '设置键',
value VARCHAR(500) NOT NULL COMMENT '设置值',
value_type VARCHAR(20) DEFAULT 'string' COMMENT '值类型',
description VARCHAR(500) NULL COMMENT '描述',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 添加自动检测开关设置
INSERT INTO global_settings (`key`, value, value_type, description) VALUES
('auto_analyze_enabled', 'false', 'bool', '是否启用自动账号分析'),
('auto_switch_enabled', 'true', 'bool', '是否启用自动换号'),
('account_analyze_interval', '300', 'int', '账号分析间隔(秒)'),
('account_analyze_batch_size', '10', 'int', '每批分析账号数量'),
('auto_switch_threshold', '98', 'int', 'Auto池自动换号阈值(用量百分比)'),
('pro_switch_threshold', '98', 'int', 'Pro池自动换号阈值(用量百分比)'),
('max_switch_per_day', '50', 'int', '每日最大换号次数'),
('auto_daily_switches', '999', 'int', 'Auto密钥每日换号次数限制'),
('pro_quota_per_switch', '1', 'int', 'Pro密钥每次换号消耗积分')
ON DUPLICATE KEY UPDATE description = VALUES(description);
-- ==================== 完成 ====================
SELECT '数据库迁移完成!' AS message;

View File

@@ -5,3 +5,7 @@ pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
aiosqlite==0.19.0
httpx==0.27.0
passlib[bcrypt]==1.7.4
apscheduler==3.10.4
pymysql==1.1.0

69
backend/run_migration.py Normal file
View File

@@ -0,0 +1,69 @@
"""
数据库迁移脚本
执行: python run_migration.py
"""
import os
import sys
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import text
from app.database import engine, Base
from app.models.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings
def run_migration():
"""执行数据库迁移"""
print("=" * 50)
print("蜂鸟Pro v2.1 数据库迁移")
print("=" * 50)
# 方式1: 使用 SQLAlchemy 自动创建/更新表结构
print("\n[1/3] 创建/更新表结构...")
try:
Base.metadata.create_all(bind=engine)
print("✓ 表结构更新完成")
except Exception as e:
print(f"✗ 表结构更新失败: {e}")
return False
# 方式2: 执行自定义迁移 SQL
print("\n[2/3] 执行数据迁移...")
migration_sqls = [
# 更新旧的状态值
"UPDATE cursor_accounts SET status = 'available' WHERE status = 'active'",
"UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired'",
# 更新旧的套餐类型
"UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free'",
]
with engine.connect() as conn:
for sql in migration_sqls:
try:
conn.execute(text(sql))
conn.commit()
print(f"{sql[:50]}...")
except Exception as e:
# 忽略不存在的列/值错误
if "Unknown column" not in str(e) and "Data truncated" not in str(e):
print(f"⚠ 跳过: {e}")
# 方式3: 初始化默认设置
print("\n[3/3] 初始化全局设置...")
try:
from sqlalchemy.orm import Session
with Session(engine) as db:
from app.services import GlobalSettingsService
GlobalSettingsService.init_settings(db)
print("✓ 全局设置初始化完成")
except Exception as e:
print(f"⚠ 设置初始化: {e}")
print("\n" + "=" * 50)
print("迁移完成!")
print("=" * 50)
print("\n现在可以启动后端: uvicorn app.main:app --reload")
return True
if __name__ == "__main__":
run_migration()

112
backend/run_migration_v2.py Normal file
View File

@@ -0,0 +1,112 @@
"""
数据库迁移脚本 v2 - 直接执行 ALTER TABLE
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import text
from app.database import engine
def run_migration():
print("=" * 50)
print("蜂鸟Pro v2.1 数据库迁移 (ALTER TABLE)")
print("=" * 50)
# 需要添加的列
alter_statements = [
# ===== cursor_accounts 表 =====
# 重命名 access_token -> token (如果存在)
("cursor_accounts", "ALTER TABLE cursor_accounts CHANGE COLUMN access_token token TEXT NOT NULL"),
# 添加新列
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN account_type VARCHAR(20) DEFAULT 'unknown'"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN membership_type VARCHAR(50) NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_start DATETIME NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN billing_cycle_end DATETIME NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN trial_days_remaining INT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_limit INT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_used INT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_remaining INT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN usage_percent DECIMAL(5,2) DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_requests INT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_input_tokens BIGINT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_output_tokens BIGINT DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN total_cost_cents DECIMAL(10,2) DEFAULT 0"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN locked_by_key_id INT NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN locked_at DATETIME NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN last_analyzed_at DATETIME NULL"),
("cursor_accounts", "ALTER TABLE cursor_accounts ADD COLUMN analyze_error VARCHAR(500) NULL"),
# ===== activation_keys 表 =====
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN master_key_id INT NULL"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN merged_count INT DEFAULT 0"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN merged_at DATETIME NULL"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN duration_days INT DEFAULT 30"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN quota_contribution INT DEFAULT 500"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN seamless_enabled BOOLEAN DEFAULT FALSE"),
("activation_keys", "ALTER TABLE activation_keys ADD COLUMN last_active_at DATETIME NULL"),
# ===== global_settings 表 =====
("global_settings", "ALTER TABLE global_settings ADD COLUMN value_type VARCHAR(20) DEFAULT 'string'"),
("global_settings", "ALTER TABLE global_settings ADD COLUMN description VARCHAR(500) NULL"),
]
print("\n[1/3] 添加缺失的列...")
with engine.connect() as conn:
for table, sql in alter_statements:
try:
conn.execute(text(sql))
conn.commit()
print(f"✓ [{table}] 成功")
except Exception as e:
err = str(e)
if "Duplicate column" in err or "Unknown column" in err:
print(f"⊘ [{table}] 跳过 (列已存在或源列不存在)")
else:
print(f"⚠ [{table}] {err[:60]}")
print("\n[2/3] 更新状态枚举值...")
update_sqls = [
"UPDATE cursor_accounts SET status = 'available' WHERE status = 'active'",
"UPDATE cursor_accounts SET status = 'exhausted' WHERE status = 'expired'",
"UPDATE cursor_accounts SET status = 'pending' WHERE status IS NULL OR status = ''",
"UPDATE activation_keys SET membership_type = 'auto' WHERE membership_type = 'free'",
]
with engine.connect() as conn:
for sql in update_sqls:
try:
conn.execute(text(sql))
conn.commit()
print(f"{sql[:50]}...")
except Exception as e:
print(f"⚠ 跳过: {str(e)[:50]}")
print("\n[3/3] 初始化全局设置...")
settings_sql = """
INSERT IGNORE INTO global_settings (`key`, value, value_type, description) VALUES
('key_max_devices', '2', 'int', '主密钥最大设备数'),
('auto_merge_enabled', 'true', 'bool', '是否启用同类型密钥自动合并'),
('auto_analyze_enabled', 'false', 'bool', '是否启用自动账号分析'),
('auto_switch_enabled', 'true', 'bool', '是否启用自动换号'),
('account_analyze_interval', '300', 'int', '账号分析间隔(秒)'),
('account_analyze_batch_size', '10', 'int', '每批分析账号数量'),
('auto_switch_threshold', '98', 'int', 'Auto池自动换号阈值'),
('pro_switch_threshold', '98', 'int', 'Pro池自动换号阈值'),
('max_switch_per_day', '50', 'int', '每日最大换号次数'),
('auto_daily_switches', '999', 'int', 'Auto密钥每日换号次数限制'),
('pro_quota_per_switch', '1', 'int', 'Pro密钥每次换号消耗积分')
"""
with engine.connect() as conn:
try:
conn.execute(text(settings_sql))
conn.commit()
print("✓ 全局设置初始化完成")
except Exception as e:
print(f"{e}")
print("\n" + "=" * 50)
print("迁移完成! 请重启后端服务")
print("=" * 50)
if __name__ == "__main__":
run_migration()

View File

@@ -88,6 +88,11 @@
class="px-3 py-2 font-medium text-sm rounded-md">
批量补偿
</button>
<button @click="currentTab = 'announcements'; loadAnnouncements()"
:class="currentTab === 'announcements' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
class="px-3 py-2 font-medium text-sm rounded-md">
公告管理
</button>
<button @click="currentTab = 'logs'; loadLogs()"
:class="currentTab === 'logs' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
class="px-3 py-2 font-medium text-sm rounded-md">
@@ -141,7 +146,7 @@
"access_token": "eyJhbG...",
"refresh_token": "xxx", // 可选
"workos_session_token": "xxx", // 可选
"membership_type": "free", // free=Auto账号, pro=高级账号
"membership_type": "free", // free=Free账号, pro=Pro账号
"remark": "备注" // 可选
}
],
@@ -174,7 +179,7 @@
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量导入
</button>
<button @click="showAccountModal = true; editingAccount = null"
<button @click="openCreateAccountModal"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
添加账号
</button>
@@ -207,7 +212,7 @@
<td class="px-6 py-4 whitespace-nowrap">
<span :class="account.membership_type === 'pro' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'"
class="px-2 py-1 text-xs font-medium rounded-full">
{{ account.membership_type.toUpperCase() }}
{{ formatMembershipLabel(account.membership_type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
@@ -233,6 +238,11 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ account.usage_count }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
<button @click="openAnalyzeModal(account)"
class="text-green-600 hover:text-green-900">
手动检测
</button>
<span class="text-gray-300">|</span>
<button @click="editAccount(account)" class="text-blue-600 hover:text-blue-900">编辑</button>
<button @click="deleteAccount(account.id)" class="text-red-600 hover:text-red-900">删除</button>
</td>
@@ -335,7 +345,7 @@
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">全部类型</option>
<option value="pro">Pro (高级模型)</option>
<option value="free">Auto (无限换号)</option>
<option value="auto">Auto (无限换号)</option>
</select>
<button @click="resetKeySearch" class="px-3 py-2 text-gray-600 hover:text-gray-800 text-sm">
重置
@@ -478,13 +488,13 @@
<h3 class="font-medium text-gray-700 border-b pb-2">Auto密钥 (无限换号)</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">换号最小间隔 (分钟)</label>
<input v-model.number="globalSettings.auto_switch_interval_minutes" type="number" min="1"
<input v-model.number="globalSettings.auto_switch_interval" type="number" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">两次换号之间至少等待的时间</p>
<p class="text-xs text-gray-500 mt-1">两次换号之间至少等待的时间0表示无限制</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">每天最大换号次数</label>
<input v-model.number="globalSettings.auto_max_switches_per_day" type="number" min="1"
<input v-model.number="globalSettings.auto_daily_switches" type="number" min="1"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">每天0点重置计数</p>
</div>
@@ -493,7 +503,7 @@
<h3 class="font-medium text-gray-700 border-b pb-2">Pro密钥 (高级模型)</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">每次换号扣除额度</label>
<input v-model.number="globalSettings.pro_quota_cost" type="number" min="1"
<input v-model.number="globalSettings.pro_quota_per_switch" type="number" min="1"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">例如50点/次500点总额度可换10次</p>
</div>
@@ -529,7 +539,7 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">全部</option>
<option value="pro">Pro (高级模型)</option>
<option value="free">Free (无限Auto)</option>
<option value="auto">Auto (无限换号)</option>
</select>
</div>
<div>
@@ -621,6 +631,58 @@
</div>
</div>
<!-- 公告管理 -->
<div v-if="currentTab === 'announcements'">
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">公告列表</h2>
<button @click="openCreateAnnouncementModal"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
发布公告
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">创建时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="ann in announcements" :key="ann.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ ann.title }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="announcementTypeClass(ann.type)"
class="px-2 py-1 text-xs font-medium rounded-full">
{{ announcementTypeLabel(ann.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<button @click="toggleAnnouncement(ann)"
:class="ann.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'"
class="px-2 py-1 text-xs font-medium rounded-full cursor-pointer hover:opacity-80 transition">
{{ ann.is_active ? '已启用' : '已禁用' }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(ann.created_at) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
<button @click="editAnnouncement(ann)" class="text-blue-600 hover:text-blue-900">编辑</button>
<button @click="deleteAnnouncement(ann.id)" class="text-red-600 hover:text-red-900">删除</button>
</td>
</tr>
<tr v-if="announcements.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-gray-500">暂无公告</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 使用日志 -->
<div v-if="currentTab === 'logs'">
<div class="bg-white rounded-lg shadow">
@@ -696,7 +758,7 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Access Token</label>
<textarea v-model="accountForm.access_token" required rows="3"
<textarea v-model="accountForm.access_token" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div>
@@ -710,12 +772,9 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">会员类型</label>
<select v-model="accountForm.membership_type"
<label class="block text-sm font-medium text-gray-700 mb-1">备注</label>
<input v-model="accountForm.remark" type="text" placeholder="来源、部门等"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="pro">Pro</option>
<option value="free">Free</option>
</select>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showAccountModal = false"
@@ -727,6 +786,76 @@
</div>
</div>
<!-- 手动检测弹窗 -->
<div v-if="showAnalyzeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">手动检测账号</h3>
<p class="text-sm text-gray-500 mt-1">可粘贴最新的 WorkosCursorSessionToken 或 user_xxx::jwt 用于调试</p>
<p class="text-sm text-gray-600 mt-2">
当前账号: <span class="font-mono text-blue-600">{{ analyzeTarget ? analyzeTarget.email : '' }}</span>
<span class="ml-4">状态: {{ getStatusText(analyzeTarget ? analyzeTarget.status : null) }}</span>
</p>
</div>
<button @click="closeAnalyzeModal" class="text-gray-400 hover:text-gray-600">
</button>
</div>
<div class="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid gap-3 md:grid-cols-3">
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Workos Session</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.workos_session_token ? analyzeTarget.workos_session_token : '未保存' }}
</p>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Access Token</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.access_token ? analyzeTarget.access_token : '未保存' }}
</p>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Refresh Token</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.refresh_token ? analyzeTarget.refresh_token : '未保存' }}
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">检测用 Token</label>
<textarea v-model="analyzeForm.token" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="WorkosCursorSessionToken=... 或 user_xxx::jwt"></textarea>
<p class="text-xs text-gray-500 mt-1">若留空,将使用系统当前保存的 Token</p>
</div>
<label class="flex items-center space-x-2 text-sm text-gray-700">
<input type="checkbox" v-model="analyzeForm.saveToken"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>检测成功后,将本次 Token 同步到账号</span>
</label>
<div v-if="analyzeResult" class="rounded-md border px-4 py-3"
:class="analyzeResult.success ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'">
<p class="font-medium" :class="analyzeResult.success ? 'text-green-700' : 'text-red-700'">
{{ analyzeResult.success ? '检测成功' : '检测失败' }}
</p>
<p v-if="analyzeResult.message" class="text-sm mt-1">{{ analyzeResult.message }}</p>
<pre v-if="analyzeResult.data" class="mt-3 text-xs bg-white border rounded p-2 overflow-auto max-h-48">{{ JSON.stringify(analyzeResult.data, null, 2) }}</pre>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
<button @click="closeAnalyzeModal"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
<button @click="submitAnalyze" :disabled="analyzeLoading"
:class="analyzeLoading ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-md">
{{ analyzeLoading ? '检测中...' : '开始检测' }}
</button>
</div>
</div>
</div>
<!-- 激活码编辑弹窗 -->
<div v-if="showKeyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
@@ -744,7 +873,7 @@
<select v-model="keyForm.membership_type"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="pro">Pro (高级模型) - 按额度计费</option>
<option value="free">Auto (无限换号) - 按时间计费</option>
<option value="auto">Auto (无限换号) - 按时间计费</option>
</select>
</div>
@@ -757,7 +886,7 @@
</div>
<!-- Auto密钥: 只设置有效天数 -->
<div v-if="keyForm.membership_type === 'free'">
<div v-if="keyForm.membership_type === 'auto'">
<label class="block text-sm font-medium text-gray-700 mb-1">有效天数</label>
<select v-model.number="keyForm.valid_days"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
@@ -958,6 +1087,49 @@
</div>
</div>
</div>
<!-- 公告编辑弹窗 -->
<div v-if="showAnnouncementModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">{{ editingAnnouncement ? '编辑公告' : '发布公告' }}</h3>
</div>
<form @submit.prevent="saveAnnouncement" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">标题</label>
<input v-model="announcementForm.title" type="text" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="公告标题">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">内容</label>
<textarea v-model="announcementForm.content" rows="5" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="公告内容,支持换行"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">类型</label>
<select v-model="announcementForm.type"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="info">信息 (info)</option>
<option value="warning">警告 (warning)</option>
<option value="error">错误 (error)</option>
<option value="success">成功 (success)</option>
</select>
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" v-model="announcementForm.is_active" id="ann-active"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="ann-active" class="text-sm text-gray-700">启用</label>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showAnnouncementModal = false"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">保存</button>
</div>
</form>
</div>
</div>
</div>
<script>
@@ -998,7 +1170,13 @@
// 表单
const loginForm = reactive({ username: '', password: '' })
const accountForm = reactive({ email: '', access_token: '', refresh_token: '', workos_session_token: '', membership_type: 'pro' })
const accountForm = reactive({
email: '',
access_token: '',
refresh_token: '',
workos_session_token: '',
remark: ''
})
const keyForm = reactive({
count: 1,
membership_type: 'pro',
@@ -1010,11 +1188,13 @@
// 弹窗
const showAccountModal = ref(false)
const showAnalyzeModal = ref(false)
const showKeyModal = ref(false)
const showImportModal = ref(false)
const showQuotaModal = ref(false)
const showExtendModal = ref(false)
const editingAccount = ref(null)
const analyzeTarget = ref(null)
const editingKey = ref(null)
const importData = ref('')
const importResult = ref(null)
@@ -1029,11 +1209,22 @@
const keyDevices = ref([])
const keyLogs = ref([])
// 公告管理
const announcements = ref([])
const showAnnouncementModal = ref(false)
const editingAnnouncement = ref(null)
const announcementForm = reactive({
title: '',
content: '',
type: 'info',
is_active: true
})
// 全局设置
const globalSettings = reactive({
auto_switch_interval_minutes: 20,
auto_max_switches_per_day: 50,
pro_quota_cost: 50
auto_switch_interval: 0,
auto_daily_switches: 999,
pro_quota_per_switch: 1
})
// 批量补偿
@@ -1120,23 +1311,47 @@
}
// 账号操作
const openCreateAccountModal = () => {
editingAccount.value = null
resetAccountForm()
showAccountModal.value = true
}
const editAccount = (account) => {
editingAccount.value = account
Object.assign(accountForm, account)
prepareAccountForm(account)
showAccountModal.value = true
}
const buildAccountPayload = () => {
const workosToken = accountForm.workos_session_token?.trim() || ''
const accessToken = accountForm.access_token?.trim() || ''
if (!workosToken && !accessToken) {
throw new Error('请至少填写 Access Token 或 Workos Token')
}
return {
email: accountForm.email,
token: workosToken || accessToken,
workos_session_token: workosToken || null,
access_token: accessToken || null,
refresh_token: accountForm.refresh_token?.trim() || null,
remark: accountForm.remark?.trim() || ''
}
}
const saveAccount = async () => {
try {
const payload = buildAccountPayload()
if (editingAccount.value) {
await api.put(`/accounts/${editingAccount.value.id}`, accountForm)
await api.put(`/accounts/${editingAccount.value.id}`, payload)
} else {
await api.post('/accounts', accountForm)
await api.post('/accounts', payload)
}
showAccountModal.value = false
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
const message = e.message || e.response?.data?.detail
alert(message || '保存失败')
}
}
@@ -1150,6 +1365,72 @@
}
}
// 手动分析单个账号
const analyzeForm = reactive({
token: '',
saveToken: false
})
const analyzeResult = ref(null)
const analyzeLoading = ref(false)
const resetAccountForm = () => {
accountForm.email = ''
accountForm.access_token = ''
accountForm.refresh_token = ''
accountForm.workos_session_token = ''
accountForm.remark = ''
}
const prepareAccountForm = (account) => {
accountForm.email = account.email || ''
accountForm.access_token = account.access_token || ''
accountForm.refresh_token = account.refresh_token || ''
accountForm.workos_session_token = account.workos_session_token || account.token || ''
accountForm.remark = account.remark || ''
}
const openAnalyzeModal = (account) => {
analyzeTarget.value = account
analyzeForm.token = account?.workos_session_token || account?.access_token || account?.token || ''
analyzeForm.saveToken = false
analyzeResult.value = null
showAnalyzeModal.value = true
}
const closeAnalyzeModal = () => {
showAnalyzeModal.value = false
analyzeTarget.value = null
analyzeForm.token = ''
analyzeForm.saveToken = false
analyzeResult.value = null
}
const submitAnalyze = async () => {
if (!analyzeTarget.value) return
analyzeLoading.value = true
analyzeResult.value = null
try {
const payload = {
save_token: analyzeForm.saveToken
}
if (analyzeForm.token?.trim()) {
payload.token = analyzeForm.token.trim()
}
const res = await api.post(`/accounts/${analyzeTarget.value.id}/analyze`, payload)
analyzeResult.value = res.data
if (res.data?.success) {
await loadAccounts()
}
} catch (e) {
analyzeResult.value = {
success: false,
message: e.response?.data?.detail || '分析失败'
}
} finally {
analyzeLoading.value = false
}
}
// 快捷切换账号状态
const toggleAccountStatus = async (account) => {
const statusMap = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
@@ -1545,6 +1826,82 @@
}
}
// 公告管理
const loadAnnouncements = async () => {
try {
const res = await api.get('/announcements')
announcements.value = res.data
} catch (e) {
console.error('加载公告失败', e)
}
}
const openCreateAnnouncementModal = () => {
editingAnnouncement.value = null
announcementForm.title = ''
announcementForm.content = ''
announcementForm.type = 'info'
announcementForm.is_active = true
showAnnouncementModal.value = true
}
const editAnnouncement = (ann) => {
editingAnnouncement.value = ann
announcementForm.title = ann.title
announcementForm.content = ann.content
announcementForm.type = ann.type
announcementForm.is_active = ann.is_active
showAnnouncementModal.value = true
}
const saveAnnouncement = async () => {
try {
if (editingAnnouncement.value) {
await api.put(`/announcements/${editingAnnouncement.value.id}`, announcementForm)
} else {
await api.post('/announcements', announcementForm)
}
showAnnouncementModal.value = false
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
}
}
const deleteAnnouncement = async (id) => {
if (!confirm('确定删除此公告?')) return
try {
await api.delete(`/announcements/${id}`)
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '删除失败')
}
}
const toggleAnnouncement = async (ann) => {
try {
await api.post(`/announcements/${ann.id}/toggle`)
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
const announcementTypeClass = (type) => {
const map = {
'info': 'bg-blue-100 text-blue-800',
'warning': 'bg-yellow-100 text-yellow-800',
'error': 'bg-red-100 text-red-800',
'success': 'bg-green-100 text-green-800'
}
return map[type] || 'bg-gray-100 text-gray-800'
}
const announcementTypeLabel = (type) => {
const map = { 'info': '信息', 'warning': '警告', 'error': '错误', 'success': '成功' }
return map[type] || type
}
// 辅助函数
const getStatusClass = (status) => {
const map = {
@@ -1556,6 +1913,11 @@
return map[status] || 'bg-gray-100 text-gray-800'
}
const formatMembershipLabel = (type) => {
if (!type) return '未知'
return String(type).toUpperCase()
}
const getStatusText = (status) => {
const map = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
return map[status] || status
@@ -1578,7 +1940,9 @@
// 监听标签页切换
watch(currentTab, (newTab) => {
if (newTab !== 'settings' && newTab !== 'compensate') {
if (newTab === 'announcements') {
loadAnnouncements()
} else if (newTab !== 'settings' && newTab !== 'compensate') {
loadData()
}
})
@@ -1589,24 +1953,29 @@
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
logs, logFilter, keySearch,
accountForm, keyForm,
showAccountModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
showAccountModal, showAnalyzeModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
editingAccount, editingKey,
editingAccount, editingKey, analyzeTarget,
importData, importResult,
quotaTarget, addQuotaAmount,
extendTarget, extendDays,
globalSettings,
compensateForm, compensatePreview, compensateResult,
announcements, showAnnouncementModal, editingAnnouncement, announcementForm,
loadAnnouncements, openCreateAnnouncementModal, editAnnouncement, saveAnnouncement,
deleteAnnouncement, toggleAnnouncement, announcementTypeClass, announcementTypeLabel,
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
toggleSelectAllKeys, batchCopyKeys, batchEnableKeys, batchDisableKeys, batchDeleteKeys,
editAccount, saveAccount, deleteAccount, importAccounts, toggleAccountStatus,
openCreateAccountModal, editAccount, saveAccount, deleteAccount, importAccounts, toggleAccountStatus,
openAnalyzeModal, closeAnalyzeModal, submitAnalyze,
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey, copyToken,
viewKeyDetail, deleteDevice, disableKey, enableKey,
extendKey, submitExtend,
analyzeForm, analyzeResult, analyzeLoading,
loadSettings, saveSettings,
previewCompensate, executeCompensate,
getStatusClass, getStatusText, formatDate
getStatusClass, getStatusText, formatDate, formatMembershipLabel
}
}
}).mount('#app')

View File

@@ -13,7 +13,7 @@ from app.services.cursor_usage_service import (
)
# 测试 Token (free_trial)
TEST_TOKEN = "user_01KCG2G9K4Q37C1PKTNR7EVNGW::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0NHMkc5SzRRMzdDMVBLVE5SN0VWTkdXIiwidGltZSI6IjE3NjU3ODc5NjYiLCJyYW5kb21uZXNzIjoiOTA1NTU4NjktYTlmMC00M2NhIiwiZXhwIjoxNzcwOTcxOTY2LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoic2Vzc2lvbiJ9.vreEnprZ7q9pU7b6TTVGQ0HUIQTJrxLXcnkz4Ne4Dng"
TEST_TOKEN = "user_01KD0CVVERZH4B04AEKP4QQDV6::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0QwQ1ZWRVJaSDRCMDRBRUtQNFFRRFY2IiwidGltZSI6IjE3NjYzMTg4MjIiLCJyYW5kb21uZXNzIjoiMjBlMzY0MDItZTY5Yi00ZmU1IiwiZXhwIjoxNzcxNTAyODIyLCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.rnqRVLK-iLFEUigNhiQI1loNjDWbhuGGDjeEUSxRAh0"
async def test_check_valid():