backend v2.1: 公告管理功能 + 系统重构

- 新增 Announcement 数据模型,支持公告的增删改查
- 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用)
- 客户端 /api/announcement 改为从数据库读取
- 账号服务重构,新增无感换号、自动分析等功能
- 新增后台任务调度器、数据库迁移脚本
- Schema/Service/Config 全面升级至 v2.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 19:58:05 +08:00
parent 73a71f198f
commit ac19d029da
20 changed files with 3341 additions and 1440 deletions

View File

@@ -4,23 +4,31 @@
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.config import settings
from app.services import AccountService, KeyService, LogService, GlobalSettingsService, BatchService, authenticate_admin, create_access_token, get_current_user
from app.schemas import (
from app.schemas.schemas import (
AccountCreate, AccountUpdate, AccountResponse, AccountImport,
KeyCreate, KeyUpdate, KeyResponse,
DashboardStats, Token, LoginRequest,
GlobalSettingsResponse, GlobalSettingsUpdate,
BatchExtendRequest, BatchExtendResponse,
ExternalBatchUpload, ExternalBatchResponse
ExternalBatchUpload, ExternalBatchResponse,
AnnouncementCreate, AnnouncementUpdate
)
from app.models import MembershipType, KeyDevice, UsageLog, ActivationKey
from app.models.models import KeyMembershipType, KeyStatus, AccountStatus, AccountType, KeyDevice, UsageLog, ActivationKey, CursorAccount, Announcement
router = APIRouter(prefix="/admin", tags=["Admin API"])
class AccountAnalyzeRequest(BaseModel):
"""手动分析账号请求"""
token: Optional[str] = None
save_token: bool = False
# ========== 认证 ==========
@router.post("/login", response_model=Token)
@@ -79,8 +87,8 @@ async def external_batch_upload(
for item in data.accounts:
try:
# 转换membership_type
mt = MembershipType.FREE if item.membership_type == "free" else MembershipType.PRO
# 转换membership_type (free/auto -> AUTO, pro -> PRO)
# 注意mt 变量暂未使用,因为 CursorAccount 模型中 membership_type 是从 Cursor API 分析得出的
existing = AccountService.get_by_email(db, item.email)
if existing:
@@ -88,10 +96,10 @@ async def external_batch_upload(
# 更新已存在的账号
AccountService.update(
db, existing.id,
token=item.workos_session_token or item.access_token,
access_token=item.access_token,
refresh_token=item.refresh_token,
workos_session_token=item.workos_session_token,
membership_type=mt,
remark=item.remark or existing.remark
)
updated += 1
@@ -100,15 +108,16 @@ async def external_batch_upload(
errors.append(f"{item.email}: 账号已存在")
else:
# 创建新账号
account_data = AccountCreate(
AccountService.create(
db,
email=item.email,
token=item.workos_session_token or item.access_token,
access_token=item.access_token,
refresh_token=item.refresh_token,
workos_session_token=item.workos_session_token,
membership_type=mt,
password=None,
remark=item.remark
)
AccountService.create(db, account_data)
created += 1
except Exception as e:
failed += 1
@@ -131,11 +140,12 @@ async def external_account_stats(
):
"""外部系统获取账号统计"""
stats = AccountService.count(db)
pro_count = db.query(CursorAccount).filter(CursorAccount.account_type == AccountType.PRO).count()
return {
"total": stats["total"],
"active": stats["active"],
"pro": stats["pro"],
"free": stats["total"] - stats["pro"]
"active": stats["available"] + stats["in_use"],
"pro": pro_count,
"free": stats["total"] - pro_count
}
@@ -181,8 +191,8 @@ async def get_dashboard(
return DashboardStats(
total_accounts=account_stats["total"],
active_accounts=account_stats["active"],
pro_accounts=account_stats["pro"],
active_accounts=account_stats["available"] + account_stats["in_use"], # 可用+使用中
pro_accounts=key_stats["pro"], # Pro密钥数量
total_keys=key_stats["total"],
active_keys=key_stats["active"],
today_usage=today_usage
@@ -212,7 +222,16 @@ async def create_account(
existing = AccountService.get_by_email(db, account.email)
if existing:
raise HTTPException(status_code=400, detail="邮箱已存在")
return AccountService.create(db, account)
return AccountService.create(
db,
email=account.email,
token=account.token,
password=account.password,
remark=account.remark,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token
)
@router.post("/accounts/import", response_model=dict)
@@ -233,13 +252,24 @@ async def import_accounts(
# 更新已存在的账号
AccountService.update(
db, existing.id,
token=account.token or account.workos_session_token or account.access_token,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token,
membership_type=account.membership_type
password=account.password,
remark=account.remark
)
else:
AccountService.create(db, account)
AccountService.create(
db,
email=account.email,
token=account.token or account.workos_session_token or account.access_token,
password=account.password,
remark=account.remark,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token
)
success += 1
except Exception as e:
failed += 1
@@ -315,8 +345,7 @@ async def toggle_account_status(
- 禁用(disabled) -> 可用(active)
- 过期(expired) -> 可用(active)
"""
from app.models import AccountStatus, Account
account = db.query(Account).filter(Account.id == account_id).first()
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
@@ -324,14 +353,15 @@ async def toggle_account_status(
# 根据当前状态切换
if account.status == AccountStatus.IN_USE:
account.status = AccountStatus.ACTIVE
account.current_key_id = None # 释放绑定
elif account.status == AccountStatus.ACTIVE:
account.status = AccountStatus.AVAILABLE
account.locked_by_key_id = None # 释放绑定
account.locked_at = None
elif account.status == AccountStatus.AVAILABLE:
account.status = AccountStatus.DISABLED
elif account.status == AccountStatus.DISABLED:
account.status = AccountStatus.ACTIVE
elif account.status == AccountStatus.EXPIRED:
account.status = AccountStatus.ACTIVE
account.status = AccountStatus.AVAILABLE
elif account.status == AccountStatus.EXHAUSTED:
account.status = AccountStatus.AVAILABLE
db.commit()
@@ -350,16 +380,16 @@ async def release_account(
current_user: dict = Depends(get_current_user)
):
"""释放账号(从使用中变为可用)"""
from app.models import AccountStatus, Account
account = db.query(Account).filter(Account.id == account_id).first()
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
if account.status != AccountStatus.IN_USE:
return {"success": False, "message": "账号不在使用中状态"}
account.status = AccountStatus.ACTIVE
account.current_key_id = None
account.status = AccountStatus.AVAILABLE
account.locked_by_key_id = None
account.locked_at = None
db.commit()
return {"success": True, "message": "账号已释放"}
@@ -372,15 +402,14 @@ async def batch_enable_accounts(
current_user: dict = Depends(get_current_user)
):
"""批量启用账号"""
from app.models import AccountStatus, Account
success = 0
failed = 0
for account_id in account_ids:
try:
account = db.query(Account).filter(Account.id == account_id).first()
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
account.status = AccountStatus.ACTIVE
account.status = AccountStatus.AVAILABLE
success += 1
else:
failed += 1
@@ -402,13 +431,12 @@ async def batch_disable_accounts(
current_user: dict = Depends(get_current_user)
):
"""批量禁用账号"""
from app.models import AccountStatus, Account
success = 0
failed = 0
for account_id in account_ids:
try:
account = db.query(Account).filter(Account.id == account_id).first()
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
account.status = AccountStatus.DISABLED
success += 1
@@ -451,15 +479,6 @@ async def batch_delete_accounts(
}
@router.get("/accounts/count")
async def get_accounts_count(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取账号总数"""
stats = AccountService.count(db)
return {"total": stats["total"]}
# ========== 激活码管理 ==========
@@ -475,7 +494,6 @@ async def list_keys(
current_user: dict = Depends(get_current_user)
):
"""获取激活码列表(支持搜索和筛选)"""
from app.models import KeyStatus
query = db.query(ActivationKey).order_by(ActivationKey.id.desc())
# 搜索激活码
@@ -493,9 +511,9 @@ async def list_keys(
elif activated and activated == "false":
query = query.filter(ActivationKey.first_activated_at == None)
# 套餐类型筛选
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO
query = query.filter(ActivationKey.membership_type == mt)
return query.offset(skip).limit(limit).all()
@@ -511,7 +529,6 @@ async def get_keys_count(
current_user: dict = Depends(get_current_user)
):
"""获取激活码总数(支持筛选)"""
from app.models import KeyStatus
query = db.query(ActivationKey)
# 搜索激活码
@@ -529,9 +546,9 @@ async def get_keys_count(
elif activated and activated == "false":
query = query.filter(ActivationKey.first_activated_at == None)
# 套餐类型筛选
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO
query = query.filter(ActivationKey.membership_type == mt)
total = query.count()
@@ -545,7 +562,15 @@ async def create_keys(
current_user: dict = Depends(get_current_user)
):
"""创建激活码"""
return KeyService.create(db, key_data)
return KeyService.create(
db,
count=key_data.count,
membership_type=key_data.membership_type,
duration_days=key_data.valid_days,
quota=key_data.quota,
max_devices=key_data.max_devices,
remark=key_data.remark
)
@router.get("/keys/{key_id}", response_model=KeyResponse)
@@ -735,7 +760,6 @@ async def disable_key(
current_user: dict = Depends(get_current_user)
):
"""禁用激活码(返回使用信息供客服参考)"""
from app.models import KeyStatus
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
@@ -770,7 +794,6 @@ async def enable_key(
current_user: dict = Depends(get_current_user)
):
"""启用激活码"""
from app.models import KeyStatus
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
@@ -788,7 +811,6 @@ async def batch_enable_keys(
current_user: dict = Depends(get_current_user)
):
"""批量启用激活码"""
from app.models import KeyStatus
success = 0
failed = 0
@@ -818,7 +840,6 @@ async def batch_disable_keys(
current_user: dict = Depends(get_current_user)
):
"""批量禁用激活码"""
from app.models import KeyStatus
success = 0
failed = 0
@@ -877,7 +898,6 @@ async def get_keys_count(
current_user: dict = Depends(get_current_user)
):
"""获取激活码总数(支持筛选)"""
from app.models import KeyStatus
query = db.query(ActivationKey)
# 搜索激活码
@@ -895,9 +915,9 @@ async def get_keys_count(
elif activated and activated == "false":
query = query.filter(ActivationKey.first_activated_at == None)
# 套餐类型筛选
# 套餐类型筛选 (free/auto -> AUTO, pro -> PRO)
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO
query = query.filter(ActivationKey.membership_type == mt)
total = query.count()
@@ -962,7 +982,7 @@ async def batch_extend_keys(
@router.post("/keys/batch-compensate")
async def batch_compensate(
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
membership_type: Optional[str] = Query(None, description="套餐类型: pro/auto"),
activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"),
not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"),
extend_days: int = Query(0, description="延长天数"),
@@ -981,11 +1001,11 @@ async def batch_compensate(
- 如果卡已过期但符合补偿条件恢复使用expire_at = 今天 + extend_days
例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥延长1天
POST /admin/keys/batch-compensate?membership_type=free&activated_before=2024-12-05&not_expired_on=2024-12-04&extend_days=1
POST /admin/keys/batch-compensate?membership_type=auto&activated_before=2024-12-05&not_expired_on=2024-12-04&extend_days=1
"""
mt = None
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO
activated_before_dt = datetime.strptime(activated_before, "%Y-%m-%d") if activated_before else None
not_expired_on_dt = datetime.strptime(not_expired_on, "%Y-%m-%d") if not_expired_on else None
@@ -1003,7 +1023,7 @@ async def batch_compensate(
@router.get("/keys/preview-compensate")
async def preview_compensate(
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
membership_type: Optional[str] = Query(None, description="套餐类型: pro/auto"),
activated_before: Optional[str] = Query(None, description="在此日期之前激活 (YYYY-MM-DD)"),
not_expired_on: Optional[str] = Query(None, description="在此日期时还未过期 (YYYY-MM-DD)"),
db: Session = Depends(get_db),
@@ -1012,7 +1032,7 @@ async def preview_compensate(
"""预览补偿 - 查看符合条件的密钥数量(不执行)"""
mt = None
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
mt = KeyMembershipType.PRO if membership_type.lower() == "pro" else KeyMembershipType.AUTO
activated_before_dt = datetime.strptime(activated_before, "%Y-%m-%d") if activated_before else None
not_expired_on_dt = datetime.strptime(not_expired_on, "%Y-%m-%d") if not_expired_on else None
@@ -1140,3 +1160,295 @@ async def get_key_logs(
"message": log.message,
"created_at": log.created_at.strftime("%Y-%m-%d %H:%M:%S") if log.created_at else None
} for log in logs]
# ========== 账号分析 (Analysis) ==========
@router.post("/accounts/{account_id}/analyze")
async def analyze_account(
account_id: int,
payload: Optional[AccountAnalyzeRequest] = None,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""手动触发单个账号分析"""
from app.services import analyze_account_from_token
account = AccountService.get_by_id(db, account_id)
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
override_token = payload.token.strip() if payload and payload.token else None
token_to_use = override_token or account.token
if not token_to_use:
raise HTTPException(status_code=400, detail="账号未保存Token请提供检测Token")
# 执行分析
result = await analyze_account_from_token(token_to_use)
if result.get("success"):
# 更新账号信息
if override_token and payload.save_token:
AccountService.update(
db,
account_id,
token=override_token,
workos_session_token=override_token
)
AccountService.update_from_analysis(db, account_id, result)
return {
"success": True,
"message": "分析完成",
"data": result
}
else:
# 记录错误
AccountService.update_from_analysis(db, account_id, {
"success": False,
"error": result.get("error", "分析失败")
})
return {
"success": False,
"message": result.get("error", "分析失败")
}
@router.post("/accounts/batch-analyze")
async def batch_analyze_accounts(
account_ids: List[int] = None,
analyze_all_pending: bool = False,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量分析账号
参数:
- account_ids: 指定账号ID列表
- analyze_all_pending: 分析所有待分析的账号
"""
from app.services import analyze_account_from_token
accounts = []
if analyze_all_pending:
accounts = AccountService.get_pending_accounts(db, limit=50)
elif account_ids:
for aid in account_ids:
acc = AccountService.get_by_id(db, aid)
if acc:
accounts.append(acc)
if not accounts:
return {"success": 0, "failed": 0, "message": "没有找到要分析的账号"}
success = 0
failed = 0
results = []
for account in accounts:
try:
result = await analyze_account_from_token(account.token)
if result.get("success"):
AccountService.update_from_analysis(db, account.id, result)
success += 1
results.append({
"id": account.id,
"email": account.email,
"success": True,
"account_type": result.get("account_type"),
"usage_percent": result.get("usage_percent")
})
else:
AccountService.update_from_analysis(db, account.id, {
"success": False,
"error": result.get("error", "分析失败")
})
failed += 1
results.append({
"id": account.id,
"email": account.email,
"success": False,
"error": result.get("error")
})
except Exception as e:
failed += 1
results.append({
"id": account.id,
"email": account.email,
"success": False,
"error": str(e)
})
return {
"success": success,
"failed": failed,
"total": len(accounts),
"results": results[:20], # 只返回前20条
"message": f"分析完成: {success} 成功, {failed} 失败"
}
@router.post("/settings/toggle-auto-analyze")
async def toggle_auto_analyze(
enabled: bool,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""开启/关闭自动账号分析"""
GlobalSettingsService.set(db, "auto_analyze_enabled", str(enabled).lower())
return {
"success": True,
"auto_analyze_enabled": enabled,
"message": f"自动分析已{'开启' if enabled else '关闭'}"
}
@router.post("/settings/toggle-auto-switch")
async def toggle_auto_switch(
enabled: bool,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""开启/关闭自动换号"""
GlobalSettingsService.set(db, "auto_switch_enabled", str(enabled).lower())
return {
"success": True,
"auto_switch_enabled": enabled,
"message": f"自动换号已{'开启' if enabled else '关闭'}"
}
@router.get("/accounts/analysis-status")
async def get_analysis_status(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取账号分析状态概览"""
stats = AccountService.count(db)
auto_analyze = GlobalSettingsService.get(db, "auto_analyze_enabled")
auto_switch = GlobalSettingsService.get(db, "auto_switch_enabled")
return {
"auto_analyze_enabled": auto_analyze == "true",
"auto_switch_enabled": auto_switch == "true",
"total_accounts": stats["total"],
"pending_analysis": stats["pending"],
"available": stats["available"],
"in_use": stats["in_use"],
"exhausted": stats["exhausted"],
"invalid": stats["invalid"]
}
# ========== 公告管理 ==========
@router.get("/announcements")
async def list_announcements(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取所有公告(含禁用的)"""
announcements = db.query(Announcement).order_by(Announcement.id.desc()).all()
return [
{
"id": a.id,
"title": a.title,
"content": a.content,
"type": a.type,
"is_active": a.is_active,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in announcements
]
@router.post("/announcements")
async def create_announcement(
data: AnnouncementCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建公告"""
announcement = Announcement(
title=data.title,
content=data.content,
type=data.type,
is_active=data.is_active,
)
db.add(announcement)
db.commit()
db.refresh(announcement)
return {
"id": announcement.id,
"title": announcement.title,
"content": announcement.content,
"type": announcement.type,
"is_active": announcement.is_active,
"created_at": announcement.created_at.isoformat() if announcement.created_at else None,
"updated_at": announcement.updated_at.isoformat() if announcement.updated_at else None,
}
@router.put("/announcements/{announcement_id}")
async def update_announcement(
announcement_id: int,
data: AnnouncementUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""编辑公告"""
announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first()
if not announcement:
raise HTTPException(status_code=404, detail="公告不存在")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(announcement, field, value)
db.commit()
db.refresh(announcement)
return {
"id": announcement.id,
"title": announcement.title,
"content": announcement.content,
"type": announcement.type,
"is_active": announcement.is_active,
"created_at": announcement.created_at.isoformat() if announcement.created_at else None,
"updated_at": announcement.updated_at.isoformat() if announcement.updated_at else None,
}
@router.delete("/announcements/{announcement_id}")
async def delete_announcement(
announcement_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除公告"""
announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first()
if not announcement:
raise HTTPException(status_code=404, detail="公告不存在")
db.delete(announcement)
db.commit()
return {"success": True, "message": "公告已删除"}
@router.post("/announcements/{announcement_id}/toggle")
async def toggle_announcement(
announcement_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""启用/禁用公告"""
announcement = db.query(Announcement).filter(Announcement.id == announcement_id).first()
if not announcement:
raise HTTPException(status_code=404, detail="公告不存在")
announcement.is_active = not announcement.is_active
db.commit()
db.refresh(announcement)
return {
"id": announcement.id,
"is_active": announcement.is_active,
"message": "公告已启用" if announcement.is_active else "公告已禁用"
}

File diff suppressed because it is too large Load Diff