CursorPro 后台管理系统 v1.0

功能:
- 激活码管理 (Pro/Auto 两种类型)
- 账号池管理
- 设备绑定记录
- 使用日志
- 搜索/筛选功能
- 禁用/启用功能 (支持退款参考)
- 全局设置 (换号间隔、额度消耗等)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ccdojox-crypto
2025-12-16 20:54:44 +08:00
commit 9e2333c90c
62 changed files with 9567 additions and 0 deletions

15
backend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=cursorpro
# JWT 配置
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# 管理员账号
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# 安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["python", "run.py"]

131
backend/README.md Normal file
View File

@@ -0,0 +1,131 @@
# CursorPro 后台管理系统
基于 FastAPI 的 Cursor 账号管理和激活码系统,兼容原 CursorPro 插件 API。
## 功能特性
- 账号管理:导入、编辑、删除 Cursor 账号
- 激活码系统:生成、管理激活码,支持换号次数限制
- Web 管理后台Vue.js + Tailwind CSS 构建的现代化界面
- 客户端 API完全兼容原 CursorPro 插件
## 快速开始
### 方式一:本地运行
1. **安装 MySQL 数据库**
2. **配置环境**
```bash
# 复制配置文件
cp .env.example .env
# 编辑 .env 填入数据库信息
```
3. **启动服务**
```bash
# Windows
start.bat
# Linux/Mac
chmod +x start.sh
./start.sh
```
4. **访问管理后台**
- 地址: http://localhost:8000
- 默认账号: admin / admin123
### 方式二Docker 部署
```bash
# 启动 MySQL + 后台服务
docker-compose up -d
# 查看日志
docker-compose logs -f backend
```
## API 文档
启动后访问 http://localhost:8000/docs 查看 Swagger API 文档。
### 客户端 API兼容原插件
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/verify-key` | POST | 验证激活码 |
| `/api/switch-account` | POST | 切换账号 |
| `/api/version` | GET | 获取版本信息 |
### 管理 API
| 接口 | 方法 | 说明 |
|------|------|------|
| `/admin/login` | POST | 管理员登录 |
| `/admin/dashboard` | GET | 仪表盘统计 |
| `/admin/accounts` | GET/POST | 账号列表/创建 |
| `/admin/accounts/import` | POST | 批量导入账号 |
| `/admin/keys` | GET/POST | 激活码列表/生成 |
## 账号数据格式
导入账号时使用 JSON 格式:
```json
[
{
"email": "user@example.com",
"access_token": "...",
"refresh_token": "...",
"workos_session_token": "...",
"membership_type": "pro"
}
]
```
## 目录结构
```
backend/
├── app/
│ ├── api/ # API 路由
│ │ ├── admin.py # 管理后台 API
│ │ └── client.py # 客户端 API兼容原插件
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 数据模式
│ ├── services/ # 业务逻辑
│ ├── config.py # 配置
│ ├── database.py # 数据库连接
│ └── main.py # 应用入口
├── templates/ # HTML 模板
├── static/ # 静态文件
├── .env.example # 环境变量示例
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── run.py # 启动脚本
└── start.bat/sh # 快捷启动
```
## 配置说明
`.env` 文件配置项:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| DB_HOST | 数据库地址 | localhost |
| DB_PORT | 数据库端口 | 3306 |
| DB_USER | 数据库用户 | root |
| DB_PASSWORD | 数据库密码 | - |
| DB_NAME | 数据库名 | cursorpro |
| JWT_SECRET_KEY | JWT 密钥 | - |
| ADMIN_USERNAME | 管理员账号 | admin |
| ADMIN_PASSWORD | 管理员密码 | admin123 |
## 安全提示
- 生产环境请修改 `JWT_SECRET_KEY`
- 修改默认管理员密码
- 建议使用 HTTPS

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# CursorPro Backend Application

View File

@@ -0,0 +1,2 @@
from app.api.client import router as client_router
from app.api.admin import router as admin_router

582
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,582 @@
"""
管理后台 API
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services import AccountService, KeyService, LogService, GlobalSettingsService, BatchService, authenticate_admin, create_access_token, get_current_user
from app.schemas import (
AccountCreate, AccountUpdate, AccountResponse, AccountImport,
KeyCreate, KeyUpdate, KeyResponse,
DashboardStats, Token, LoginRequest,
GlobalSettingsResponse, GlobalSettingsUpdate,
BatchExtendRequest, BatchExtendResponse
)
from app.models import MembershipType, KeyDevice, UsageLog, ActivationKey
router = APIRouter(prefix="/admin", tags=["Admin API"])
# ========== 认证 ==========
@router.post("/login", response_model=Token)
async def login(request: LoginRequest):
"""管理员登录"""
if not authenticate_admin(request.username, request.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误"
)
access_token = create_access_token(data={"sub": request.username})
return Token(access_token=access_token)
# ========== 仪表盘 ==========
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取仪表盘统计"""
account_stats = AccountService.count(db)
key_stats = KeyService.count(db)
today_usage = LogService.get_today_count(db)
return DashboardStats(
total_accounts=account_stats["total"],
active_accounts=account_stats["active"],
pro_accounts=account_stats["pro"],
total_keys=key_stats["total"],
active_keys=key_stats["active"],
today_usage=today_usage
)
# ========== 账号管理 ==========
@router.get("/accounts", response_model=List[AccountResponse])
async def list_accounts(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取账号列表"""
return AccountService.get_all(db, skip, limit)
@router.post("/accounts", response_model=AccountResponse)
async def create_account(
account: AccountCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建账号"""
existing = AccountService.get_by_email(db, account.email)
if existing:
raise HTTPException(status_code=400, detail="邮箱已存在")
return AccountService.create(db, account)
@router.post("/accounts/import", response_model=dict)
async def import_accounts(
data: AccountImport,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量导入账号"""
success = 0
failed = 0
errors = []
for account in data.accounts:
try:
existing = AccountService.get_by_email(db, account.email)
if existing:
# 更新已存在的账号
AccountService.update(
db, existing.id,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token,
membership_type=account.membership_type
)
else:
AccountService.create(db, account)
success += 1
except Exception as e:
failed += 1
errors.append(f"{account.email}: {str(e)}")
return {
"success": success,
"failed": failed,
"errors": errors[:10] # 只返回前10个错误
}
@router.get("/accounts/{account_id}", response_model=AccountResponse)
async def get_account(
account_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取账号详情"""
account = AccountService.get_by_id(db, account_id)
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
return account
@router.put("/accounts/{account_id}", response_model=AccountResponse)
async def update_account(
account_id: int,
account: AccountUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""更新账号"""
updated = AccountService.update(db, account_id, **account.model_dump(exclude_unset=True))
if not updated:
raise HTTPException(status_code=404, detail="账号不存在")
return updated
@router.delete("/accounts/{account_id}")
async def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除账号"""
if not AccountService.delete(db, account_id):
raise HTTPException(status_code=404, detail="账号不存在")
return {"message": "删除成功"}
# ========== 激活码管理 ==========
@router.get("/keys", response_model=List[KeyResponse])
async def list_keys(
skip: int = 0,
limit: int = 100,
search: Optional[str] = Query(None, description="搜索激活码"),
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
activated: Optional[bool] = Query(None, description="是否已激活"),
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取激活码列表(支持搜索和筛选)"""
from app.models import KeyStatus
query = db.query(ActivationKey).order_by(ActivationKey.id.desc())
# 搜索激活码
if search:
query = query.filter(ActivationKey.key.contains(search))
# 状态筛选
if status:
query = query.filter(ActivationKey.status == status)
# 是否已激活
if activated is True:
query = query.filter(ActivationKey.first_activated_at != None)
elif activated is False:
query = query.filter(ActivationKey.first_activated_at == None)
# 套餐类型筛选
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
query = query.filter(ActivationKey.membership_type == mt)
return query.offset(skip).limit(limit).all()
@router.post("/keys", response_model=List[KeyResponse])
async def create_keys(
key_data: KeyCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建激活码"""
return KeyService.create(db, key_data)
@router.get("/keys/{key_id}", response_model=KeyResponse)
async def get_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取激活码详情"""
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
return key
@router.put("/keys/{key_id}", response_model=KeyResponse)
async def update_key(
key_id: int,
key_data: KeyUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""更新激活码"""
updated = KeyService.update(db, key_id, **key_data.model_dump(exclude_unset=True))
if not updated:
raise HTTPException(status_code=404, detail="激活码不存在")
return updated
@router.delete("/keys/{key_id}")
async def delete_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除激活码"""
if not KeyService.delete(db, key_id):
raise HTTPException(status_code=404, detail="激活码不存在")
return {"message": "删除成功"}
@router.get("/keys/{key_id}/usage-info")
async def get_key_usage_info(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取激活码使用信息(用于禁用/退款参考)"""
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
now = datetime.now()
usage_info = {
"key": key.key,
"membership_type": key.membership_type.value,
"status": key.status.value,
"is_activated": key.first_activated_at is not None,
"first_activated_at": key.first_activated_at.strftime("%Y-%m-%d %H:%M:%S") if key.first_activated_at else None,
"expire_at": key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None,
"valid_days": key.valid_days,
"used_days": 0,
"remaining_days": 0,
"switch_count": key.switch_count,
"quota": key.quota,
"quota_used": key.quota_used,
"quota_remaining": key.quota - key.quota_used,
"device_count": db.query(KeyDevice).filter(KeyDevice.key_id == key_id).count(),
"max_devices": key.max_devices,
}
# 计算使用天数
if key.first_activated_at:
used_delta = now - key.first_activated_at
usage_info["used_days"] = used_delta.days
if key.expire_at:
if key.expire_at > now:
remaining_delta = key.expire_at - now
usage_info["remaining_days"] = remaining_delta.days
else:
usage_info["remaining_days"] = 0
usage_info["is_expired"] = True
return usage_info
@router.post("/keys/{key_id}/disable")
async def disable_key(
key_id: int,
db: Session = Depends(get_db),
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="激活码不存在")
now = datetime.now()
# 计算使用信息
used_days = 0
if key.first_activated_at:
used_delta = now - key.first_activated_at
used_days = used_delta.days
# 禁用
key.status = KeyStatus.DISABLED
db.commit()
return {
"message": "激活码已禁用",
"key": key.key,
"membership_type": key.membership_type.value,
"used_days": used_days,
"switch_count": key.switch_count,
"quota_used": key.quota_used,
"first_activated_at": key.first_activated_at.strftime("%Y-%m-%d %H:%M:%S") if key.first_activated_at else "未激活"
}
@router.post("/keys/{key_id}/enable")
async def enable_key(
key_id: int,
db: Session = Depends(get_db),
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="激活码不存在")
key.status = KeyStatus.ACTIVE
db.commit()
return {"message": "激活码已启用"}
@router.post("/keys/{key_id}/add-quota", response_model=KeyResponse)
async def add_key_quota(
key_id: int,
add_quota: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""叠加额度(只加额度不加时间)"""
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
KeyService.add_quota(db, key, add_quota)
db.refresh(key)
return key
# ========== 全局设置 ==========
@router.get("/settings", response_model=GlobalSettingsResponse)
async def get_settings(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取全局设置"""
# 初始化默认设置(如果不存在)
GlobalSettingsService.init_settings(db)
return GlobalSettingsService.get_all(db)
@router.put("/settings", response_model=GlobalSettingsResponse)
async def update_settings(
settings: GlobalSettingsUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""更新全局设置"""
GlobalSettingsService.update_all(db, **settings.model_dump(exclude_unset=True))
return GlobalSettingsService.get_all(db)
# ========== 批量操作 ==========
@router.post("/keys/batch-extend", response_model=BatchExtendResponse)
async def batch_extend_keys(
request: BatchExtendRequest,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量延长密钥指定ID列表
- extend_days: 延长天数Auto和Pro都可用
- add_quota: 增加额度仅Pro有效
"""
result = BatchService.extend_keys(db, request.key_ids, request.extend_days, request.add_quota)
return BatchExtendResponse(**result)
@router.post("/keys/batch-compensate")
async def batch_compensate(
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
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="延长天数"),
add_quota: int = Query(0, description="增加额度(仅Pro)"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量补偿 - 根据条件筛选密钥并补偿
筛选条件:
- activated_before: 在此日期之前激活的
- not_expired_on: 在此日期时还未过期的
补偿逻辑:
- 如果卡当前还没过期expire_at += extend_days
- 如果卡已过期但符合补偿条件恢复使用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
"""
mt = None
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
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
result = BatchService.batch_compensate(
db,
membership_type=mt,
activated_before=activated_before_dt,
not_expired_on=not_expired_on_dt,
extend_days=extend_days,
add_quota=add_quota
)
return result
@router.get("/keys/preview-compensate")
async def preview_compensate(
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
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),
current_user: dict = Depends(get_current_user)
):
"""预览补偿 - 查看符合条件的密钥数量(不执行)"""
mt = None
if membership_type:
mt = MembershipType.PRO if membership_type.lower() == "pro" else MembershipType.FREE
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
keys = BatchService.get_keys_for_compensation(
db,
membership_type=mt,
activated_before=activated_before_dt,
not_expired_on=not_expired_on_dt
)
now = datetime.now()
return {
"total_matched": len(keys),
"keys": [{"id": k.id, "key": k.key[:8] + "...", "membership_type": k.membership_type.value,
"expire_at": k.expire_at.strftime("%Y-%m-%d %H:%M") if k.expire_at else "永久",
"activated_at": k.first_activated_at.strftime("%Y-%m-%d") if k.first_activated_at else "-",
"is_expired": k.expire_at < now if k.expire_at else False} for k in keys[:20]],
"message": f"共找到 {len(keys)} 个符合条件的密钥" + ("仅显示前20个" if len(keys) > 20 else "")
}
# ========== 设备记录 ==========
@router.get("/keys/{key_id}/devices")
async def get_key_devices(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取激活码绑定的设备列表"""
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
devices = db.query(KeyDevice).filter(KeyDevice.key_id == key_id).order_by(KeyDevice.created_at.desc()).all()
return [{
"id": d.id,
"device_id": d.device_id,
"device_name": d.device_name,
"last_active_at": d.last_active_at.strftime("%Y-%m-%d %H:%M:%S") if d.last_active_at else None,
"created_at": d.created_at.strftime("%Y-%m-%d %H:%M:%S") if d.created_at else None
} for d in devices]
@router.delete("/devices/{device_id}")
async def delete_device(
device_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除设备绑定"""
device = db.query(KeyDevice).filter(KeyDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
db.delete(device)
db.commit()
return {"message": "删除成功"}
# ========== 使用日志 (Usage Logs) ==========
@router.get("/logs")
async def get_logs(
key_id: Optional[int] = Query(None, description="按激活码ID筛选"),
action: Optional[str] = Query(None, description="操作类型: verify/switch"),
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取使用日志"""
query = db.query(UsageLog).order_by(UsageLog.created_at.desc())
if key_id:
query = query.filter(UsageLog.key_id == key_id)
if action:
query = query.filter(UsageLog.action == action)
logs = query.offset(skip).limit(limit).all()
# 获取关联的激活码信息
key_ids = list(set(log.key_id for log in logs))
keys_map = {}
if key_ids:
keys = db.query(ActivationKey).filter(ActivationKey.id.in_(key_ids)).all()
keys_map = {k.id: k.key[:8] + "..." for k in keys}
return [{
"id": log.id,
"key_id": log.key_id,
"key_preview": keys_map.get(log.key_id, "-"),
"account_id": log.account_id,
"action": log.action,
"ip_address": log.ip_address,
"success": log.success,
"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]
@router.get("/keys/{key_id}/logs")
async def get_key_logs(
key_id: int,
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取指定激活码的使用日志"""
key = KeyService.get_by_id(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="激活码不存在")
logs = db.query(UsageLog).filter(
UsageLog.key_id == key_id
).order_by(UsageLog.created_at.desc()).offset(skip).limit(limit).all()
return [{
"id": log.id,
"action": log.action,
"account_id": log.account_id,
"ip_address": log.ip_address,
"success": log.success,
"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]

148
backend/app/api/client.py Normal file
View File

@@ -0,0 +1,148 @@
"""
客户端 API - 兼容原 CursorPro 插件
"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.services import AccountService, KeyService, LogService, GlobalSettingsService
from app.schemas import VerifyKeyRequest, VerifyKeyResponse, SwitchAccountRequest, SwitchAccountResponse
router = APIRouter(prefix="/api", tags=["Client API"])
@router.post("/verify-key", response_model=VerifyKeyResponse)
async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)):
"""验证激活码"""
key = KeyService.get_by_key(db, request.key)
if not key:
return VerifyKeyResponse(success=False, error="激活码不存在")
# 首次激活:设置激活时间和过期时间
KeyService.activate(db, key)
# 检查设备限制
if request.device_id:
device_ok, device_msg = KeyService.check_device(db, key, request.device_id)
if not device_ok:
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=device_msg)
return VerifyKeyResponse(success=False, error=device_msg)
# 检查激活码是否有效
is_valid, message = KeyService.is_valid(key, db)
if not is_valid:
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=message)
return VerifyKeyResponse(success=False, error=message)
# 获取当前绑定的账号,或分配新账号
account = None
if key.current_account_id:
account = AccountService.get_by_id(db, key.current_account_id)
if not account or account.status != "active":
# 分配新账号
account = AccountService.get_available(db, key.membership_type)
if not account:
LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message="无可用账号")
return VerifyKeyResponse(success=False, error="暂无可用账号,请稍后重试")
KeyService.bind_account(db, key, account)
AccountService.mark_used(db, account, key.id)
LogService.log(db, key.id, "verify", account.id, ip_address=req.client.host, success=True)
expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None
quota_cost = KeyService.get_quota_cost(db, key.membership_type)
return VerifyKeyResponse(
success=True,
email=account.email,
accessToken=account.access_token,
refreshToken=account.refresh_token,
WorkosCursorSessionToken=account.workos_session_token,
membership_type=account.membership_type.value,
quota=key.quota,
quotaUsed=key.quota_used,
quotaRemaining=key.quota - key.quota_used,
quotaCost=quota_cost,
expireDate=expire_date
)
@router.post("/switch-account", response_model=SwitchAccountResponse)
async def switch_account(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)):
"""切换账号"""
key = KeyService.get_by_key(db, request.key)
if not key:
return SwitchAccountResponse(success=False, error="激活码不存在")
# 检查设备限制
if request.device_id:
device_ok, device_msg = KeyService.check_device(db, key, request.device_id)
if not device_ok:
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=device_msg)
return SwitchAccountResponse(success=False, error=device_msg)
# 检查激活码是否有效
is_valid, message = KeyService.is_valid(key, db)
if not is_valid:
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=message)
return SwitchAccountResponse(success=False, error=message)
# 检查换号频率限制
can_switch, switch_msg = KeyService.can_switch(db, key)
if not can_switch:
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message=switch_msg)
return SwitchAccountResponse(success=False, error=switch_msg)
# 释放当前账号
if key.current_account_id:
old_account = AccountService.get_by_id(db, key.current_account_id)
if old_account:
AccountService.release(db, old_account)
# 获取新账号
account = AccountService.get_available(db, key.membership_type)
if not account:
LogService.log(db, key.id, "switch", ip_address=req.client.host, success=False, message="无可用账号")
return SwitchAccountResponse(success=False, error="暂无可用账号,请稍后重试")
# 绑定新账号并扣除额度
KeyService.bind_account(db, key, account)
KeyService.use_switch(db, key)
AccountService.mark_used(db, account, key.id)
LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True)
return SwitchAccountResponse(
success=True,
message="切换成功",
email=account.email,
accessToken=account.access_token,
refreshToken=account.refresh_token,
WorkosCursorSessionToken=account.workos_session_token,
membership_type=account.membership_type.value,
quotaUsed=key.quota_used,
quotaRemaining=key.quota - key.quota_used
)
@router.get("/version")
async def get_version():
"""获取版本信息"""
return {
"version": "1.0.0",
"update_url": None,
"message": None
}
@router.get("/seamless/status")
async def seamless_status():
"""无感换号状态(简化实现)"""
return {
"success": True,
"enabled": False,
"message": "无感换号功能暂未开放"
}

35
backend/app/config.py Normal file
View File

@@ -0,0 +1,35 @@
import os
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# 数据库配置
USE_SQLITE: bool = True # 设为 False 使用 MySQL
DB_HOST: str = "localhost"
DB_PORT: int = 3306
DB_USER: str = "root"
DB_PASSWORD: str = ""
DB_NAME: str = "cursorpro"
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
# 管理员账号
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin123"
@property
def DATABASE_URL(self) -> str:
if self.USE_SQLITE:
# SQLite 用于本地测试
db_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "cursorpro.db")
return f"sqlite:///{db_path}"
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
class Config:
env_file = ".env"
extra = "allow"
settings = Settings()

29
backend/app/database.py Normal file
View File

@@ -0,0 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
# SQLite 不支持某些连接池选项
if settings.USE_SQLITE:
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False
)
else:
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
echo=False
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

69
backend/app/main.py Normal file
View File

@@ -0,0 +1,69 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, FileResponse
from contextlib import asynccontextmanager
import os
from app.database import engine, Base
from app.api import client_router, admin_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时创建数据库表
Base.metadata.create_all(bind=engine)
yield
# 关闭时清理
app = FastAPI(
title="CursorPro 管理后台",
description="Cursor 账号管理系统 API",
version="1.0.0",
lifespan=lifespan
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件
static_path = os.path.join(os.path.dirname(__file__), "..", "static")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
# 模板路径
templates_path = os.path.join(os.path.dirname(__file__), "..", "templates")
# 注册路由
app.include_router(client_router)
app.include_router(admin_router)
@app.get("/", response_class=HTMLResponse)
async def index():
"""管理后台首页"""
index_file = os.path.join(templates_path, "index.html")
if os.path.exists(index_file):
return FileResponse(index_file, media_type="text/html")
return HTMLResponse(content="""
<html>
<head><title>CursorPro 管理后台</title></head>
<body>
<h1>CursorPro 管理后台</h1>
<p>请访问 <a href="/docs">/docs</a> 查看 API 文档</p>
</body>
</html>
""")
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "ok"}

View File

@@ -0,0 +1 @@
from app.models.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, GlobalSettings, MembershipType, AccountStatus, KeyStatus

View File

@@ -0,0 +1,130 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MembershipType(str, enum.Enum):
FREE = "free"
PRO = "pro"
class AccountStatus(str, enum.Enum):
ACTIVE = "active" # 可用
IN_USE = "in_use" # 使用中
DISABLED = "disabled" # 禁用
EXPIRED = "expired" # 过期
class KeyStatus(str, enum.Enum):
ACTIVE = "active"
DISABLED = "disabled"
EXPIRED = "expired"
class CursorAccount(Base):
"""Cursor 账号池"""
__tablename__ = "cursor_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), unique=True, nullable=False, comment="邮箱")
access_token = Column(Text, nullable=False, comment="访问令牌")
refresh_token = Column(Text, nullable=True, comment="刷新令牌")
workos_session_token = Column(Text, nullable=True, comment="WorkOS会话令牌")
membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="会员类型")
status = Column(Enum(AccountStatus), default=AccountStatus.ACTIVE, comment="状态")
# 使用统计
usage_count = Column(Integer, default=0, comment="使用次数")
last_used_at = Column(DateTime, nullable=True, comment="最后使用时间")
current_key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=True, comment="当前使用的激活码")
# 备注
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ActivationKey(Base):
"""激活码"""
__tablename__ = "activation_keys"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(64), unique=True, nullable=False, index=True, comment="激活码")
status = Column(Enum(KeyStatus), default=KeyStatus.ACTIVE, comment="状态")
# 套餐类型
membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="套餐类型: free=无限auto, pro=高级模型")
# 额度系统
quota = Column(Integer, default=500, comment="总额度")
quota_used = Column(Integer, default=0, comment="已用额度")
# 有效期设置
valid_days = Column(Integer, default=30, comment="有效天数(0表示永久)")
first_activated_at = Column(DateTime, nullable=True, comment="首次激活时间")
expire_at = Column(DateTime, nullable=True, comment="过期时间(首次激活时计算)")
# 设备限制
max_devices = Column(Integer, default=2, comment="最大设备数")
# 换号频率限制(已废弃,现由全局设置控制)
switch_interval_minutes = Column(Integer, default=30, comment="[已废弃]换号间隔(分钟)")
switch_limit_per_interval = Column(Integer, default=2, 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="最后换号时间")
# 备注
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class KeyDevice(Base):
"""激活码绑定的设备"""
__tablename__ = "key_devices"
id = Column(Integer, primary_key=True, autoincrement=True)
key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False)
device_id = Column(String(255), nullable=False, comment="设备标识")
device_name = Column(String(255), nullable=True, comment="设备名称")
last_active_at = Column(DateTime, nullable=True, comment="最后活跃时间")
created_at = Column(DateTime, server_default=func.now())
key = relationship("ActivationKey")
class GlobalSettings(Base):
"""全局设置"""
__tablename__ = "global_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, comment="设置键")
value = Column(String(500), nullable=False, comment="设置值")
description = Column(String(500), nullable=True, comment="描述")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class UsageLog(Base):
"""使用日志"""
__tablename__ = "usage_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=False)
account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True)
action = Column(String(50), nullable=False, comment="操作类型: verify/switch/seamless")
ip_address = Column(String(50), nullable=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=True)
message = Column(String(500), nullable=True)
created_at = Column(DateTime, server_default=func.now())
key = relationship("ActivationKey")
account = relationship("CursorAccount")

View File

@@ -0,0 +1 @@
from app.schemas.schemas import *

View File

@@ -0,0 +1,186 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, List
from datetime import datetime
from app.models.models import MembershipType, AccountStatus, KeyStatus
# ========== 账号相关 ==========
class AccountBase(BaseModel):
email: str
access_token: str
refresh_token: Optional[str] = None
workos_session_token: Optional[str] = None
membership_type: MembershipType = MembershipType.PRO
remark: Optional[str] = None
class AccountCreate(AccountBase):
pass
class AccountUpdate(BaseModel):
email: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
workos_session_token: Optional[str] = None
membership_type: Optional[MembershipType] = None
status: Optional[AccountStatus] = None
remark: Optional[str] = None
class AccountResponse(AccountBase):
id: int
status: AccountStatus
usage_count: int
last_used_at: Optional[datetime] = None
current_key_id: Optional[int] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AccountImport(BaseModel):
"""批量导入账号"""
accounts: List[AccountCreate]
# ========== 激活码相关 ==========
class KeyBase(BaseModel):
membership_type: MembershipType = MembershipType.PRO # pro=高级模型, free=无限auto
quota: int = 500 # 总额度 (仅Pro有效)
valid_days: int = 30 # 有效天数0表示永久 (仅Auto有效)
max_devices: int = 2 # 最大设备数
remark: Optional[str] = None
class KeyCreate(KeyBase):
key: Optional[str] = None # 不提供则自动生成
count: int = 1 # 批量生成数量
class KeyUpdate(BaseModel):
membership_type: Optional[MembershipType] = None
quota: Optional[int] = None
valid_days: Optional[int] = None
max_devices: Optional[int] = None
status: Optional[KeyStatus] = None
remark: Optional[str] = None
class KeyAddQuota(BaseModel):
"""叠加额度(只加额度不加时间)"""
add_quota: int
class KeyResponse(BaseModel):
id: int
key: str
status: KeyStatus
membership_type: MembershipType
quota: int
quota_used: int
quota_remaining: Optional[int] = None # 剩余额度(计算字段)
valid_days: int
first_activated_at: Optional[datetime] = None
expire_at: Optional[datetime] = None
max_devices: int
switch_count: int
last_switch_at: Optional[datetime] = None
current_account_id: Optional[int] = None
remark: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ========== 客户端 API 相关 ==========
class VerifyKeyRequest(BaseModel):
key: str
device_id: Optional[str] = None # 设备标识
class VerifyKeyResponse(BaseModel):
success: bool
message: Optional[str] = None
error: Optional[str] = None
# 成功时返回
email: Optional[str] = None
accessToken: Optional[str] = None
refreshToken: Optional[str] = None
WorkosCursorSessionToken: Optional[str] = None
membership_type: Optional[str] = None
# 额度信息
quota: Optional[int] = None # 总额度
quotaUsed: Optional[int] = None # 已用额度
quotaRemaining: Optional[int] = None # 剩余额度
quotaCost: Optional[int] = None # 每次换号消耗
expireDate: Optional[str] = None
class SwitchAccountRequest(BaseModel):
key: str
device_id: Optional[str] = None # 设备标识
class SwitchAccountResponse(BaseModel):
success: bool
message: Optional[str] = None
error: Optional[str] = None
email: Optional[str] = None
accessToken: Optional[str] = None
refreshToken: Optional[str] = None
WorkosCursorSessionToken: Optional[str] = None
membership_type: Optional[str] = None
# 额度信息
quotaUsed: Optional[int] = None
quotaRemaining: Optional[int] = None
# ========== 统计相关 ==========
class DashboardStats(BaseModel):
total_accounts: int
active_accounts: int
pro_accounts: int
total_keys: int
active_keys: int
today_usage: int
# ========== 认证相关 ==========
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
username: str
password: str
# ========== 全局设置相关 ==========
class GlobalSettingsResponse(BaseModel):
"""全局设置响应"""
# Auto密钥设置
auto_switch_interval_minutes: int = 20 # 换号最小间隔(分钟)
auto_max_switches_per_day: int = 50 # 每天最大换号次数
# Pro密钥设置
pro_quota_cost: int = 50 # 每次换号扣除额度
class GlobalSettingsUpdate(BaseModel):
"""更新全局设置"""
auto_switch_interval_minutes: Optional[int] = None
auto_max_switches_per_day: Optional[int] = None
pro_quota_cost: Optional[int] = None
# ========== 批量操作相关 ==========
class BatchExtendRequest(BaseModel):
"""批量延长请求"""
key_ids: List[int] # 激活码ID列表
extend_days: int = 0 # 延长天数Auto和Pro都可用
add_quota: int = 0 # 增加额度仅Pro有效
class BatchExtendResponse(BaseModel):
"""批量延长响应"""
success: int
failed: int
errors: List[str] = []

View File

@@ -0,0 +1,2 @@
from app.services.account_service import AccountService, KeyService, LogService, GlobalSettingsService, BatchService
from app.services.auth_service import authenticate_admin, create_access_token, get_current_user

View File

@@ -0,0 +1,547 @@
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from app.models import CursorAccount, ActivationKey, KeyDevice, UsageLog, MembershipType, AccountStatus, KeyStatus, GlobalSettings
from app.schemas import AccountCreate, KeyCreate
def generate_key(length: int = 32) -> str:
"""生成随机激活码"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
class AccountService:
"""账号管理服务"""
@staticmethod
def create(db: Session, account: AccountCreate) -> CursorAccount:
"""创建账号"""
db_account = CursorAccount(
email=account.email,
access_token=account.access_token,
refresh_token=account.refresh_token,
workos_session_token=account.workos_session_token,
membership_type=account.membership_type,
remark=account.remark
)
db.add(db_account)
db.commit()
db.refresh(db_account)
return db_account
@staticmethod
def get_by_id(db: Session, account_id: int) -> Optional[CursorAccount]:
return db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
@staticmethod
def get_by_email(db: Session, email: str) -> Optional[CursorAccount]:
return db.query(CursorAccount).filter(CursorAccount.email == email).first()
@staticmethod
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[CursorAccount]:
return db.query(CursorAccount).offset(skip).limit(limit).all()
@staticmethod
def get_available(db: Session, membership_type: MembershipType = None) -> Optional[CursorAccount]:
"""获取一个可用账号"""
query = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE)
if membership_type:
query = query.filter(CursorAccount.membership_type == membership_type)
# 优先选择使用次数少的
return query.order_by(CursorAccount.usage_count.asc()).first()
@staticmethod
def update(db: Session, account_id: int, **kwargs) -> Optional[CursorAccount]:
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
for key, value in kwargs.items():
if hasattr(account, key) and value is not None:
setattr(account, key, value)
db.commit()
db.refresh(account)
return account
@staticmethod
def delete(db: Session, account_id: int) -> bool:
account = db.query(CursorAccount).filter(CursorAccount.id == account_id).first()
if account:
db.delete(account)
db.commit()
return True
return False
@staticmethod
def mark_used(db: Session, account: CursorAccount, key_id: int = None):
"""标记账号被使用"""
account.usage_count += 1
account.last_used_at = datetime.now()
account.status = AccountStatus.IN_USE
if key_id:
account.current_key_id = key_id
db.commit()
@staticmethod
def release(db: Session, account: CursorAccount):
"""释放账号"""
account.status = AccountStatus.ACTIVE
account.current_key_id = None
db.commit()
@staticmethod
def count(db: Session) -> dict:
"""统计账号数量"""
total = db.query(CursorAccount).count()
active = db.query(CursorAccount).filter(CursorAccount.status == AccountStatus.ACTIVE).count()
pro = db.query(CursorAccount).filter(CursorAccount.membership_type == MembershipType.PRO).count()
return {"total": total, "active": active, "pro": pro}
class KeyService:
"""激活码管理服务"""
@staticmethod
def create(db: Session, key_data: KeyCreate) -> List[ActivationKey]:
"""创建激活码(支持批量)"""
keys = []
max_retries = 5 # 最大重试次数
for _ in range(key_data.count):
# 生成唯一的key如果冲突则重试
for retry in range(max_retries):
key_str = key_data.key if key_data.key and key_data.count == 1 else generate_key()
# 检查key是否已存在
existing = db.query(ActivationKey).filter(ActivationKey.key == key_str).first()
if not existing:
break
if retry == max_retries - 1:
raise ValueError(f"无法生成唯一激活码,请重试")
db_key = ActivationKey(
key=key_str,
membership_type=key_data.membership_type,
quota=key_data.quota if key_data.membership_type == MembershipType.PRO else 0, # Free不需要额度
valid_days=key_data.valid_days,
max_devices=key_data.max_devices,
remark=key_data.remark
)
db.add(db_key)
keys.append(db_key)
db.commit()
for k in keys:
db.refresh(k)
return keys
@staticmethod
def get_by_key(db: Session, key: str) -> Optional[ActivationKey]:
return db.query(ActivationKey).filter(ActivationKey.key == key).first()
@staticmethod
def get_by_id(db: Session, key_id: int) -> Optional[ActivationKey]:
return db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
@staticmethod
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[ActivationKey]:
return db.query(ActivationKey).order_by(ActivationKey.id.desc()).offset(skip).limit(limit).all()
@staticmethod
def update(db: Session, key_id: int, **kwargs) -> Optional[ActivationKey]:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if key:
for k, v in kwargs.items():
if hasattr(key, k) and v is not None:
setattr(key, k, v)
db.commit()
db.refresh(key)
return key
@staticmethod
def delete(db: Session, key_id: int) -> bool:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if key:
# 删除关联的设备记录
db.query(KeyDevice).filter(KeyDevice.key_id == key_id).delete()
db.delete(key)
db.commit()
return True
return False
@staticmethod
def activate(db: Session, key: ActivationKey):
"""首次激活:设置激活时间和过期时间"""
if key.first_activated_at is None:
key.first_activated_at = datetime.now()
if key.valid_days > 0:
key.expire_at = key.first_activated_at + timedelta(days=key.valid_days)
db.commit()
@staticmethod
def is_valid(key: ActivationKey, db: Session) -> Tuple[bool, str]:
"""检查激活码是否有效"""
if key.status != KeyStatus.ACTIVE:
return False, "激活码已禁用"
# 检查是否已过期(只有激活后才检查)
if key.first_activated_at and key.expire_at and key.expire_at < datetime.now():
return False, "激活码已过期"
# Pro套餐检查额度
if key.membership_type == MembershipType.PRO:
quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost")
if key.quota_used + quota_cost > key.quota:
return False, f"额度不足,需要{quota_cost},剩余{key.quota - key.quota_used}"
return True, "有效"
@staticmethod
def can_switch(db: Session, key: ActivationKey) -> Tuple[bool, str]:
"""检查是否可以换号
- Auto: 检查换号间隔 + 每天最大次数(全局设置)
- Pro: 无频率限制只检查额度在is_valid中
"""
# Pro密钥无频率限制
if key.membership_type == MembershipType.PRO:
return True, "可以换号"
# === Auto密钥频率检查 ===
now = datetime.now()
# 1. 检查换号间隔
interval_minutes = GlobalSettingsService.get_int(db, "auto_switch_interval_minutes")
if key.last_switch_at:
minutes_since_last = (now - key.last_switch_at).total_seconds() / 60
if minutes_since_last < interval_minutes:
wait_minutes = int(interval_minutes - minutes_since_last)
return False, f"换号太频繁,请等待{wait_minutes}分钟"
# 2. 检查今日换号次数
max_per_day = GlobalSettingsService.get_int(db, "auto_max_switches_per_day")
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = db.query(UsageLog).filter(
UsageLog.key_id == key.id,
UsageLog.action == "switch",
UsageLog.success == True,
UsageLog.created_at >= today_start
).count()
if today_count >= max_per_day:
return False, f"今日换号次数已达上限({max_per_day}次)"
return True, "可以换号"
@staticmethod
def check_device(db: Session, key: ActivationKey, device_id: str) -> Tuple[bool, str]:
"""检查设备限制"""
if not device_id:
return True, "无设备ID"
# 查找现有设备
device = db.query(KeyDevice).filter(
KeyDevice.key_id == key.id,
KeyDevice.device_id == device_id
).first()
if device:
# 更新最后活跃时间
device.last_active_at = datetime.now()
db.commit()
return True, "设备已绑定"
# 检查设备数量
device_count = db.query(KeyDevice).filter(KeyDevice.key_id == key.id).count()
if device_count >= key.max_devices:
return False, f"设备数量已达上限({key.max_devices}个)"
# 添加新设备
new_device = KeyDevice(key_id=key.id, device_id=device_id, last_active_at=datetime.now())
db.add(new_device)
db.commit()
return True, "新设备已绑定"
@staticmethod
def use_switch(db: Session, key: ActivationKey):
"""使用一次换号Pro扣除额度Free不扣"""
if key.membership_type == MembershipType.PRO:
quota_cost = GlobalSettingsService.get_int(db, "pro_quota_cost")
key.quota_used += quota_cost
# Free不扣额度
key.switch_count += 1
key.last_switch_at = datetime.now()
db.commit()
@staticmethod
def get_quota_cost(db: Session, membership_type: MembershipType) -> int:
"""获取换号消耗的额度"""
if membership_type == MembershipType.PRO:
return GlobalSettingsService.get_int(db, "pro_quota_cost")
return 0 # Free不消耗额度
@staticmethod
def add_quota(db: Session, key: ActivationKey, add_quota: int):
"""叠加额度只加额度不加时间仅Pro有效"""
key.quota += add_quota
db.commit()
@staticmethod
def bind_account(db: Session, key: ActivationKey, account: CursorAccount):
"""绑定账号"""
key.current_account_id = account.id
db.commit()
@staticmethod
def count(db: Session) -> dict:
"""统计激活码数量"""
total = db.query(ActivationKey).count()
active = db.query(ActivationKey).filter(ActivationKey.status == KeyStatus.ACTIVE).count()
return {"total": total, "active": active}
class LogService:
"""日志服务"""
@staticmethod
def log(db: Session, key_id: int, action: str, account_id: int = None,
ip_address: str = None, user_agent: str = None,
success: bool = True, message: str = None):
log = UsageLog(
key_id=key_id,
account_id=account_id,
action=action,
ip_address=ip_address,
user_agent=user_agent,
success=success,
message=message
)
db.add(log)
db.commit()
@staticmethod
def get_today_count(db: Session) -> int:
today = datetime.now().date()
return db.query(UsageLog).filter(
func.date(UsageLog.created_at) == today
).count()
class GlobalSettingsService:
"""全局设置服务"""
# 默认设置
DEFAULT_SETTINGS = {
# Auto密钥设置
"auto_switch_interval_minutes": ("20", "Auto换号最小间隔(分钟)"),
"auto_max_switches_per_day": ("50", "Auto每天最大换号次数"),
# Pro密钥设置
"pro_quota_cost": ("50", "Pro每次换号扣除额度"),
}
@staticmethod
def init_settings(db: Session):
"""初始化默认设置"""
for key, (value, desc) in GlobalSettingsService.DEFAULT_SETTINGS.items():
existing = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if not existing:
setting = GlobalSettings(key=key, value=value, description=desc)
db.add(setting)
db.commit()
@staticmethod
def get(db: Session, key: str) -> Optional[str]:
"""获取单个设置"""
setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if setting:
return setting.value
# 返回默认值
if key in GlobalSettingsService.DEFAULT_SETTINGS:
return GlobalSettingsService.DEFAULT_SETTINGS[key][0]
return None
@staticmethod
def get_int(db: Session, key: str) -> int:
"""获取整数设置"""
value = GlobalSettingsService.get(db, key)
return int(value) if value else 0
@staticmethod
def set(db: Session, key: str, value: str, description: str = None):
"""设置单个配置"""
setting = db.query(GlobalSettings).filter(GlobalSettings.key == key).first()
if setting:
setting.value = value
if description:
setting.description = description
else:
setting = GlobalSettings(key=key, value=value, description=description)
db.add(setting)
db.commit()
@staticmethod
def get_all(db: Session) -> dict:
"""获取所有设置"""
return {
"auto_switch_interval_minutes": GlobalSettingsService.get_int(db, "auto_switch_interval_minutes"),
"auto_max_switches_per_day": GlobalSettingsService.get_int(db, "auto_max_switches_per_day"),
"pro_quota_cost": GlobalSettingsService.get_int(db, "pro_quota_cost"),
}
@staticmethod
def update_all(db: Session, **kwargs):
"""批量更新设置"""
for key, value in kwargs.items():
if value is not None:
GlobalSettingsService.set(db, key, str(value))
class BatchService:
"""批量操作服务"""
@staticmethod
def extend_keys(db: Session, key_ids: List[int], extend_days: int = 0, add_quota: int = 0) -> dict:
"""批量延长密钥
- Auto密钥只能延长到期时间
- Pro密钥可以延长到期时间 + 增加额度
"""
success = 0
failed = 0
errors = []
for key_id in key_ids:
try:
key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first()
if not key:
failed += 1
errors.append(f"ID {key_id}: 密钥不存在")
continue
# 延长到期时间
if extend_days > 0:
if key.expire_at:
# 已激活:在当前到期时间基础上延长
key.expire_at = key.expire_at + timedelta(days=extend_days)
else:
# 未激活:增加有效天数
key.valid_days += extend_days
# 增加额度仅Pro有效
if add_quota > 0 and key.membership_type == MembershipType.PRO:
key.quota += add_quota
db.commit()
success += 1
except Exception as e:
failed += 1
errors.append(f"ID {key_id}: {str(e)}")
return {"success": success, "failed": failed, "errors": errors[:10]}
@staticmethod
def get_keys_for_compensation(
db: Session,
membership_type: MembershipType = None,
activated_before: datetime = None,
not_expired_on: datetime = None,
) -> List[ActivationKey]:
"""获取符合补偿条件的密钥列表
- membership_type: 筛选套餐类型 (pro/free)
- activated_before: 在此日期之前激活的 (first_activated_at < activated_before)
- not_expired_on: 在此日期时还未过期的 (expire_at > not_expired_on)
例如补偿12月4号之前激活、且12月4号还没过期的用户
activated_before = 2024-12-05 (12月4号之前即<12月5号0点)
not_expired_on = 2024-12-04 (12月4号还没过期即expire_at > 12月4号)
"""
query = db.query(ActivationKey)
if membership_type:
query = query.filter(ActivationKey.membership_type == membership_type)
# 只选择状态为active的
query = query.filter(ActivationKey.status == KeyStatus.ACTIVE)
# 只选择已激活的(有激活时间的)
query = query.filter(ActivationKey.first_activated_at != None)
if activated_before:
# 在指定日期之前激活的
query = query.filter(ActivationKey.first_activated_at < activated_before)
if not_expired_on:
# 在指定日期时还未过期的 (expire_at > 指定日期 或 永久卡)
query = query.filter(
or_(
ActivationKey.expire_at == None, # 永久卡
ActivationKey.expire_at > not_expired_on # 在那天还没过期
)
)
return query.all()
@staticmethod
def batch_compensate(
db: Session,
membership_type: MembershipType = None,
activated_before: datetime = None,
not_expired_on: datetime = None,
extend_days: int = 0,
add_quota: int = 0
) -> dict:
"""批量补偿 - 根据条件筛选并补偿
补偿逻辑:
- 如果卡当前还没过期expire_at += extend_days
- 如果卡已过期但符合补偿条件expire_at = 今天 + extend_days恢复使用
例如: 补偿12月4号之前激活、12月4号还没过期的Auto密钥延长1天
"""
keys = BatchService.get_keys_for_compensation(
db,
membership_type=membership_type,
activated_before=activated_before,
not_expired_on=not_expired_on
)
if not keys:
return {"success": 0, "failed": 0, "total_matched": 0, "recovered": 0, "errors": ["没有符合条件的密钥"]}
success = 0
failed = 0
recovered = 0 # 恢复使用的数量
errors = []
now = datetime.now()
for key in keys:
try:
# 延长到期时间
if extend_days > 0 and key.expire_at:
if key.expire_at > now:
# 还没过期:在当前过期时间上加天数
key.expire_at = key.expire_at + timedelta(days=extend_days)
else:
# 已过期:恢复使用,设为今天+补偿天数
key.expire_at = now + timedelta(days=extend_days)
recovered += 1
# 增加额度仅Pro有效
if add_quota > 0 and key.membership_type == MembershipType.PRO:
key.quota += add_quota
db.commit()
success += 1
except Exception as e:
failed += 1
errors.append(f"ID {key.id}: {str(e)}")
return {
"success": success,
"failed": failed,
"total_matched": len(keys),
"recovered": recovered,
"errors": errors[:10]
}

View File

@@ -0,0 +1,59 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
def authenticate_admin(username: str, password: str) -> bool:
"""验证管理员账号"""
return username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""获取当前用户"""
token = credentials.credentials
payload = verify_token(token)
username = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据"
)
return {"username": username}

View File

@@ -0,0 +1,53 @@
version: '3.8'
services:
# MySQL 数据库
mysql:
image: mysql:8.0
container_name: cursorpro-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root123456}
MYSQL_DATABASE: ${DB_NAME:-cursorpro}
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
- "${DB_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 5s
retries: 10
# CursorPro 后台服务
backend:
build:
context: .
dockerfile: Dockerfile
container_name: cursorpro-backend
restart: unless-stopped
ports:
- "${PORT:-8000}:8000"
environment:
- DB_HOST=mysql
- DB_PORT=3306
- DB_USER=root
- DB_PASSWORD=${DB_PASSWORD:-root123456}
- DB_NAME=${DB_NAME:-cursorpro}
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-this-secret-key}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
depends_on:
mysql:
condition: service_healthy
volumes:
- ./app:/app/app:ro
- ./templates:/app/templates:ro
volumes:
mysql_data:

64
backend/init.sql Normal file
View File

@@ -0,0 +1,64 @@
-- CursorPro 数据库初始化脚本
-- 如果需要手动建表可以使用此脚本FastAPI 启动时会自动创建表
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS cursorpro
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE cursorpro;
-- cursor_accounts 表
CREATE TABLE IF NOT EXISTS cursor_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
access_token TEXT NOT NULL,
refresh_token TEXT,
workos_session_token TEXT,
membership_type ENUM('free', 'pro') DEFAULT 'pro',
status ENUM('active', 'in_use', 'disabled', 'expired') DEFAULT 'active',
usage_count INT DEFAULT 0,
current_key_id INT,
last_used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_membership_type (membership_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- activation_keys 表
CREATE TABLE IF NOT EXISTS activation_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
`key` VARCHAR(64) NOT NULL UNIQUE,
switch_limit INT DEFAULT 100,
switch_count INT DEFAULT 0,
membership_type ENUM('free', 'pro') DEFAULT 'pro',
status ENUM('active', 'disabled') DEFAULT 'active',
current_account_id INT,
expire_at DATETIME,
remark VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_key (`key`),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- usage_logs 表
CREATE TABLE IF NOT EXISTS usage_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
key_id INT NOT NULL,
account_id INT,
action ENUM('verify', 'switch') NOT NULL,
ip_address VARCHAR(45),
success BOOLEAN DEFAULT TRUE,
message VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_key_id (key_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 插入示例激活码(可选)
-- INSERT INTO activation_keys (`key`, switch_limit, membership_type) VALUES ('TEST-KEY-0001', 100, 'pro');

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
pymysql==1.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
jinja2==3.1.3
aiofiles==23.2.1
pydantic==2.5.3
pydantic-settings==2.1.0

40
backend/run.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
CursorPro 后台管理系统启动脚本
"""
import uvicorn
import os
import sys
# 确保项目路径在 Python 路径中
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def main():
"""启动服务"""
# 从环境变量或默认值获取配置
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "8000"))
reload = os.getenv("RELOAD", "true").lower() == "true"
print(f"""
╔═══════════════════════════════════════════════════════════╗
║ CursorPro 后台管理系统 ║
╠═══════════════════════════════════════════════════════════╣
║ 管理后台: http://{host}:{port}/
║ API 文档: http://{host}:{port}/docs
║ 健康检查: http://{host}:{port}/health
╚═══════════════════════════════════════════════════════════╝
""")
uvicorn.run(
"app.main:app",
host=host,
port=port,
reload=reload,
log_level="info"
)
if __name__ == "__main__":
main()

42
backend/start.bat Normal file
View File

@@ -0,0 +1,42 @@
@echo off
chcp 65001 >nul
echo.
echo ╔═══════════════════════════════════════════════════════════╗
echo ║ CursorPro 后台管理系统 ║
echo ╚═══════════════════════════════════════════════════════════╝
echo.
REM 检查 Python
python --version >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 Python请先安装 Python 3.8+
pause
exit /b 1
)
REM 检查虚拟环境
if not exist "venv" (
echo [信息] 创建虚拟环境...
python -m venv venv
)
REM 激活虚拟环境
call venv\Scripts\activate.bat
REM 安装依赖
echo [信息] 检查依赖...
pip install -r requirements.txt -q
REM 检查 .env 文件
if not exist ".env" (
echo [信息] 创建 .env 配置文件...
copy .env.example .env
echo [警告] 请编辑 .env 文件配置数据库连接!
)
echo.
echo [信息] 启动服务...
echo.
python run.py
pause

38
backend/start.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ CursorPro 后台管理系统 ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
# 检查 Python
if ! command -v python3 &> /dev/null; then
echo "[错误] 未找到 Python请先安装 Python 3.8+"
exit 1
fi
# 检查虚拟环境
if [ ! -d "venv" ]; then
echo "[信息] 创建虚拟环境..."
python3 -m venv venv
fi
# 激活虚拟环境
source venv/bin/activate
# 安装依赖
echo "[信息] 检查依赖..."
pip install -r requirements.txt -q
# 检查 .env 文件
if [ ! -f ".env" ]; then
echo "[信息] 创建 .env 配置文件..."
cp .env.example .env
echo "[警告] 请编辑 .env 文件配置数据库连接!"
fi
echo ""
echo "[信息] 启动服务..."
echo ""
python run.py

1
backend/static/.gitkeep Normal file
View File

@@ -0,0 +1 @@
/* 静态文件目录占位 */

1243
backend/templates/index.html Normal file

File diff suppressed because it is too large Load Diff