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_PORT=3306
|
||||||
DB_USER=root
|
DB_USER=cursorpro
|
||||||
DB_PASSWORD=your_password
|
DB_PASSWORD=your_db_password_here
|
||||||
DB_NAME=cursorpro
|
DB_NAME=cursorpro
|
||||||
|
|
||||||
# JWT 配置
|
# ========== JWT 配置 ==========
|
||||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production
|
SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
JWT_ALGORITHM=HS256
|
ALGORITHM=HS256
|
||||||
JWT_EXPIRE_MINUTES=1440
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
# 管理员账号
|
# ========== 管理员账号 ==========
|
||||||
ADMIN_USERNAME=admin
|
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 datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services import AccountService, KeyService, LogService, GlobalSettingsService, BatchService, authenticate_admin, create_access_token, get_current_user
|
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,
|
AccountCreate, AccountUpdate, AccountResponse, AccountImport,
|
||||||
KeyCreate, KeyUpdate, KeyResponse,
|
KeyCreate, KeyUpdate, KeyResponse,
|
||||||
DashboardStats, Token, LoginRequest,
|
DashboardStats, Token, LoginRequest,
|
||||||
GlobalSettingsResponse, GlobalSettingsUpdate,
|
GlobalSettingsResponse, GlobalSettingsUpdate,
|
||||||
BatchExtendRequest, BatchExtendResponse,
|
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"])
|
router = APIRouter(prefix="/admin", tags=["Admin API"])
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAnalyzeRequest(BaseModel):
|
||||||
|
"""手动分析账号请求"""
|
||||||
|
token: Optional[str] = None
|
||||||
|
save_token: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ========== 认证 ==========
|
# ========== 认证 ==========
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
@@ -79,8 +87,8 @@ async def external_batch_upload(
|
|||||||
|
|
||||||
for item in data.accounts:
|
for item in data.accounts:
|
||||||
try:
|
try:
|
||||||
# 转换membership_type
|
# 转换membership_type (free/auto -> AUTO, pro -> PRO)
|
||||||
mt = MembershipType.FREE if item.membership_type == "free" else MembershipType.PRO
|
# 注意:mt 变量暂未使用,因为 CursorAccount 模型中 membership_type 是从 Cursor API 分析得出的
|
||||||
|
|
||||||
existing = AccountService.get_by_email(db, item.email)
|
existing = AccountService.get_by_email(db, item.email)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -88,10 +96,10 @@ async def external_batch_upload(
|
|||||||
# 更新已存在的账号
|
# 更新已存在的账号
|
||||||
AccountService.update(
|
AccountService.update(
|
||||||
db, existing.id,
|
db, existing.id,
|
||||||
|
token=item.workos_session_token or item.access_token,
|
||||||
access_token=item.access_token,
|
access_token=item.access_token,
|
||||||
refresh_token=item.refresh_token,
|
refresh_token=item.refresh_token,
|
||||||
workos_session_token=item.workos_session_token,
|
workos_session_token=item.workos_session_token,
|
||||||
membership_type=mt,
|
|
||||||
remark=item.remark or existing.remark
|
remark=item.remark or existing.remark
|
||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
@@ -100,15 +108,16 @@ async def external_batch_upload(
|
|||||||
errors.append(f"{item.email}: 账号已存在")
|
errors.append(f"{item.email}: 账号已存在")
|
||||||
else:
|
else:
|
||||||
# 创建新账号
|
# 创建新账号
|
||||||
account_data = AccountCreate(
|
AccountService.create(
|
||||||
|
db,
|
||||||
email=item.email,
|
email=item.email,
|
||||||
|
token=item.workos_session_token or item.access_token,
|
||||||
access_token=item.access_token,
|
access_token=item.access_token,
|
||||||
refresh_token=item.refresh_token,
|
refresh_token=item.refresh_token,
|
||||||
workos_session_token=item.workos_session_token,
|
workos_session_token=item.workos_session_token,
|
||||||
membership_type=mt,
|
password=None,
|
||||||
remark=item.remark
|
remark=item.remark
|
||||||
)
|
)
|
||||||
AccountService.create(db, account_data)
|
|
||||||
created += 1
|
created += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -131,11 +140,12 @@ async def external_account_stats(
|
|||||||
):
|
):
|
||||||
"""外部系统获取账号统计"""
|
"""外部系统获取账号统计"""
|
||||||
stats = AccountService.count(db)
|
stats = AccountService.count(db)
|
||||||
|
pro_count = db.query(CursorAccount).filter(CursorAccount.account_type == AccountType.PRO).count()
|
||||||
return {
|
return {
|
||||||
"total": stats["total"],
|
"total": stats["total"],
|
||||||
"active": stats["active"],
|
"active": stats["available"] + stats["in_use"],
|
||||||
"pro": stats["pro"],
|
"pro": pro_count,
|
||||||
"free": stats["total"] - stats["pro"]
|
"free": stats["total"] - pro_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -181,8 +191,8 @@ async def get_dashboard(
|
|||||||
|
|
||||||
return DashboardStats(
|
return DashboardStats(
|
||||||
total_accounts=account_stats["total"],
|
total_accounts=account_stats["total"],
|
||||||
active_accounts=account_stats["active"],
|
active_accounts=account_stats["available"] + account_stats["in_use"], # 可用+使用中
|
||||||
pro_accounts=account_stats["pro"],
|
pro_accounts=key_stats["pro"], # Pro密钥数量
|
||||||
total_keys=key_stats["total"],
|
total_keys=key_stats["total"],
|
||||||
active_keys=key_stats["active"],
|
active_keys=key_stats["active"],
|
||||||
today_usage=today_usage
|
today_usage=today_usage
|
||||||
@@ -212,7 +222,16 @@ async def create_account(
|
|||||||
existing = AccountService.get_by_email(db, account.email)
|
existing = AccountService.get_by_email(db, account.email)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="邮箱已存在")
|
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)
|
@router.post("/accounts/import", response_model=dict)
|
||||||
@@ -233,13 +252,24 @@ async def import_accounts(
|
|||||||
# 更新已存在的账号
|
# 更新已存在的账号
|
||||||
AccountService.update(
|
AccountService.update(
|
||||||
db, existing.id,
|
db, existing.id,
|
||||||
|
token=account.token or account.workos_session_token or account.access_token,
|
||||||
access_token=account.access_token,
|
access_token=account.access_token,
|
||||||
refresh_token=account.refresh_token,
|
refresh_token=account.refresh_token,
|
||||||
workos_session_token=account.workos_session_token,
|
workos_session_token=account.workos_session_token,
|
||||||
membership_type=account.membership_type
|
password=account.password,
|
||||||
|
remark=account.remark
|
||||||
)
|
)
|
||||||
else:
|
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
|
success += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -315,8 +345,7 @@ async def toggle_account_status(
|
|||||||
- 禁用(disabled) -> 可用(active)
|
- 禁用(disabled) -> 可用(active)
|
||||||
- 过期(expired) -> 可用(active)
|
- 过期(expired) -> 可用(active)
|
||||||
"""
|
"""
|
||||||
from app.models import AccountStatus, Account
|
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
|
||||||
account = db.query(Account).filter(Account.id == account_id).first()
|
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
|
|
||||||
@@ -324,14 +353,15 @@ async def toggle_account_status(
|
|||||||
|
|
||||||
# 根据当前状态切换
|
# 根据当前状态切换
|
||||||
if account.status == AccountStatus.IN_USE:
|
if account.status == AccountStatus.IN_USE:
|
||||||
account.status = AccountStatus.ACTIVE
|
account.status = AccountStatus.AVAILABLE
|
||||||
account.current_key_id = None # 释放绑定
|
account.locked_by_key_id = None # 释放绑定
|
||||||
elif account.status == AccountStatus.ACTIVE:
|
account.locked_at = None
|
||||||
|
elif account.status == AccountStatus.AVAILABLE:
|
||||||
account.status = AccountStatus.DISABLED
|
account.status = AccountStatus.DISABLED
|
||||||
elif account.status == AccountStatus.DISABLED:
|
elif account.status == AccountStatus.DISABLED:
|
||||||
account.status = AccountStatus.ACTIVE
|
account.status = AccountStatus.AVAILABLE
|
||||||
elif account.status == AccountStatus.EXPIRED:
|
elif account.status == AccountStatus.EXHAUSTED:
|
||||||
account.status = AccountStatus.ACTIVE
|
account.status = AccountStatus.AVAILABLE
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -350,16 +380,16 @@ async def release_account(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""释放账号(从使用中变为可用)"""
|
"""释放账号(从使用中变为可用)"""
|
||||||
from app.models import AccountStatus, Account
|
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
|
||||||
account = db.query(Account).filter(Account.id == account_id).first()
|
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
|
|
||||||
if account.status != AccountStatus.IN_USE:
|
if account.status != AccountStatus.IN_USE:
|
||||||
return {"success": False, "message": "账号不在使用中状态"}
|
return {"success": False, "message": "账号不在使用中状态"}
|
||||||
|
|
||||||
account.status = AccountStatus.ACTIVE
|
account.status = AccountStatus.AVAILABLE
|
||||||
account.current_key_id = None
|
account.locked_by_key_id = None
|
||||||
|
account.locked_at = None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"success": True, "message": "账号已释放"}
|
return {"success": True, "message": "账号已释放"}
|
||||||
@@ -372,15 +402,14 @@ async def batch_enable_accounts(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""批量启用账号"""
|
"""批量启用账号"""
|
||||||
from app.models import AccountStatus, Account
|
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
for account_id in account_ids:
|
for account_id in account_ids:
|
||||||
try:
|
try:
|
||||||
account = db.query(Account).filter(Account.id == account_id).first()
|
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
|
||||||
if account:
|
if account:
|
||||||
account.status = AccountStatus.ACTIVE
|
account.status = AccountStatus.AVAILABLE
|
||||||
success += 1
|
success += 1
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -402,13 +431,12 @@ async def batch_disable_accounts(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""批量禁用账号"""
|
"""批量禁用账号"""
|
||||||
from app.models import AccountStatus, Account
|
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
for account_id in account_ids:
|
for account_id in account_ids:
|
||||||
try:
|
try:
|
||||||
account = db.query(Account).filter(Account.id == account_id).first()
|
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
|
||||||
if account:
|
if account:
|
||||||
account.status = AccountStatus.DISABLED
|
account.status = AccountStatus.DISABLED
|
||||||
success += 1
|
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)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""获取激活码列表(支持搜索和筛选)"""
|
"""获取激活码列表(支持搜索和筛选)"""
|
||||||
from app.models import KeyStatus
|
|
||||||
query = db.query(ActivationKey).order_by(ActivationKey.id.desc())
|
query = db.query(ActivationKey).order_by(ActivationKey.id.desc())
|
||||||
|
|
||||||
# 搜索激活码
|
# 搜索激活码
|
||||||
@@ -493,9 +511,9 @@ async def list_keys(
|
|||||||
elif activated and activated == "false":
|
elif activated and activated == "false":
|
||||||
query = query.filter(ActivationKey.first_activated_at == None)
|
query = query.filter(ActivationKey.first_activated_at == None)
|
||||||
|
|
||||||
# 套餐类型筛选
|
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
|
||||||
if membership_type:
|
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)
|
query = query.filter(ActivationKey.membership_type == mt)
|
||||||
|
|
||||||
return query.offset(skip).limit(limit).all()
|
return query.offset(skip).limit(limit).all()
|
||||||
@@ -511,7 +529,6 @@ async def get_keys_count(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""获取激活码总数(支持筛选)"""
|
"""获取激活码总数(支持筛选)"""
|
||||||
from app.models import KeyStatus
|
|
||||||
query = db.query(ActivationKey)
|
query = db.query(ActivationKey)
|
||||||
|
|
||||||
# 搜索激活码
|
# 搜索激活码
|
||||||
@@ -529,9 +546,9 @@ async def get_keys_count(
|
|||||||
elif activated and activated == "false":
|
elif activated and activated == "false":
|
||||||
query = query.filter(ActivationKey.first_activated_at == None)
|
query = query.filter(ActivationKey.first_activated_at == None)
|
||||||
|
|
||||||
# 套餐类型筛选
|
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
|
||||||
if membership_type:
|
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)
|
query = query.filter(ActivationKey.membership_type == mt)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
@@ -545,7 +562,15 @@ async def create_keys(
|
|||||||
current_user: dict = Depends(get_current_user)
|
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)
|
@router.get("/keys/{key_id}", response_model=KeyResponse)
|
||||||
@@ -735,7 +760,6 @@ async def disable_key(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""禁用激活码(返回使用信息供客服参考)"""
|
"""禁用激活码(返回使用信息供客服参考)"""
|
||||||
from app.models import KeyStatus
|
|
||||||
key = KeyService.get_by_id(db, key_id)
|
key = KeyService.get_by_id(db, key_id)
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="激活码不存在")
|
raise HTTPException(status_code=404, detail="激活码不存在")
|
||||||
@@ -770,7 +794,6 @@ async def enable_key(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""启用激活码"""
|
"""启用激活码"""
|
||||||
from app.models import KeyStatus
|
|
||||||
key = KeyService.get_by_id(db, key_id)
|
key = KeyService.get_by_id(db, key_id)
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="激活码不存在")
|
raise HTTPException(status_code=404, detail="激活码不存在")
|
||||||
@@ -788,7 +811,6 @@ async def batch_enable_keys(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""批量启用激活码"""
|
"""批量启用激活码"""
|
||||||
from app.models import KeyStatus
|
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
@@ -818,7 +840,6 @@ async def batch_disable_keys(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""批量禁用激活码"""
|
"""批量禁用激活码"""
|
||||||
from app.models import KeyStatus
|
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
@@ -877,7 +898,6 @@ async def get_keys_count(
|
|||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""获取激活码总数(支持筛选)"""
|
"""获取激活码总数(支持筛选)"""
|
||||||
from app.models import KeyStatus
|
|
||||||
query = db.query(ActivationKey)
|
query = db.query(ActivationKey)
|
||||||
|
|
||||||
# 搜索激活码
|
# 搜索激活码
|
||||||
@@ -895,9 +915,9 @@ async def get_keys_count(
|
|||||||
elif activated and activated == "false":
|
elif activated and activated == "false":
|
||||||
query = query.filter(ActivationKey.first_activated_at == None)
|
query = query.filter(ActivationKey.first_activated_at == None)
|
||||||
|
|
||||||
# 套餐类型筛选
|
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
|
||||||
if membership_type:
|
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)
|
query = query.filter(ActivationKey.membership_type == mt)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
@@ -962,7 +982,7 @@ async def batch_extend_keys(
|
|||||||
|
|
||||||
@router.post("/keys/batch-compensate")
|
@router.post("/keys/batch-compensate")
|
||||||
async def 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)"),
|
activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"),
|
||||||
not_expired_on: 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="延长天数"),
|
extend_days: int = Query(0, description="延长天数"),
|
||||||
@@ -981,11 +1001,11 @@ async def batch_compensate(
|
|||||||
- 如果卡已过期(但符合补偿条件):恢复使用,expire_at = 今天 + extend_days
|
- 如果卡已过期(但符合补偿条件):恢复使用,expire_at = 今天 + extend_days
|
||||||
|
|
||||||
例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥,延长1天
|
例如: 补偿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
|
mt = None
|
||||||
if membership_type:
|
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
|
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
|
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")
|
@router.get("/keys/preview-compensate")
|
||||||
async def 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)"),
|
activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"),
|
||||||
not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"),
|
not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -1012,7 +1032,7 @@ async def preview_compensate(
|
|||||||
"""预览补偿 - 查看符合条件的密钥数量(不执行)"""
|
"""预览补偿 - 查看符合条件的密钥数量(不执行)"""
|
||||||
mt = None
|
mt = None
|
||||||
if membership_type:
|
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
|
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
|
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,
|
"message": log.message,
|
||||||
"created_at": log.created_at.strftime("%Y-%m-%d %H:%M:%S") if log.created_at else None
|
"created_at": log.created_at.strftime("%Y-%m-%d %H:%M:%S") if log.created_at else None
|
||||||
} for log in logs]
|
} 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_HOST: str = "127.0.0.1"
|
||||||
DB_PORT: int = 3306
|
DB_PORT: int = 3306
|
||||||
DB_USER: str = "cursorpro"
|
DB_USER: str = "cursorpro"
|
||||||
DB_PASSWORD: str = "jf6BntYBPz6KH6Pw"
|
DB_PASSWORD: str = ""
|
||||||
DB_NAME: str = "cursorpro"
|
DB_NAME: str = "cursorpro"
|
||||||
|
|
||||||
# JWT配置
|
# JWT配置
|
||||||
SECRET_KEY: str = "hb8x2kF9mNpQ3rT7vY1zA4cE6gJ0lO5sU8wB2dH4"
|
SECRET_KEY: str = "" # Must be set via .env
|
||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||||
|
|
||||||
# 管理员账号
|
# 管理员账号
|
||||||
ADMIN_USERNAME: str = "admin"
|
ADMIN_USERNAME: str = "admin"
|
||||||
ADMIN_PASSWORD: str = "Hb@2024Pro!"
|
ADMIN_PASSWORD: str = ""
|
||||||
|
|
||||||
# 外部系统API Token (用于批量上传账号等)
|
# 外部系统API Token (用于批量上传账号等)
|
||||||
API_TOKEN: str = "hb-ext-9kX2mP5nQ8rT1vY4zA7c"
|
API_TOKEN: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DATABASE_URL(self) -> str:
|
def DATABASE_URL(self) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# SQLite 不支持某些连接池选项
|
# SQLite 不支持某些连接池选项
|
||||||
@@ -19,7 +18,8 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
@@ -4,23 +4,40 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
from app.api import client_router, admin_router
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# 启动时创建数据库表
|
# 启动时创建数据库表
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# 启动后台任务调度器
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
|
# 运行启动任务
|
||||||
|
await run_startup_tasks()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# 关闭时清理
|
|
||||||
|
# 关闭时停止调度器
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="CursorPro 管理后台",
|
title="蜂鸟Pro 管理后台",
|
||||||
description="Cursor 账号管理系统 API",
|
description="蜂鸟Pro 账号管理系统 API v2.1",
|
||||||
version="1.0.0",
|
version="2.1.0",
|
||||||
lifespan=lifespan
|
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.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
class MembershipType(str, enum.Enum):
|
|
||||||
FREE = "free"
|
# ==================== 枚举类型 ====================
|
||||||
PRO = "pro"
|
|
||||||
|
|
||||||
class AccountStatus(str, enum.Enum):
|
class AccountStatus(str, enum.Enum):
|
||||||
ACTIVE = "active" # 可用
|
"""账号状态"""
|
||||||
IN_USE = "in_use" # 使用中
|
PENDING = "pending" # 待分析
|
||||||
DISABLED = "disabled" # 禁用
|
ANALYZING = "analyzing" # 分析中
|
||||||
EXPIRED = "expired" # 过期
|
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):
|
class KeyStatus(str, enum.Enum):
|
||||||
|
"""密钥状态"""
|
||||||
UNUSED = "unused" # 未使用
|
UNUSED = "unused" # 未使用
|
||||||
ACTIVE = "active" # 已激活(主密钥)
|
ACTIVE = "active" # 已激活
|
||||||
MERGED = "merged" # 已合并到主密钥
|
EXPIRED = "expired" # 已过期
|
||||||
REVOKED = "revoked" # 已撤销
|
DISABLED = "disabled" # 已禁用
|
||||||
DISABLED = "disabled" # 禁用
|
|
||||||
EXPIRED = "expired" # 过期
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 数据模型 ====================
|
||||||
|
|
||||||
class CursorAccount(Base):
|
class CursorAccount(Base):
|
||||||
"""Cursor 账号池"""
|
"""
|
||||||
|
Cursor 账号表
|
||||||
|
存储从Cursor API获取的账号信息和用量数据
|
||||||
|
"""
|
||||||
__tablename__ = "cursor_accounts"
|
__tablename__ = "cursor_accounts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
email = Column(String(255), unique=True, nullable=False, comment="邮箱")
|
email = Column(String(255), nullable=False, comment="账号邮箱")
|
||||||
access_token = Column(Text, nullable=False, comment="访问令牌")
|
token = Column(Text, nullable=False, comment="认证Token (user_id::jwt)")
|
||||||
refresh_token = Column(Text, nullable=True, comment="刷新令牌")
|
password = Column(String(255), nullable=True, comment="账号密码(可选)")
|
||||||
workos_session_token = Column(Text, nullable=True, comment="WorkOS会话令牌")
|
access_token = Column(Text, nullable=True, comment="Access Token (GraphQL/API)")
|
||||||
membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="会员类型")
|
refresh_token = Column(Text, nullable=True, comment="Refresh Token")
|
||||||
status = Column(Enum(AccountStatus), default=AccountStatus.ACTIVE, comment="状态")
|
workos_session_token = Column(Text, nullable=True, comment="Workos Session Token")
|
||||||
|
|
||||||
# 使用统计
|
# 状态管理
|
||||||
usage_count = Column(Integer, default=0, comment="使用次数")
|
status = Column(
|
||||||
last_used_at = Column(DateTime, nullable=True, comment="最后使用时间")
|
Enum(AccountStatus),
|
||||||
current_key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=True, comment="当前使用的激活码")
|
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="备注")
|
remark = Column(String(500), nullable=True, comment="备注")
|
||||||
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=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):
|
class ActivationKey(Base):
|
||||||
"""激活码"""
|
"""
|
||||||
|
激活码表
|
||||||
|
支持Auto/Pro双池,密钥合并,无感换号
|
||||||
|
"""
|
||||||
__tablename__ = "activation_keys"
|
__tablename__ = "activation_keys"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
key = Column(String(64), unique=True, nullable=False, index=True, comment="激活码")
|
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")
|
device_id = Column(String(255), nullable=True, index=True, comment="绑定的设备ID")
|
||||||
|
|
||||||
# 该密钥贡献的资源 (创建时设置,不变)
|
# ===== Auto密钥专属字段 =====
|
||||||
duration_days = Column(Integer, default=30, comment="Auto: 该密钥贡献的天数")
|
duration_days = Column(Integer, default=30, comment="该密钥贡献的天数")
|
||||||
quota_contribution = Column(Integer, default=500, comment="Pro: 该密钥贡献的积分")
|
expire_at = Column(DateTime, nullable=True, comment="到期时间 (首次激活时计算)")
|
||||||
|
|
||||||
# 额度系统 (仅主密钥使用,累计值)
|
# ===== Pro密钥专属字段 =====
|
||||||
quota = Column(Integer, default=500, comment="Pro主密钥: 总额度(累加)")
|
quota_contribution = Column(Integer, default=500, comment="该密钥贡献的积分")
|
||||||
quota_used = Column(Integer, default=0, comment="Pro主密钥: 已用额度")
|
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="首次激活时间")
|
first_activated_at = Column(DateTime, nullable=True, comment="首次激活时间")
|
||||||
merged_at = Column(DateTime, nullable=True, comment="合并时间")
|
last_active_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="已合并的密钥数量")
|
|
||||||
|
|
||||||
# 备注
|
# 备注
|
||||||
remark = Column(String(500), 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])
|
master_key = relationship("ActivationKey", remote_side=[id], foreign_keys=[master_key_id])
|
||||||
|
current_account = relationship("CursorAccount", foreign_keys=[current_account_id])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def valid_days(self):
|
def valid_days(self):
|
||||||
"""兼容旧API: duration_days的别名"""
|
"""兼容旧API: duration_days的别名"""
|
||||||
return self.duration_days or 0
|
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):
|
class KeyDevice(Base):
|
||||||
"""激活码绑定的设备"""
|
"""
|
||||||
|
设备绑定表
|
||||||
|
记录激活码绑定的所有设备
|
||||||
|
"""
|
||||||
__tablename__ = "key_devices"
|
__tablename__ = "key_devices"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False)
|
key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False)
|
||||||
device_id = Column(String(255), nullable=False, comment="设备标识")
|
device_id = Column(String(255), nullable=False, comment="设备标识")
|
||||||
device_name = Column(String(255), nullable=True, 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="最后活跃时间")
|
last_active_at = Column(DateTime, nullable=True, comment="最后活跃时间")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# 关系
|
||||||
key = relationship("ActivationKey")
|
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):
|
class GlobalSettings(Base):
|
||||||
"""全局设置"""
|
"""
|
||||||
|
全局设置表
|
||||||
|
存储系统配置
|
||||||
|
"""
|
||||||
__tablename__ = "global_settings"
|
__tablename__ = "global_settings"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
key = Column(String(100), unique=True, nullable=False, comment="设置键")
|
key = Column(String(100), unique=True, nullable=False, comment="设置键")
|
||||||
value = Column(String(500), 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="描述")
|
description = Column(String(500), nullable=True, comment="描述")
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
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):
|
|
||||||
"""使用日志"""
|
class Announcement(Base):
|
||||||
__tablename__ = "usage_logs"
|
"""
|
||||||
|
公告表
|
||||||
|
管理员发布的系统公告
|
||||||
|
"""
|
||||||
|
__tablename__ = "announcements"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False)
|
title = Column(String(200), nullable=False, comment="公告标题")
|
||||||
account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True)
|
content = Column(Text, nullable=False, comment="公告内容")
|
||||||
action = Column(String(50), nullable=False, comment="操作类型: verify/switch/seamless")
|
type = Column(String(20), default="info", comment="公告类型: info/warning/error/success")
|
||||||
ip_address = Column(String(50), nullable=True)
|
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||||
user_agent = Column(String(500), nullable=True)
|
|
||||||
success = Column(Boolean, default=True)
|
|
||||||
message = Column(String(500), nullable=True)
|
|
||||||
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
key = relationship("ActivationKey")
|
|
||||||
account = relationship("CursorAccount")
|
|
||||||
|
|||||||
@@ -1,37 +1,67 @@
|
|||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, Field, model_validator
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.models.models import MembershipType, AccountStatus, KeyStatus
|
from app.models.models import KeyMembershipType, AccountStatus, KeyStatus
|
||||||
|
|
||||||
|
|
||||||
# ========== 账号相关 ==========
|
# ========== 账号相关 ==========
|
||||||
|
|
||||||
class AccountBase(BaseModel):
|
class AccountBase(BaseModel):
|
||||||
|
"""账号基础信息 (用于创建/更新)"""
|
||||||
email: str
|
email: str
|
||||||
access_token: str
|
token: Optional[str] = Field(None, description="兼容旧字段: user_id::jwt")
|
||||||
refresh_token: Optional[str] = None
|
access_token: Optional[str] = Field(None, description="Access Token")
|
||||||
workos_session_token: Optional[str] = None
|
refresh_token: Optional[str] = Field(None, description="Refresh Token")
|
||||||
membership_type: MembershipType = MembershipType.PRO
|
workos_session_token: Optional[str] = Field(None, description="WorkosCursorSessionToken")
|
||||||
|
password: Optional[str] = None
|
||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
|
|
||||||
class AccountCreate(AccountBase):
|
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):
|
class AccountUpdate(BaseModel):
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
token: Optional[str] = None
|
||||||
access_token: Optional[str] = None
|
access_token: Optional[str] = None
|
||||||
refresh_token: Optional[str] = None
|
refresh_token: Optional[str] = None
|
||||||
workos_session_token: Optional[str] = None
|
workos_session_token: Optional[str] = None
|
||||||
membership_type: Optional[MembershipType] = None
|
password: Optional[str] = None
|
||||||
status: Optional[AccountStatus] = None
|
status: Optional[AccountStatus] = None
|
||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
|
|
||||||
class AccountResponse(AccountBase):
|
class AccountResponse(BaseModel):
|
||||||
|
"""账号响应 (匹配 CursorAccount 模型)"""
|
||||||
id: int
|
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
|
status: AccountStatus
|
||||||
usage_count: int
|
account_type: Optional[str] = None
|
||||||
last_used_at: Optional[datetime] = None
|
membership_type: Optional[str] = None
|
||||||
current_key_id: Optional[int] = 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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -51,7 +81,7 @@ class ExternalAccountItem(BaseModel):
|
|||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: Optional[str] = None
|
refresh_token: Optional[str] = None
|
||||||
workos_session_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
|
remark: Optional[str] = None
|
||||||
|
|
||||||
class ExternalBatchUpload(BaseModel):
|
class ExternalBatchUpload(BaseModel):
|
||||||
@@ -72,7 +102,7 @@ class ExternalBatchResponse(BaseModel):
|
|||||||
# ========== 激活码相关 ==========
|
# ========== 激活码相关 ==========
|
||||||
|
|
||||||
class KeyBase(BaseModel):
|
class KeyBase(BaseModel):
|
||||||
membership_type: MembershipType = MembershipType.PRO # pro=高级模型, free=无限auto
|
membership_type: KeyMembershipType = KeyMembershipType.PRO # pro=高级模型, auto=无限换号
|
||||||
quota: int = 500 # 总额度 (仅Pro有效)
|
quota: int = 500 # 总额度 (仅Pro有效)
|
||||||
valid_days: int = 30 # 有效天数,0表示永久 (仅Auto有效)
|
valid_days: int = 30 # 有效天数,0表示永久 (仅Auto有效)
|
||||||
max_devices: int = 2 # 最大设备数
|
max_devices: int = 2 # 最大设备数
|
||||||
@@ -83,7 +113,7 @@ class KeyCreate(KeyBase):
|
|||||||
count: int = 1 # 批量生成数量
|
count: int = 1 # 批量生成数量
|
||||||
|
|
||||||
class KeyUpdate(BaseModel):
|
class KeyUpdate(BaseModel):
|
||||||
membership_type: Optional[MembershipType] = None
|
membership_type: Optional[KeyMembershipType] = None
|
||||||
quota: Optional[int] = None
|
quota: Optional[int] = None
|
||||||
valid_days: Optional[int] = None
|
valid_days: Optional[int] = None
|
||||||
max_devices: Optional[int] = None
|
max_devices: Optional[int] = None
|
||||||
@@ -98,15 +128,15 @@ class KeyResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
status: KeyStatus
|
status: KeyStatus
|
||||||
membership_type: MembershipType
|
membership_type: KeyMembershipType
|
||||||
quota: int
|
quota: int
|
||||||
quota_used: int
|
quota_used: int
|
||||||
quota_remaining: Optional[int] = None # 剩余额度(计算字段)
|
quota_remaining: Optional[int] = None # 剩余额度(计算字段)
|
||||||
valid_days: int
|
valid_days: int = 30 # 有效天数 (映射自 duration_days)
|
||||||
first_activated_at: Optional[datetime] = None
|
first_activated_at: Optional[datetime] = None
|
||||||
expire_at: Optional[datetime] = None
|
expire_at: Optional[datetime] = None
|
||||||
max_devices: int
|
max_devices: int
|
||||||
switch_count: int
|
switch_count: int = 0
|
||||||
last_switch_at: Optional[datetime] = None
|
last_switch_at: Optional[datetime] = None
|
||||||
current_account_id: Optional[int] = None
|
current_account_id: Optional[int] = None
|
||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
@@ -184,17 +214,43 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
class GlobalSettingsResponse(BaseModel):
|
class GlobalSettingsResponse(BaseModel):
|
||||||
"""全局设置响应"""
|
"""全局设置响应"""
|
||||||
# Auto密钥设置
|
# ===== 密钥策略 =====
|
||||||
auto_switch_interval_minutes: int = 20 # 换号最小间隔(分钟)
|
key_max_devices: int = 2 # 主密钥最大设备数
|
||||||
auto_max_switches_per_day: int = 50 # 每天最大换号次数
|
auto_merge_enabled: bool = True # 是否启用同类型密钥自动合并
|
||||||
# Pro密钥设置
|
# ===== 自动检测开关 =====
|
||||||
pro_quota_cost: int = 50 # 每次换号扣除额度
|
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):
|
class GlobalSettingsUpdate(BaseModel):
|
||||||
"""更新全局设置"""
|
"""更新全局设置"""
|
||||||
auto_switch_interval_minutes: Optional[int] = None
|
# ===== 密钥策略 =====
|
||||||
auto_max_switches_per_day: Optional[int] = None
|
key_max_devices: Optional[int] = None
|
||||||
pro_quota_cost: 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
|
success: int
|
||||||
failed: int
|
failed: int
|
||||||
errors: List[str] = []
|
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.auth_service import authenticate_admin, create_access_token, get_current_user
|
||||||
from app.services.cursor_usage_service import (
|
from app.services.cursor_usage_service import (
|
||||||
CursorUsageService,
|
CursorUsageService,
|
||||||
@@ -7,5 +9,9 @@ from app.services.cursor_usage_service import (
|
|||||||
check_account_valid,
|
check_account_valid,
|
||||||
get_account_usage,
|
get_account_usage,
|
||||||
batch_check_accounts,
|
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 typing import Optional
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
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:
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
else:
|
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})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Cursor 官方用量 API 服务
|
Cursor 官方用量 API 服务 v2.1
|
||||||
用于验证账号有效性和查询用量信息
|
用于验证账号有效性和查询用量信息
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -7,6 +7,7 @@ import asyncio
|
|||||||
from typing import Optional, Dict, Any, Tuple, List
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@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 = []
|
semaphore = asyncio.Semaphore(max_concurrency)
|
||||||
for token in tokens:
|
|
||||||
info = await cursor_usage_service.validate_and_get_usage(token)
|
async def _check_one(token: str) -> Dict[str, Any]:
|
||||||
results.append({
|
async with semaphore:
|
||||||
"token": token[:20] + "...", # 脱敏
|
info = await cursor_usage_service.validate_and_get_usage(token)
|
||||||
"is_valid": info.is_valid,
|
return {
|
||||||
"is_usable": info.is_usable,
|
"token": token[:20] + "...",
|
||||||
"pool_type": info.pool_type, # pro/auto
|
"is_valid": info.is_valid,
|
||||||
"membership_type": info.membership_type if info.is_valid else None,
|
"is_usable": info.is_usable,
|
||||||
"days_remaining_on_trial": info.days_remaining_on_trial,
|
"pool_type": info.pool_type,
|
||||||
"plan_used": info.plan_used if info.is_valid else 0,
|
"membership_type": info.membership_type if info.is_valid else None,
|
||||||
"plan_limit": info.plan_limit if info.is_valid else 0,
|
"days_remaining_on_trial": info.days_remaining_on_trial,
|
||||||
"plan_remaining": info.plan_remaining if info.is_valid else 0,
|
"plan_used": info.plan_used if info.is_valid else 0,
|
||||||
"total_requests": info.total_requests if info.is_valid else 0,
|
"plan_limit": info.plan_limit if info.is_valid else 0,
|
||||||
"error": info.error_message
|
"plan_remaining": info.plan_remaining if info.is_valid else 0,
|
||||||
})
|
"total_requests": info.total_requests if info.is_valid else 0,
|
||||||
return results
|
"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]:
|
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,
|
"total_requests": info.total_requests,
|
||||||
"recommendation": f"建议放入 {'Pro' if info.pool_type == 'pro' else 'Auto'} 号池"
|
"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
|
pydantic-settings==2.1.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
aiosqlite==0.19.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">
|
class="px-3 py-2 font-medium text-sm rounded-md">
|
||||||
批量补偿
|
批量补偿
|
||||||
</button>
|
</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()"
|
<button @click="currentTab = 'logs'; loadLogs()"
|
||||||
:class="currentTab === 'logs' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
: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">
|
class="px-3 py-2 font-medium text-sm rounded-md">
|
||||||
@@ -141,7 +146,7 @@
|
|||||||
"access_token": "eyJhbG...",
|
"access_token": "eyJhbG...",
|
||||||
"refresh_token": "xxx", // 可选
|
"refresh_token": "xxx", // 可选
|
||||||
"workos_session_token": "xxx", // 可选
|
"workos_session_token": "xxx", // 可选
|
||||||
"membership_type": "free", // free=Auto账号, pro=高级账号
|
"membership_type": "free", // free=Free账号, pro=Pro账号
|
||||||
"remark": "备注" // 可选
|
"remark": "备注" // 可选
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -174,7 +179,7 @@
|
|||||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||||||
批量导入
|
批量导入
|
||||||
</button>
|
</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">
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||||
添加账号
|
添加账号
|
||||||
</button>
|
</button>
|
||||||
@@ -207,7 +212,7 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<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'"
|
<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">
|
class="px-2 py-1 text-xs font-medium rounded-full">
|
||||||
{{ account.membership_type.toUpperCase() }}
|
{{ formatMembershipLabel(account.membership_type) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
|
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
|
||||||
@@ -233,6 +238,11 @@
|
|||||||
</td>
|
</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 text-gray-500">{{ account.usage_count }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
<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="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>
|
<button @click="deleteAccount(account.id)" class="text-red-600 hover:text-red-900">删除</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -335,7 +345,7 @@
|
|||||||
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
<option value="">全部类型</option>
|
<option value="">全部类型</option>
|
||||||
<option value="pro">Pro (高级模型)</option>
|
<option value="pro">Pro (高级模型)</option>
|
||||||
<option value="free">Auto (无限换号)</option>
|
<option value="auto">Auto (无限换号)</option>
|
||||||
</select>
|
</select>
|
||||||
<button @click="resetKeySearch" class="px-3 py-2 text-gray-600 hover:text-gray-800 text-sm">
|
<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>
|
<h3 class="font-medium text-gray-700 border-b pb-2">Auto密钥 (无限换号)</h3>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">换号最小间隔 (分钟)</label>
|
<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">
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">每天最大换号次数</label>
|
<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">
|
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>
|
<p class="text-xs text-gray-500 mt-1">每天0点重置计数</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,7 +503,7 @@
|
|||||||
<h3 class="font-medium text-gray-700 border-b pb-2">Pro密钥 (高级模型)</h3>
|
<h3 class="font-medium text-gray-700 border-b pb-2">Pro密钥 (高级模型)</h3>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">每次换号扣除额度</label>
|
<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">
|
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>
|
<p class="text-xs text-gray-500 mt-1">例如:50点/次,500点总额度可换10次</p>
|
||||||
</div>
|
</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">
|
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="">全部</option>
|
||||||
<option value="pro">Pro (高级模型)</option>
|
<option value="pro">Pro (高级模型)</option>
|
||||||
<option value="free">Free (无限Auto)</option>
|
<option value="auto">Auto (无限换号)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -621,6 +631,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="currentTab === 'logs'">
|
||||||
<div class="bg-white rounded-lg shadow">
|
<div class="bg-white rounded-lg shadow">
|
||||||
@@ -696,7 +758,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Access Token</label>
|
<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>
|
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>
|
||||||
<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>
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">会员类型</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">备注</label>
|
||||||
<select v-model="accountForm.membership_type"
|
<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">
|
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>
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" @click="showAccountModal = false"
|
<button type="button" @click="showAccountModal = false"
|
||||||
@@ -727,6 +786,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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">
|
<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"
|
<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">
|
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="pro">Pro (高级模型) - 按额度计费</option>
|
||||||
<option value="free">Auto (无限换号) - 按时间计费</option>
|
<option value="auto">Auto (无限换号) - 按时间计费</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -757,7 +886,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto密钥: 只设置有效天数 -->
|
<!-- 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>
|
<label class="block text-sm font-medium text-gray-700 mb-1">有效天数</label>
|
||||||
<select v-model.number="keyForm.valid_days"
|
<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">
|
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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -998,7 +1170,13 @@
|
|||||||
|
|
||||||
// 表单
|
// 表单
|
||||||
const loginForm = reactive({ username: '', password: '' })
|
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({
|
const keyForm = reactive({
|
||||||
count: 1,
|
count: 1,
|
||||||
membership_type: 'pro',
|
membership_type: 'pro',
|
||||||
@@ -1010,11 +1188,13 @@
|
|||||||
|
|
||||||
// 弹窗
|
// 弹窗
|
||||||
const showAccountModal = ref(false)
|
const showAccountModal = ref(false)
|
||||||
|
const showAnalyzeModal = ref(false)
|
||||||
const showKeyModal = ref(false)
|
const showKeyModal = ref(false)
|
||||||
const showImportModal = ref(false)
|
const showImportModal = ref(false)
|
||||||
const showQuotaModal = ref(false)
|
const showQuotaModal = ref(false)
|
||||||
const showExtendModal = ref(false)
|
const showExtendModal = ref(false)
|
||||||
const editingAccount = ref(null)
|
const editingAccount = ref(null)
|
||||||
|
const analyzeTarget = ref(null)
|
||||||
const editingKey = ref(null)
|
const editingKey = ref(null)
|
||||||
const importData = ref('')
|
const importData = ref('')
|
||||||
const importResult = ref(null)
|
const importResult = ref(null)
|
||||||
@@ -1029,11 +1209,22 @@
|
|||||||
const keyDevices = ref([])
|
const keyDevices = ref([])
|
||||||
const keyLogs = 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({
|
const globalSettings = reactive({
|
||||||
auto_switch_interval_minutes: 20,
|
auto_switch_interval: 0,
|
||||||
auto_max_switches_per_day: 50,
|
auto_daily_switches: 999,
|
||||||
pro_quota_cost: 50
|
pro_quota_per_switch: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
// 批量补偿
|
// 批量补偿
|
||||||
@@ -1120,23 +1311,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 账号操作
|
// 账号操作
|
||||||
|
const openCreateAccountModal = () => {
|
||||||
|
editingAccount.value = null
|
||||||
|
resetAccountForm()
|
||||||
|
showAccountModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const editAccount = (account) => {
|
const editAccount = (account) => {
|
||||||
editingAccount.value = account
|
editingAccount.value = account
|
||||||
Object.assign(accountForm, account)
|
prepareAccountForm(account)
|
||||||
showAccountModal.value = true
|
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 () => {
|
const saveAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
const payload = buildAccountPayload()
|
||||||
if (editingAccount.value) {
|
if (editingAccount.value) {
|
||||||
await api.put(`/accounts/${editingAccount.value.id}`, accountForm)
|
await api.put(`/accounts/${editingAccount.value.id}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await api.post('/accounts', accountForm)
|
await api.post('/accounts', payload)
|
||||||
}
|
}
|
||||||
showAccountModal.value = false
|
showAccountModal.value = false
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
} catch (e) {
|
} 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 toggleAccountStatus = async (account) => {
|
||||||
const statusMap = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
|
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 getStatusClass = (status) => {
|
||||||
const map = {
|
const map = {
|
||||||
@@ -1556,6 +1913,11 @@
|
|||||||
return map[status] || 'bg-gray-100 text-gray-800'
|
return map[status] || 'bg-gray-100 text-gray-800'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMembershipLabel = (type) => {
|
||||||
|
if (!type) return '未知'
|
||||||
|
return String(type).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
const getStatusText = (status) => {
|
const getStatusText = (status) => {
|
||||||
const map = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
|
const map = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
|
||||||
return map[status] || status
|
return map[status] || status
|
||||||
@@ -1578,7 +1940,9 @@
|
|||||||
|
|
||||||
// 监听标签页切换
|
// 监听标签页切换
|
||||||
watch(currentTab, (newTab) => {
|
watch(currentTab, (newTab) => {
|
||||||
if (newTab !== 'settings' && newTab !== 'compensate') {
|
if (newTab === 'announcements') {
|
||||||
|
loadAnnouncements()
|
||||||
|
} else if (newTab !== 'settings' && newTab !== 'compensate') {
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1589,24 +1953,29 @@
|
|||||||
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
|
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
|
||||||
logs, logFilter, keySearch,
|
logs, logFilter, keySearch,
|
||||||
accountForm, keyForm,
|
accountForm, keyForm,
|
||||||
showAccountModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
|
showAccountModal, showAnalyzeModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
|
||||||
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
|
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
|
||||||
editingAccount, editingKey,
|
editingAccount, editingKey, analyzeTarget,
|
||||||
importData, importResult,
|
importData, importResult,
|
||||||
quotaTarget, addQuotaAmount,
|
quotaTarget, addQuotaAmount,
|
||||||
extendTarget, extendDays,
|
extendTarget, extendDays,
|
||||||
globalSettings,
|
globalSettings,
|
||||||
compensateForm, compensatePreview, compensateResult,
|
compensateForm, compensatePreview, compensateResult,
|
||||||
|
announcements, showAnnouncementModal, editingAnnouncement, announcementForm,
|
||||||
|
loadAnnouncements, openCreateAnnouncementModal, editAnnouncement, saveAnnouncement,
|
||||||
|
deleteAnnouncement, toggleAnnouncement, announcementTypeClass, announcementTypeLabel,
|
||||||
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
|
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
|
||||||
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
|
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
|
||||||
toggleSelectAllKeys, batchCopyKeys, batchEnableKeys, batchDisableKeys, batchDeleteKeys,
|
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,
|
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey, copyToken,
|
||||||
viewKeyDetail, deleteDevice, disableKey, enableKey,
|
viewKeyDetail, deleteDevice, disableKey, enableKey,
|
||||||
extendKey, submitExtend,
|
extendKey, submitExtend,
|
||||||
|
analyzeForm, analyzeResult, analyzeLoading,
|
||||||
loadSettings, saveSettings,
|
loadSettings, saveSettings,
|
||||||
previewCompensate, executeCompensate,
|
previewCompensate, executeCompensate,
|
||||||
getStatusClass, getStatusText, formatDate
|
getStatusClass, getStatusText, formatDate, formatMembershipLabel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.services.cursor_usage_service import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 测试 Token (free_trial)
|
# 测试 Token (free_trial)
|
||||||
TEST_TOKEN = "user_01KCG2G9K4Q37C1PKTNR7EVNGW::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0NHMkc5SzRRMzdDMVBLVE5SN0VWTkdXIiwidGltZSI6IjE3NjU3ODc5NjYiLCJyYW5kb21uZXNzIjoiOTA1NTU4NjktYTlmMC00M2NhIiwiZXhwIjoxNzcwOTcxOTY2LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoic2Vzc2lvbiJ9.vreEnprZ7q9pU7b6TTVGQ0HUIQTJrxLXcnkz4Ne4Dng"
|
TEST_TOKEN = "user_01KD0CVVERZH4B04AEKP4QQDV6::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0QwQ1ZWRVJaSDRCMDRBRUtQNFFRRFY2IiwidGltZSI6IjE3NjYzMTg4MjIiLCJyYW5kb21uZXNzIjoiMjBlMzY0MDItZTY5Yi00ZmU1IiwiZXhwIjoxNzcxNTAyODIyLCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.rnqRVLK-iLFEUigNhiQI1loNjDWbhuGGDjeEUSxRAh0"
|
||||||
|
|
||||||
|
|
||||||
async def test_check_valid():
|
async def test_check_valid():
|
||||||
|
|||||||
Reference in New Issue
Block a user