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:
@@ -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
|
||||
|
||||
@@ -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¬_expired_on=2024-12-04&extend_days=1
|
||||
POST /admin/keys/batch-compensate?membership_type=auto&activated_before=2024-12-05¬_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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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" # 可用
|
||||
IN_USE = "in_use" # 使用中
|
||||
DISABLED = "disabled" # 禁用
|
||||
EXPIRED = "expired" # 过期
|
||||
"""账号状态"""
|
||||
PENDING = "pending" # 待分析
|
||||
ANALYZING = "analyzing" # 分析中
|
||||
AVAILABLE = "available" # 可用
|
||||
IN_USE = "in_use" # 使用中
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,27 +290,30 @@ 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:
|
||||
info = await cursor_usage_service.validate_and_get_usage(token)
|
||||
results.append({
|
||||
"token": token[:20] + "...", # 脱敏
|
||||
"is_valid": info.is_valid,
|
||||
"is_usable": info.is_usable,
|
||||
"pool_type": info.pool_type, # pro/auto
|
||||
"membership_type": info.membership_type if info.is_valid else None,
|
||||
"days_remaining_on_trial": info.days_remaining_on_trial,
|
||||
"plan_used": info.plan_used if info.is_valid else 0,
|
||||
"plan_limit": info.plan_limit if info.is_valid else 0,
|
||||
"plan_remaining": info.plan_remaining if info.is_valid else 0,
|
||||
"total_requests": info.total_requests if info.is_valid else 0,
|
||||
"error": info.error_message
|
||||
})
|
||||
return results
|
||||
semaphore = asyncio.Semaphore(max_concurrency)
|
||||
|
||||
async def _check_one(token: str) -> Dict[str, Any]:
|
||||
async with semaphore:
|
||||
info = await cursor_usage_service.validate_and_get_usage(token)
|
||||
return {
|
||||
"token": token[:20] + "...",
|
||||
"is_valid": info.is_valid,
|
||||
"is_usable": info.is_usable,
|
||||
"pool_type": info.pool_type,
|
||||
"membership_type": info.membership_type if info.is_valid else None,
|
||||
"days_remaining_on_trial": info.days_remaining_on_trial,
|
||||
"plan_used": info.plan_used if info.is_valid else 0,
|
||||
"plan_limit": info.plan_limit if info.is_valid else 0,
|
||||
"plan_remaining": info.plan_remaining if info.is_valid else 0,
|
||||
"total_requests": info.total_requests if info.is_valid else 0,
|
||||
"error": info.error_message
|
||||
}
|
||||
|
||||
return await asyncio.gather(*[_check_one(t) for t in tokens])
|
||||
|
||||
|
||||
async def check_and_classify_account(token: str) -> Dict[str, Any]:
|
||||
@@ -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
294
backend/app/tasks.py
Normal 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()
|
||||
202
backend/migrations/upgrade_v2.sql
Normal file
202
backend/migrations/upgrade_v2.sql
Normal 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;
|
||||
@@ -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
69
backend/run_migration.py
Normal 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
112
backend/run_migration_v2.py
Normal 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()
|
||||
@@ -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')
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user