备份: 完整开发状态(含反混淆脚本和临时文件)

This commit is contained in:
ccdojox-crypto
2025-12-17 17:18:02 +08:00
parent 9e2333c90c
commit 7e9ea173a7
2872 changed files with 326818 additions and 249 deletions

123
backend/DEPLOY.md Normal file
View File

@@ -0,0 +1,123 @@
# 蜂鸟CursorPro 后端部署指南
## 1. 上传文件
将整个 `backend/` 目录上传到服务器,例如 `/opt/cursorpro/`
## 2. 安装依赖
```bash
cd /opt/cursorpro
pip3 install -r requirements.txt
```
## 3. 修改配置
编辑 `app/config.py`
```python
# 修改管理员密码
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "你的强密码"
# 修改JWT密钥 (随机字符串)
SECRET_KEY: str = "随机生成一个长字符串"
# 修改外部API Token
API_TOKEN: str = "你的API密钥"
```
## 4. 启动服务
### 方式1: 直接运行 (测试)
```bash
python3 run.py
```
### 方式2: 使用 systemd (生产推荐)
创建 `/etc/systemd/system/cursorpro.service`
```ini
[Unit]
Description=CursorPro Backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cursorpro
ExecStart=/usr/bin/python3 run.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
systemctl daemon-reload
systemctl enable cursorpro
systemctl start cursorpro
systemctl status cursorpro
```
### 方式3: 使用 PM2 (Node.js 环境)
```bash
pm2 start run.py --name cursorpro --interpreter python3
pm2 save
```
## 5. Nginx 反向代理 (推荐)
```nginx
server {
listen 443 ssl;
server_name api.aicode.edu.pl;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 6. 访问地址
- 管理后台: https://api.aicode.edu.pl/
- API文档: https://api.aicode.edu.pl/docs
- 健康检查: https://api.aicode.edu.pl/health
## 7. 数据库
默认使用 SQLite数据库文件`cursorpro.db`
如需备份,复制此文件即可。
## 8. 多域名反代配置
在其他服务器配置反代指向主后台:
```nginx
# 备用域名服务器 (api2.aicode.edu.pl)
server {
listen 443 ssl;
server_name api2.aicode.edu.pl;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass https://api.aicode.edu.pl;
proxy_set_header Host api.aicode.edu.pl;
proxy_ssl_server_name on;
}
}
```

View File

@@ -1,131 +1,111 @@
# CursorPro 后台管理系统
# 蜂鸟CursorPro 后
基于 FastAPI 的 Cursor 账号管理和激活码系统,兼容原 CursorPro 插件 API。
## 域名配置
## 功能特性
| 类型 | 域名 | 说明 |
|------|------|------|
| 主域名 | `api.aicode.edu.pl` | 主后台服务器 |
| 备用域名1 | `hb.aicode.edu.pl` | 反代到主域名 |
| 备用域名2 | `cursor.aicode.edu.pl` | 反代到主域名 |
| 备用域名3 | `pro.aicode.edu.pl` | 反代到主域名 |
- 账号管理:导入、编辑、删除 Cursor 账号
- 激活码系统:生成、管理激活码,支持换号次数限制
- Web 管理后台Vue.js + Tailwind CSS 构建的现代化界面
- 客户端 API完全兼容原 CursorPro 插件
## 登录信息
## 快速开始
```
管理后台: https://api.aicode.edu.pl/
用户名: admin
密码: Hb@2024Pro!
```
### 方式一:本地运行
## 外部API
1. **安装 MySQL 数据库**
```
Token: hb-ext-9kX2mP5nQ8rT1vY4zA7c
Header: X-API-Token
2. **配置环境**
```bash
# 复制配置文件
cp .env.example .env
批量上传: POST /admin/external/accounts/batch
账号统计: GET /admin/external/accounts/stats
```
# 编辑 .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
# 上传到服务器
cd /opt/cursorpro
# 启动 (首次自动创建venv并安装依赖)
./start.sh
# 停止
./stop.sh
# 查看日志
docker-compose logs -f backend
tail -f cursorpro.log
```
## API 文档
部署信息:
- 管理后台: https://api.aicode.edu.pl/
- API文档: https://api.aicode.edu.pl/docs
- 健康检查: ✅ {"status":"ok"}
启动后访问 http://localhost:8000/docs 查看 Swagger API 文档。
登录信息:
用户名: admin
密码: Hb@2024Pro!
### 客户端 API兼容原插件
外部API Token: hb-ext-9kX2mP5nQ8rT1vY4zA7c
| 接口 | 方法 | 说明 |
|------|------|------|
| `/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 | 激活码列表/生成 |
## Nginx 配置
## 账号数据格式
### 主域名 (api.aicode.edu.pl)
```nginx
server {
listen 443 ssl http2;
server_name api.aicode.edu.pl;
导入账号时使用 JSON 格式:
ssl_certificate /etc/ssl/api.aicode.edu.pl.pem;
ssl_certificate_key /etc/ssl/api.aicode.edu.pl.key;
```json
[
{
"email": "user@example.com",
"access_token": "...",
"refresh_token": "...",
"workos_session_token": "...",
"membership_type": "pro"
}
]
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 目录结构
### 备用域名 (反代到主域名)
```nginx
server {
listen 443 ssl http2;
server_name hb.aicode.edu.pl; # 或 cursor.aicode.edu.pl / pro.aicode.edu.pl
```
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 # 快捷启动
ssl_certificate /etc/ssl/hb.aicode.edu.pl.pem;
ssl_certificate_key /etc/ssl/hb.aicode.edu.pl.key;
location / {
proxy_pass https://api.aicode.edu.pl;
proxy_set_header Host api.aicode.edu.pl;
proxy_ssl_server_name on;
}
}
```
## 配置说明
## 客户端插件域名配置
`.env` 文件配置项:
插件 `extension/out/api/client.js` 中已配置:
```javascript
const API_DOMAINS = [
'https://api.aicode.edu.pl', // 主域名
'https://hb.aicode.edu.pl', // 备用域名1
'https://cursor.aicode.edu.pl', // 备用域名2
'https://pro.aicode.edu.pl', // 备用域名3
'http://127.0.0.1:8000' // 本地开发
];
```
| 变量 | 说明 | 默认值 |
|------|------|--------|
| 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
自动故障转移:主域名失败会自动切换到备用域名。

View File

@@ -3,16 +3,18 @@
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
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 (
AccountCreate, AccountUpdate, AccountResponse, AccountImport,
KeyCreate, KeyUpdate, KeyResponse,
DashboardStats, Token, LoginRequest,
GlobalSettingsResponse, GlobalSettingsUpdate,
BatchExtendRequest, BatchExtendResponse
BatchExtendRequest, BatchExtendResponse,
ExternalBatchUpload, ExternalBatchResponse
)
from app.models import MembershipType, KeyDevice, UsageLog, ActivationKey
@@ -33,6 +35,138 @@ async def login(request: LoginRequest):
return Token(access_token=access_token)
# ========== 外部系统API (Token认证) ==========
def verify_api_token(x_api_token: str = Header(..., alias="X-API-Token")):
"""验证外部系统API Token"""
if x_api_token != settings.API_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API Token"
)
return True
@router.post("/external/accounts/batch", response_model=ExternalBatchResponse)
async def external_batch_upload(
data: ExternalBatchUpload,
db: Session = Depends(get_db),
_: bool = Depends(verify_api_token)
):
"""外部系统批量上传账号
使用方法:
POST /admin/external/accounts/batch
Headers: X-API-Token: your-api-token
Body: {
"accounts": [
{
"email": "user@example.com",
"access_token": "xxx",
"refresh_token": "xxx",
"workos_session_token": "xxx",
"membership_type": "free", // free=auto账号, pro=高级账号
"remark": "备注"
}
],
"update_existing": true // 是否更新已存在的账号
}
"""
created = 0
updated = 0
failed = 0
errors = []
for item in data.accounts:
try:
# 转换membership_type
mt = MembershipType.FREE if item.membership_type == "free" else MembershipType.PRO
existing = AccountService.get_by_email(db, item.email)
if existing:
if data.update_existing:
# 更新已存在的账号
AccountService.update(
db, existing.id,
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
else:
failed += 1
errors.append(f"{item.email}: 账号已存在")
else:
# 创建新账号
account_data = AccountCreate(
email=item.email,
access_token=item.access_token,
refresh_token=item.refresh_token,
workos_session_token=item.workos_session_token,
membership_type=mt,
remark=item.remark
)
AccountService.create(db, account_data)
created += 1
except Exception as e:
failed += 1
errors.append(f"{item.email}: {str(e)}")
return ExternalBatchResponse(
success=failed == 0,
total=len(data.accounts),
created=created,
updated=updated,
failed=failed,
errors=errors[:20] # 只返回前20个错误
)
@router.get("/external/accounts/stats")
async def external_account_stats(
db: Session = Depends(get_db),
_: bool = Depends(verify_api_token)
):
"""外部系统获取账号统计"""
stats = AccountService.count(db)
return {
"total": stats["total"],
"active": stats["active"],
"pro": stats["pro"],
"free": stats["total"] - stats["pro"]
}
@router.delete("/external/accounts/batch")
async def external_batch_delete(
emails: List[str],
db: Session = Depends(get_db),
_: bool = Depends(verify_api_token)
):
"""外部系统批量删除账号"""
deleted = 0
failed = 0
for email in emails:
try:
account = AccountService.get_by_email(db, email)
if account:
AccountService.delete(db, account.id)
deleted += 1
else:
failed += 1
except Exception:
failed += 1
return {
"success": failed == 0,
"deleted": deleted,
"failed": failed
}
# ========== 仪表盘 ==========
@router.get("/dashboard", response_model=DashboardStats)
@@ -118,6 +252,16 @@ async def import_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"]}
@router.get("/accounts/{account_id}", response_model=AccountResponse)
async def get_account(
account_id: int,
@@ -157,6 +301,166 @@ async def delete_account(
return {"message": "删除成功"}
@router.post("/accounts/{account_id}/toggle-status")
async def toggle_account_status(
account_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""快捷切换账号状态
切换逻辑:
- 使用中(in_use) -> 可用(active) 释放账号
- 可用(active) -> 禁用(disabled)
- 禁用(disabled) -> 可用(active)
- 过期(expired) -> 可用(active)
"""
from app.models import AccountStatus, Account
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
old_status = 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.DISABLED
elif account.status == AccountStatus.DISABLED:
account.status = AccountStatus.ACTIVE
elif account.status == AccountStatus.EXPIRED:
account.status = AccountStatus.ACTIVE
db.commit()
return {
"success": True,
"old_status": old_status.value,
"new_status": account.status.value,
"message": f"状态已从 {old_status.value} 切换为 {account.status.value}"
}
@router.post("/accounts/{account_id}/release")
async def release_account(
account_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""释放账号(从使用中变为可用)"""
from app.models import AccountStatus, Account
account = db.query(Account).filter(Account.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
db.commit()
return {"success": True, "message": "账号已释放"}
@router.post("/accounts/batch-enable")
async def batch_enable_accounts(
account_ids: List[int],
db: Session = Depends(get_db),
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()
if account:
account.status = AccountStatus.ACTIVE
success += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"success": success,
"failed": failed,
"message": f"成功启用 {success} 个账号"
}
@router.post("/accounts/batch-disable")
async def batch_disable_accounts(
account_ids: List[int],
db: Session = Depends(get_db),
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()
if account:
account.status = AccountStatus.DISABLED
success += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"success": success,
"failed": failed,
"message": f"成功禁用 {success} 个账号"
}
@router.post("/accounts/batch-delete")
async def batch_delete_accounts(
account_ids: List[int],
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量删除账号"""
success = 0
failed = 0
for account_id in account_ids:
try:
if AccountService.delete(db, account_id):
success += 1
else:
failed += 1
except Exception:
failed += 1
return {
"success": success,
"failed": failed,
"message": f"成功删除 {success} 个账号"
}
@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"]}
# ========== 激活码管理 ==========
@router.get("/keys", response_model=List[KeyResponse])
@@ -165,7 +469,7 @@ async def list_keys(
limit: int = 100,
search: Optional[str] = Query(None, description="搜索激活码"),
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
activated: Optional[bool] = Query(None, description="是否已激活"),
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
membership_type: Optional[str] = Query(None, description="套餐类型: pro/free"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
@@ -180,12 +484,13 @@ async def list_keys(
# 状态筛选
if status:
query = query.filter(ActivationKey.status == status)
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
query = query.filter(ActivationKey.status == status_enum)
# 是否已激活
if activated is True:
if activated and activated == "true":
query = query.filter(ActivationKey.first_activated_at != None)
elif activated is False:
elif activated and activated == "false":
query = query.filter(ActivationKey.first_activated_at == None)
# 套餐类型筛选
@@ -196,6 +501,43 @@ async def list_keys(
return query.offset(skip).limit(limit).all()
@router.get("/keys/count")
async def get_keys_count(
search: Optional[str] = Query(None, description="搜索激活码"),
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
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)
# 搜索激活码
if search:
query = query.filter(ActivationKey.key.contains(search))
# 状态筛选
if status:
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
query = query.filter(ActivationKey.status == status_enum)
# 是否已激活
if activated and activated == "true":
query = query.filter(ActivationKey.first_activated_at != None)
elif activated and activated == "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)
total = query.count()
return {"total": total}
@router.post("/keys", response_model=List[KeyResponse])
async def create_keys(
key_data: KeyCreate,
@@ -344,6 +686,129 @@ async def enable_key(
return {"message": "激活码已启用"}
@router.post("/keys/batch-enable")
async def batch_enable_keys(
key_ids: List[int],
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量启用激活码"""
from app.models import KeyStatus
success = 0
failed = 0
for key_id in key_ids:
try:
key = KeyService.get_by_id(db, key_id)
if key:
key.status = KeyStatus.ACTIVE
success += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"success": success,
"failed": failed,
"message": f"成功启用 {success} 个激活码"
}
@router.post("/keys/batch-disable")
async def batch_disable_keys(
key_ids: List[int],
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量禁用激活码"""
from app.models import KeyStatus
success = 0
failed = 0
for key_id in key_ids:
try:
key = KeyService.get_by_id(db, key_id)
if key:
key.status = KeyStatus.DISABLED
success += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"success": success,
"failed": failed,
"message": f"成功禁用 {success} 个激活码"
}
@router.post("/keys/batch-delete")
async def batch_delete_keys(
key_ids: List[int],
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""批量删除激活码"""
success = 0
failed = 0
for key_id in key_ids:
try:
if KeyService.delete(db, key_id):
success += 1
else:
failed += 1
except Exception:
failed += 1
return {
"success": success,
"failed": failed,
"message": f"成功删除 {success} 个激活码"
}
@router.get("/keys/count")
async def get_keys_count(
search: Optional[str] = Query(None, description="搜索激活码"),
status: Optional[str] = Query(None, description="状态筛选: active/disabled"),
activated: Optional[str] = Query(None, description="是否已激活: true/false"),
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)
# 搜索激活码
if search:
query = query.filter(ActivationKey.key.contains(search))
# 状态筛选
if status:
status_enum = KeyStatus.ACTIVE if status == "active" else KeyStatus.DISABLED
query = query.filter(ActivationKey.status == status_enum)
# 是否已激活
if activated and activated == "true":
query = query.filter(ActivationKey.first_activated_at != None)
elif activated and activated == "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)
total = query.count()
return {"total": total}
@router.post("/keys/{key_id}/add-quota", response_model=KeyResponse)
async def add_key_quota(
key_id: int,

View File

@@ -3,20 +3,87 @@
"""
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from typing import Optional, List
from pydantic import BaseModel
from app.database import get_db
from app.services import AccountService, KeyService, LogService, GlobalSettingsService
from app.schemas import VerifyKeyRequest, VerifyKeyResponse, SwitchAccountRequest, SwitchAccountResponse
from app.models import AccountStatus, MembershipType
router = APIRouter(prefix="/api", tags=["Client API"])
@router.post("/verify-key", response_model=VerifyKeyResponse)
# ========== 账号数据响应模型 ==========
class AccountData(BaseModel):
"""账号数据 - 写入本地Cursor的数据"""
accessToken: str
refreshToken: Optional[str] = None
workosSessionToken: Optional[str] = None
email: str
membership_type: str
usage_type: Optional[str] = "default"
# 机器ID相关 (可选)
serviceMachineId: Optional[str] = None
machineId: Optional[str] = None
macMachineId: Optional[str] = None
devDeviceId: Optional[str] = None
sqmId: Optional[str] = None
machineIdFile: Optional[str] = None
# 使用统计
requestCount: Optional[int] = 0
usageAmount: Optional[float] = 0.0
# 额度信息
quota: Optional[int] = None
quotaUsed: Optional[int] = None
quotaRemaining: Optional[int] = None
expireDate: Optional[str] = None
class ApiResponse(BaseModel):
"""通用API响应"""
success: bool
message: Optional[str] = None
error: Optional[str] = None
data: Optional[AccountData] = None
# ========== 验证和切换 API ==========
def build_account_data(account, key) -> AccountData:
"""构建账号数据对象"""
expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None
return AccountData(
accessToken=account.access_token,
refreshToken=account.refresh_token,
workosSessionToken=account.workos_session_token,
email=account.email,
membership_type=account.membership_type.value,
quota=key.quota,
quotaUsed=key.quota_used,
quotaRemaining=key.quota - key.quota_used,
expireDate=expire_date
)
@router.post("/verify")
async def verify(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)):
"""验证激活码 (前端调用的路径)"""
return await verify_key_impl(request, req, db)
@router.post("/verify-key")
async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depends(get_db)):
"""验证激活码"""
"""验证激活码 (兼容路径)"""
return await verify_key_impl(request, req, db)
async def verify_key_impl(request: VerifyKeyRequest, req: Request, db: Session):
"""验证激活码实现"""
key = KeyService.get_by_key(db, request.key)
if not key:
return VerifyKeyResponse(success=False, error="激活码不存在")
return {"success": False, "valid": False, "error": "激活码不存在"}
# 首次激活:设置激活时间和过期时间
KeyService.activate(db, key)
@@ -26,75 +93,81 @@ async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depe
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)
return {"success": False, "valid": 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)
return {"success": False, "valid": 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":
# 只有账号不存在或被禁用/过期才分配新的IN_USE 状态的账号继续使用)
if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
# 分配新账号
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="暂无可用账号,请稍后重试")
return {"success": False, "valid": 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
)
return {
"success": True,
"valid": True,
"expire_date": expire_date,
"switch_remaining": key.quota - key.quota_used,
"switch_limit": key.quota,
"data": build_account_data(account, key)
}
@router.post("/switch-account", response_model=SwitchAccountResponse)
@router.post("/switch")
async def switch(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)):
"""切换账号 (前端调用的路径)"""
return await switch_account_impl(request, req, db)
@router.post("/switch-account")
async def switch_account(request: SwitchAccountRequest, req: Request, db: Session = Depends(get_db)):
"""切换账号"""
"""切换账号 (兼容路径)"""
return await switch_account_impl(request, req, db)
async def switch_account_impl(request: SwitchAccountRequest, req: Request, db: Session):
"""切换账号实现"""
key = KeyService.get_by_key(db, request.key)
if not key:
return SwitchAccountResponse(success=False, error="激活码不存在")
return ApiResponse(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)
return ApiResponse(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)
return ApiResponse(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)
return ApiResponse(success=False, error=switch_msg)
# 释放当前账号
if key.current_account_id:
@@ -106,7 +179,7 @@ async def switch_account(request: SwitchAccountRequest, req: Request, db: Sessio
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="暂无可用账号,请稍后重试")
return ApiResponse(success=False, error="暂无可用账号,请稍后重试")
# 绑定新账号并扣除额度
KeyService.bind_account(db, key, account)
@@ -115,34 +188,581 @@ async def switch_account(request: SwitchAccountRequest, req: Request, db: Sessio
LogService.log(db, key.id, "switch", account.id, ip_address=req.client.host, success=True)
return SwitchAccountResponse(
return ApiResponse(
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
data=build_account_data(account, key)
)
# ========== 版本 API ==========
@router.get("/version")
async def get_version():
"""获取版本信息"""
return {
"success": True,
"version": "1.0.0",
"update_url": None,
"message": None
"latestVersion": "1.0.0",
"updateUrl": None,
"message": None,
"forceUpdate": False
}
# ========== 代理配置 API ==========
# 内存存储代理配置 (生产环境应存数据库)
proxy_config = {
"is_enabled": False,
"proxy_url": ""
}
@router.get("/proxy-config")
async def get_proxy_config():
"""获取代理配置"""
return {
"success": True,
"data": proxy_config
}
@router.post("/proxy-config")
async def update_proxy_config(config: dict):
"""更新代理配置"""
global proxy_config
if "is_enabled" in config:
proxy_config["is_enabled"] = config["is_enabled"]
if "proxy_url" in config:
proxy_config["proxy_url"] = config["proxy_url"]
return {
"success": True,
"message": "代理配置已更新",
"data": proxy_config
}
# ========== 无感换号 API ==========
# 内存存储无感配置 (生产环境应存数据库)
seamless_config = {
"enabled": False,
"mode": "auto",
"switchThreshold": 10,
"accountPool": [],
"currentIndex": 0
}
@router.get("/seamless/status")
async def seamless_status():
"""无感换号状态(简化实现)"""
async def seamless_status(db: Session = Depends(get_db)):
"""获取无感换号状态"""
return {
"success": True,
"enabled": seamless_config["enabled"],
"message": "无感换号功能已就绪" if seamless_config["enabled"] else "无感换号功能未启用"
}
@router.get("/seamless/user-status")
async def seamless_user_status(key: str = None, userKey: str = None, db: Session = Depends(get_db)):
"""获取用户切换状态"""
# 兼容两种参数名
actual_key = key or userKey
if not actual_key:
return {"success": False, "valid": False, "error": "缺少激活码参数"}
activation_key = KeyService.get_by_key(db, actual_key)
if not activation_key:
return {"success": False, "valid": False, "error": "激活码不存在"}
# 检查是否有效
is_valid, message = KeyService.is_valid(activation_key, db)
if not is_valid:
return {"success": False, "valid": False, "error": message}
# 获取当前绑定的账号
locked_account = None
if activation_key.current_account_id:
account = AccountService.get_by_id(db, activation_key.current_account_id)
if account and account.status not in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
locked_account = {
"email": account.email,
"membership_type": account.membership_type.value
}
# Pro密钥使用 quota (积分制)
# Auto密钥使用换号次数限制
is_pro = activation_key.membership_type == MembershipType.PRO
switch_remaining = activation_key.quota - activation_key.quota_used if is_pro else 999 # Auto不限积分
return {
"success": True,
"valid": True,
"switchRemaining": switch_remaining,
"canSwitch": switch_remaining > 0,
"lockedAccount": locked_account,
"membershipType": activation_key.membership_type.value,
"data": {
"canSwitch": True,
"quotaRemaining": activation_key.quota - activation_key.quota_used,
"switchCount": activation_key.switch_count
}
}
@router.get("/seamless/config")
async def get_seamless_config():
"""获取无感配置"""
return {
"success": True,
"data": seamless_config
}
@router.post("/seamless/config")
async def update_seamless_config(config: dict):
"""更新无感配置"""
global seamless_config
for key in ["enabled", "mode", "switchThreshold", "accountPool", "currentIndex"]:
if key in config:
seamless_config[key] = config[key]
return {
"success": True,
"message": "配置已更新",
"data": seamless_config
}
@router.post("/seamless/inject")
async def inject_seamless(data: dict, req: Request, db: Session = Depends(get_db)):
"""注入无感模式 - 返回账号数据"""
user_key = data.get("user_key")
if not user_key:
return {"success": False, "error": "缺少user_key参数"}
key = KeyService.get_by_key(db, user_key)
if not key:
return {"success": False, "error": "激活码不存在"}
# 检查有效性
is_valid, message = KeyService.is_valid(key, db)
if not is_valid:
return {"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 in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
account = AccountService.get_available(db, key.membership_type)
if not account:
return {"success": False, "error": "暂无可用账号"}
KeyService.bind_account(db, key, account)
AccountService.mark_used(db, account, key.id)
LogService.log(db, key.id, "seamless_inject", account.id, ip_address=req.client.host, success=True)
return {
"success": True,
"message": "无感模式已注入",
"data": build_account_data(account, key).model_dump()
}
@router.post("/seamless/restore")
async def restore_seamless():
"""恢复无感模式设置"""
global seamless_config
seamless_config["enabled"] = False
return {
"success": True,
"message": "已恢复默认设置"
}
@router.get("/seamless/accounts")
async def get_seamless_accounts(db: Session = Depends(get_db)):
"""获取账号池列表"""
# 返回可用账号列表 (脱敏)
accounts = AccountService.get_all(db, limit=100)
account_list = []
for acc in accounts:
if acc.status == AccountStatus.ACTIVE:
account_list.append({
"id": acc.id,
"email": acc.email[:3] + "***" + acc.email[acc.email.index("@"):],
"membership_type": acc.membership_type.value,
"status": acc.status.value
})
return {
"success": True,
"data": {
"accounts": account_list,
"total": len(account_list)
}
}
@router.post("/seamless/accounts")
async def sync_seamless_accounts(data: dict):
"""同步账号池"""
# 这个接口在我们的架构中不需要实际功能
# 账号由管理后台统一管理
return {
"success": True,
"message": "账号由管理后台统一管理"
}
@router.get("/seamless/token")
async def get_seamless_token(key: str, db: Session = Depends(get_db)):
"""获取无感Token"""
activation_key = KeyService.get_by_key(db, key)
if not activation_key:
return {"success": False, "error": "激活码不存在"}
account = None
if activation_key.current_account_id:
account = AccountService.get_by_id(db, activation_key.current_account_id)
if not account:
return {"success": False, "error": "未绑定账号"}
return {
"success": True,
"data": build_account_data(account, activation_key).model_dump()
}
@router.get("/seamless/get-token")
async def get_seamless_token_v2(userKey: str = None, key: str = None, req: Request = None, db: Session = Depends(get_db)):
"""获取无感Token (注入代码调用的路径,兼容 userKey 和 key 两种参数名)
返回格式需要直接包含 accessToken、email 等字段,供注入代码使用
"""
actual_key = userKey or key
if not actual_key:
return {"success": False, "error": "缺少激活码参数"}
activation_key = KeyService.get_by_key(db, actual_key)
if not activation_key:
return {"success": False, "error": "激活码不存在"}
# 检查有效性
is_valid, message = KeyService.is_valid(activation_key, db)
if not is_valid:
return {"success": False, "error": message}
# 获取或分配账号
account = None
is_new = False
if activation_key.current_account_id:
account = AccountService.get_by_id(db, activation_key.current_account_id)
# 只有账号不存在或被禁用/过期才分配新的
if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED):
# Auto密钥检查是否允许获取新账号频率限制
if activation_key.membership_type == MembershipType.FREE:
can_switch, switch_msg = KeyService.can_switch(db, activation_key)
if not can_switch:
return {"success": False, "error": switch_msg}
# 分配新账号
account = AccountService.get_available(db, activation_key.membership_type)
if not account:
return {"success": False, "error": "暂无可用账号"}
KeyService.bind_account(db, activation_key, account)
AccountService.mark_used(db, account, activation_key.id)
# 记录换号(用于频率限制)
KeyService.use_switch(db, activation_key)
is_new = True
# 记录日志
if req:
LogService.log(db, activation_key.id, "seamless_get_token", account.id, ip_address=req.client.host, success=True)
# 返回格式需要直接包含字段,供注入代码使用
# 注入代码检查: if(d && d.accessToken) { ... }
return {
"success": True,
"accessToken": account.access_token,
"refreshToken": account.refresh_token or "",
"email": account.email,
"membership_type": account.membership_type.value,
"switchVersion": activation_key.switch_count, # 用于检测是否需要更新
"switchRemaining": activation_key.quota - activation_key.quota_used,
"is_new": is_new,
"machineIds": None # 机器ID由客户端本地管理
}
@router.post("/seamless/switch")
async def switch_seamless_token(data: dict, req: Request, db: Session = Depends(get_db)):
"""切换无感Token"""
user_key = data.get("userKey")
if not user_key:
return {"switched": False, "message": "缺少userKey参数"}
# 复用切换逻辑
request = SwitchAccountRequest(key=user_key)
return await switch_token_impl(request, req, db)
@router.post("/seamless/switch-token")
async def switch_seamless_token_v2(data: dict, req: Request, db: Session = Depends(get_db)):
"""切换无感Token (client.js 调用的路径)"""
user_key = data.get("userKey")
if not user_key:
return {"switched": False, "message": "缺少userKey参数"}
request = SwitchAccountRequest(key=user_key)
return await switch_token_impl(request, req, db)
async def switch_token_impl(request: SwitchAccountRequest, req: Request, db: Session):
"""切换Token实现 - 返回插件期望的格式"""
key = KeyService.get_by_key(db, request.key)
if not key:
return {"switched": False, "message": "激活码不存在"}
# 检查设备限制
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 {"switched": False, "message": 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 {"switched": False, "message": 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 {"switched": False, "message": 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 {"switched": False, "message": "暂无可用账号,请稍后重试"}
# 绑定新账号并扣除额度
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 {
"switched": True,
"switchRemaining": key.quota - key.quota_used,
"email": account.email,
"data": build_account_data(account, key)
}
# ========== 代理配置 API ==========
@router.get("/proxy-config")
async def get_proxy_config(db: Session = Depends(get_db)):
"""获取代理配置"""
return {
"success": True,
"enabled": False,
"message": "无感换号功能暂未开放"
"host": "",
"port": 0,
"username": "",
"password": ""
}
@router.put("/proxy-config")
async def update_proxy_config(config: dict, db: Session = Depends(get_db)):
"""更新代理配置 (客户端本地使用,后端仅返回成功)"""
return {"success": True, "message": "代理配置已保存"}
# ========== 无感换号配置 API ==========
@router.get("/seamless/status")
async def get_seamless_status(db: Session = Depends(get_db)):
"""获取无感换号全局状态"""
settings = GlobalSettingsService.get_all(db)
account_stats = AccountService.count(db)
return {
"success": True,
"enabled": True,
"available_accounts": account_stats.get("active", 0),
"total_accounts": account_stats.get("total", 0),
"switch_interval_minutes": settings.auto_switch_interval_minutes,
"max_switches_per_day": settings.auto_max_switches_per_day
}
@router.get("/seamless/config")
async def get_seamless_config(db: Session = Depends(get_db)):
"""获取无感换号配置"""
settings = GlobalSettingsService.get_all(db)
return {
"success": True,
"auto_switch": True,
"switch_interval_minutes": settings.auto_switch_interval_minutes,
"max_switches_per_day": settings.auto_max_switches_per_day,
"pro_quota_cost": settings.pro_quota_cost
}
@router.post("/seamless/config")
async def update_seamless_config(config: dict, db: Session = Depends(get_db)):
"""更新无感换号配置"""
# 从请求中提取配置
updates = {}
if "switch_interval_minutes" in config:
updates["auto_switch_interval_minutes"] = config["switch_interval_minutes"]
if "max_switches_per_day" in config:
updates["auto_max_switches_per_day"] = config["max_switches_per_day"]
if "pro_quota_cost" in config:
updates["pro_quota_cost"] = config["pro_quota_cost"]
if updates:
GlobalSettingsService.update_all(db, **updates)
return {"success": True, "message": "配置已更新"}
@router.post("/seamless/inject")
async def seamless_inject(data: dict, db: Session = Depends(get_db)):
"""注入无感换号代码 (客户端本地操作,后端仅记录)"""
user_key = data.get("userKey")
if user_key:
key = KeyService.get_by_key(db, user_key)
if key:
LogService.log(db, key.id, "seamless_inject", success=True, message="启用无感换号")
return {"success": True, "message": "无感换号已启用"}
@router.post("/seamless/restore")
async def seamless_restore(data: dict = None, db: Session = Depends(get_db)):
"""恢复原始代码 (客户端本地操作,后端仅记录)"""
if data and data.get("userKey"):
key = KeyService.get_by_key(db, data["userKey"])
if key:
LogService.log(db, key.id, "seamless_restore", success=True, message="禁用无感换号")
return {"success": True, "message": "已恢复原始状态"}
@router.get("/seamless/accounts")
async def get_seamless_accounts(userKey: str = None, db: Session = Depends(get_db)):
"""获取可用账号列表 (供管理使用)"""
if not userKey:
return {"success": False, "error": "缺少 userKey 参数"}
key = KeyService.get_by_key(db, userKey)
if not key:
return {"success": False, "error": "激活码不存在"}
# 获取该激活码类型的可用账号数量
accounts = AccountService.get_all(db, limit=100)
available_count = sum(1 for a in accounts if a.status == AccountStatus.ACTIVE and a.membership_type == key.membership_type)
return {
"success": True,
"available_count": available_count,
"membership_type": key.membership_type.value
}
@router.post("/seamless/sync-accounts")
async def sync_seamless_accounts(data: dict, db: Session = Depends(get_db)):
"""同步账号 (客户端上报账号信息)"""
# 客户端可能上报当前使用的账号状态
return {"success": True, "message": "同步成功"}
# ========== 版本检查 API ==========
@router.get("/version")
async def get_version():
"""获取最新版本信息"""
return {
"success": True,
"current_version": "0.4.5",
"latest_version": "0.4.5",
"has_update": False,
"download_url": "",
"changelog": ""
}
# ========== 公告 API ==========
@router.get("/announcement")
async def get_announcement(db: Session = Depends(get_db)):
"""获取公告信息"""
return {
"success": True,
"has_announcement": True,
"data": {
"is_active": True,
"title": "欢迎使用蜂鸟Pro",
"content": "感谢使用蜂鸟Pro\n\n如有问题请联系客服。",
"type": "info",
"created_at": "2024-12-17 00:00:00"
}
}
@router.get("/announcements/latest")
async def get_announcements_latest(db: Session = Depends(get_db)):
"""获取最新公告 (前端调用的路径)"""
return {
"success": True,
"data": {
"is_active": True,
"title": "欢迎使用蜂鸟Pro",
"content": "感谢使用蜂鸟Pro\n\n如有问题请联系客服。",
"type": "info",
"created_at": "2024-12-17 00:00:00"
}
}
# ========== 用量查询 API ==========
@router.get("/usage")
async def get_usage(userKey: str = None, db: Session = Depends(get_db)):
"""获取用量信息"""
if not userKey:
return {"success": False, "error": "缺少 userKey 参数"}
key = KeyService.get_by_key(db, userKey)
if not key:
return {"success": False, "error": "激活码不存在"}
account = None
if key.current_account_id:
account = AccountService.get_by_id(db, key.current_account_id)
return {
"success": True,
"membership_type": key.membership_type.value,
"quota": key.quota,
"quota_used": key.quota_used,
"quota_remaining": key.quota - key.quota_used,
"switch_count": key.switch_count,
"expire_at": key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None,
"current_email": account.email if account else None
}

View File

@@ -12,13 +12,16 @@ class Settings(BaseSettings):
DB_NAME: str = "cursorpro"
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production"
SECRET_KEY: str = "hb8x2kF9mNpQ3rT7vY1zA4cE6gJ0lO5sU8wB2dH4"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
# 管理员账号
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin123"
ADMIN_PASSWORD: str = "Hb@2024Pro!"
# 外部系统API Token (用于批量上传账号等)
API_TOKEN: str = "hb-ext-9kX2mP5nQ8rT1vY4zA7c"
@property
def DATABASE_URL(self) -> str:

View File

@@ -43,6 +43,32 @@ class AccountImport(BaseModel):
accounts: List[AccountCreate]
# ========== 外部系统批量上传 ==========
class ExternalAccountItem(BaseModel):
"""外部系统上传的账号项"""
email: str
access_token: str
refresh_token: Optional[str] = None
workos_session_token: Optional[str] = None
membership_type: Optional[str] = "free" # free/pro, 默认free(auto账号)
remark: Optional[str] = None
class ExternalBatchUpload(BaseModel):
"""外部系统批量上传请求"""
accounts: List[ExternalAccountItem]
update_existing: bool = True # 是否更新已存在的账号
class ExternalBatchResponse(BaseModel):
"""外部系统批量上传响应"""
success: bool
total: int
created: int
updated: int
failed: int
errors: List[str] = []
# ========== 激活码相关 ==========
class KeyBase(BaseModel):

View File

@@ -1,11 +1,7 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
uvicorn==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
python-jose[cryptography]==3.3.0
aiosqlite==0.19.0

View File

@@ -1,38 +1,20 @@
#!/bin/bash
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ CursorPro 后台管理系统 ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
# 检查 Python
if ! command -v python3 &> /dev/null; then
echo "[错误] 未找到 Python请先安装 Python 3.8+"
exit 1
fi
# 检查虚拟环境
cd "$(dirname "$0")"
# 2. 杀掉占用端口的进程
pkill -f 'python run.py'
# 创建虚拟环境 (首次运行)
if [ ! -d "venv" ]; then
echo "[信息] 创建虚拟环境..."
echo "创建虚拟环境..."
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
else
source venv/bin/activate
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
# 启动服务 (端口8000, 关闭热重载)
echo "启动 CursorPro 后台 (端口: 8000)..."
RELOAD=false nohup python run.py > cursorpro.log 2>&1 &
echo $! > cursorpro.pid
echo "服务已启动PID: $(cat cursorpro.pid)"
echo "日志: tail -f cursorpro.log"

17
backend/stop.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ -f "cursorpro.pid" ]; then
PID=$(cat cursorpro.pid)
if ps -p $PID > /dev/null 2>&1; then
kill $PID
echo "服务已停止 (PID: $PID)"
else
echo "服务未运行"
fi
rm -f cursorpro.pid
else
echo "PID文件不存在"
# 尝试查找并杀死进程
pkill -f "python run.py" && echo "已停止所有相关进程"
fi

View File

@@ -119,26 +119,79 @@
<!-- 账号管理 -->
<div v-if="currentTab === 'accounts'">
<!-- 外部API说明 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-blue-800">外部系统批量上传接口</h3>
<div class="mt-2 text-sm text-blue-700">
<p class="font-mono bg-blue-100 px-2 py-1 rounded mb-2">POST /admin/external/accounts/batch</p>
<p class="mb-1"><strong>Header:</strong> X-API-Token: hb-api-token-change-in-production</p>
<details class="cursor-pointer">
<summary class="text-blue-800 hover:text-blue-900">查看请求格式 ▼</summary>
<pre class="mt-2 bg-gray-800 text-green-400 p-3 rounded text-xs overflow-x-auto">{
"accounts": [
{
"email": "user@example.com",
"access_token": "eyJhbG...",
"refresh_token": "xxx", // 可选
"workos_session_token": "xxx", // 可选
"membership_type": "free", // free=Auto账号, pro=高级账号
"remark": "备注" // 可选
}
],
"update_existing": true // 是否更新已存在的账号
}</pre>
</details>
</div>
</div>
</div>
</div>
<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>
<div class="space-x-2">
<button @click="showImportModal = true"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量导入
</button>
<button @click="showAccountModal = true; editingAccount = null"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
添加账号
</button>
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">账号列表 (共 {{ accountsTotal }} 条)</h2>
<div class="space-x-2">
<button v-if="selectedAccounts.length > 0" @click="batchEnableAccounts"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量启用 ({{ selectedAccounts.length }})
</button>
<button v-if="selectedAccounts.length > 0" @click="batchDisableAccounts"
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
批量禁用 ({{ selectedAccounts.length }})
</button>
<button v-if="selectedAccounts.length > 0" @click="batchDeleteAccounts"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
批量删除 ({{ selectedAccounts.length }})
</button>
<button @click="showImportModal = true"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量导入
</button>
<button @click="showAccountModal = true; editingAccount = null"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
添加账号
</button>
</div>
</div>
</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">
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</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">Token</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>
@@ -146,6 +199,10 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="account in accounts" :key="account.id">
<td class="px-6 py-4 whitespace-nowrap">
<input type="checkbox" :value="account.id" v-model="selectedAccounts"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ account.email }}</td>
<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'"
@@ -153,9 +210,24 @@
{{ account.membership_type.toUpperCase() }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
<button @click="copyToken(account.access_token, 'AccessToken')"
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs hover:bg-blue-200" title="复制 Access Token">
AT
</button>
<button v-if="account.refresh_token" @click="copyToken(account.refresh_token, 'RefreshToken')"
class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs hover:bg-green-200" title="复制 Refresh Token">
RT
</button>
<button v-if="account.workos_session_token" @click="copyToken(account.workos_session_token, 'WorkosSessionToken')"
class="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs hover:bg-purple-200" title="复制 Workos Session Token">
WT
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getStatusClass(account.status)"
class="px-2 py-1 text-xs font-medium rounded-full">
<span @click="toggleAccountStatus(account)" :class="getStatusClass(account.status)"
class="px-2 py-1 text-xs font-medium rounded-full cursor-pointer hover:opacity-80 transition"
:title="'点击切换状态'">
{{ getStatusText(account.status) }}
</span>
</td>
@@ -168,6 +240,49 @@
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700">每页显示</span>
<select v-model="accountsPagination.pageSize" @change="loadAccounts"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="text-sm text-gray-700"></span>
</div>
<div class="flex items-center space-x-2">
<button @click="accountsPagination.currentPage = 1; loadAccounts()"
:disabled="accountsPagination.currentPage === 1"
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
首页
</button>
<button @click="accountsPagination.currentPage--; loadAccounts()"
:disabled="accountsPagination.currentPage === 1"
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
上一页
</button>
<span class="text-sm text-gray-700">
第 {{ accountsPagination.currentPage }} / {{ accountsPagination.totalPages }} 页
</span>
<button @click="accountsPagination.currentPage++; loadAccounts()"
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
下一页
</button>
<button @click="accountsPagination.currentPage = accountsPagination.totalPages; loadAccounts()"
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
末页
</button>
</div>
</div>
</div>
</div>
@@ -176,11 +291,29 @@
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">激活码列表</h2>
<button @click="showKeyModal = true; editingKey = null; resetKeyForm()"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
生成激活码
</button>
<h2 class="text-lg font-medium text-gray-900">激活码列表 (共 {{ keysTotal }} 条)</h2>
<div class="space-x-2">
<button v-if="selectedKeys.length > 0" @click="batchCopyKeys"
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm">
批量复制 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchEnableKeys"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量启用 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchDisableKeys"
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
批量禁用 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchDeleteKeys"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
批量删除 ({{ selectedKeys.length }})
</button>
<button @click="showKeyModal = true; editingKey = null; resetKeyForm()"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
生成激活码
</button>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="flex flex-wrap gap-3">
@@ -213,6 +346,10 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left">
<input type="checkbox" @change="toggleSelectAllKeys" :checked="isAllKeysSelected"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</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>
@@ -223,6 +360,10 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="key in keys" :key="key.id">
<td class="px-6 py-4 whitespace-nowrap">
<input type="checkbox" :value="key.id" v-model="selectedKeys"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ key.key }}</code>
<button @click="copyKey(key.key)" class="ml-2 text-gray-400 hover:text-gray-600">
@@ -273,6 +414,49 @@
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700">每页显示</span>
<select v-model="keysPagination.pageSize" @change="searchKeys"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="text-sm text-gray-700"></span>
</div>
<div class="flex items-center space-x-2">
<button @click="keysPagination.currentPage = 1; searchKeys()"
:disabled="keysPagination.currentPage === 1"
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
首页
</button>
<button @click="keysPagination.currentPage--; searchKeys()"
:disabled="keysPagination.currentPage === 1"
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
上一页
</button>
<span class="text-sm text-gray-700">
第 {{ keysPagination.currentPage }} / {{ keysPagination.totalPages }} 页
</span>
<button @click="keysPagination.currentPage++; searchKeys()"
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
下一页
</button>
<button @click="keysPagination.currentPage = keysPagination.totalPages; searchKeys()"
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
末页
</button>
</div>
</div>
</div>
</div>
@@ -777,7 +961,7 @@
</div>
<script>
const { createApp, ref, reactive, onMounted, watch } = Vue
const { createApp, ref, reactive, computed, onMounted, watch } = Vue
createApp({
setup() {
@@ -791,7 +975,21 @@
// 数据
const stats = ref({ total_accounts: 0, active_accounts: 0, pro_accounts: 0, total_keys: 0, active_keys: 0, today_usage: 0 })
const accounts = ref([])
const accountsTotal = ref(0)
const selectedAccounts = ref([])
const accountsPagination = reactive({
currentPage: 1,
pageSize: 20,
totalPages: 1
})
const keys = ref([])
const keysTotal = ref(0)
const selectedKeys = ref([])
const keysPagination = reactive({
currentPage: 1,
pageSize: 20,
totalPages: 1
})
const logs = ref([])
const logFilter = reactive({ action: '' })
@@ -883,19 +1081,44 @@
// 加载数据
const loadData = async () => {
try {
const [statsRes, accountsRes, keysRes] = await Promise.all([
api.get('/dashboard'),
api.get('/accounts'),
api.get('/keys')
])
const statsRes = await api.get('/dashboard')
stats.value = statsRes.data
accounts.value = accountsRes.data
keys.value = keysRes.data
await Promise.all([loadAccounts(), searchKeys()])
} catch (e) {
console.error('加载数据失败', e)
}
}
// 加载账号列表(分页)
const loadAccounts = async () => {
try {
const skip = (accountsPagination.currentPage - 1) * accountsPagination.pageSize
const [accountsRes, countRes] = await Promise.all([
api.get(`/accounts?skip=${skip}&limit=${accountsPagination.pageSize}`),
api.get('/accounts/count')
])
accounts.value = accountsRes.data
accountsTotal.value = countRes.data.total
accountsPagination.totalPages = Math.ceil(accountsTotal.value / accountsPagination.pageSize)
selectedAccounts.value = []
} catch (e) {
console.error('加载账号失败', e)
}
}
// 全选/取消全选
const isAllSelected = computed(() => {
return accounts.value.length > 0 && selectedAccounts.value.length === accounts.value.length
})
const toggleSelectAll = (event) => {
if (event.target.checked) {
selectedAccounts.value = accounts.value.map(a => a.id)
} else {
selectedAccounts.value = []
}
}
// 账号操作
const editAccount = (account) => {
editingAccount.value = account
@@ -911,7 +1134,7 @@
await api.post('/accounts', accountForm)
}
showAccountModal.value = false
loadData()
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
}
@@ -921,18 +1144,72 @@
if (!confirm('确定删除此账号?')) return
try {
await api.delete(`/accounts/${id}`)
loadData()
loadAccounts()
} catch (e) {
alert('删除失败')
}
}
// 快捷切换账号状态
const toggleAccountStatus = async (account) => {
const statusMap = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
const nextMap = { 'in_use': 'active', 'active': 'disabled', 'disabled': 'active', 'expired': 'active' }
const nextStatus = nextMap[account.status] || 'active'
const msg = `确定将账号状态从「${statusMap[account.status]}」切换为「${statusMap[nextStatus]}」?`
if (!confirm(msg)) return
try {
const res = await api.post(`/accounts/${account.id}/toggle-status`)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '切换失败')
}
}
// 批量启用账号
const batchEnableAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定启用选中的 ${selectedAccounts.value.length} 个账号?`)) return
try {
const res = await api.post('/accounts/batch-enable', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量启用失败')
}
}
// 批量禁用账号
const batchDisableAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定禁用选中的 ${selectedAccounts.value.length} 个账号?`)) return
try {
const res = await api.post('/accounts/batch-disable', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量禁用失败')
}
}
// 批量删除账号
const batchDeleteAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定删除选中的 ${selectedAccounts.value.length} 个账号?此操作不可撤销!`)) return
try {
const res = await api.post('/accounts/batch-delete', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量删除失败')
}
}
const importAccounts = async () => {
try {
const data = JSON.parse(importData.value)
const res = await api.post('/accounts/import', { accounts: data })
importResult.value = res.data
loadData()
loadAccounts()
} catch (e) {
alert('导入失败: ' + (e.response?.data?.detail || e.message))
}
@@ -964,7 +1241,7 @@
await api.post('/keys', keyForm)
}
showKeyModal.value = false
loadData()
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
}
@@ -974,7 +1251,7 @@
if (!confirm('确定删除此激活码?')) return
try {
await api.delete(`/keys/${id}`)
loadData()
searchKeys()
} catch (e) {
alert('删除失败')
}
@@ -990,7 +1267,7 @@
try {
await api.post(`/keys/${quotaTarget.value.id}/add-quota?add_quota=${addQuotaAmount.value}`)
showQuotaModal.value = false
loadData()
searchKeys()
alert('充值成功')
} catch (e) {
alert(e.response?.data?.detail || '充值失败')
@@ -1002,6 +1279,11 @@
alert('已复制')
}
const copyToken = (token, name) => {
navigator.clipboard.writeText(token)
alert(`${name} 已复制`)
}
// 延期操作
const extendKey = (key) => {
extendTarget.value = key
@@ -1017,7 +1299,7 @@
add_quota: 0
})
showExtendModal.value = false
loadData()
searchKeys()
alert('延期成功')
} catch (e) {
alert(e.response?.data?.detail || '延期失败')
@@ -1072,7 +1354,7 @@
const res = await api.post('/keys/batch-compensate?' + params.toString())
compensateResult.value = res.data
compensatePreview.value = null
loadData()
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '补偿失败')
}
@@ -1116,19 +1398,38 @@
}
}
// 搜索激活码
// 搜索激活码(支持分页)
let searchTimeout = null
const searchKeys = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
try {
const params = new URLSearchParams()
if (keySearch.search) params.append('search', keySearch.search)
if (keySearch.status) params.append('status', keySearch.status)
if (keySearch.activated) params.append('activated', keySearch.activated)
if (keySearch.membership_type) params.append('membership_type', keySearch.membership_type)
const res = await api.get('/keys?' + params.toString())
keys.value = res.data
const skip = (keysPagination.currentPage - 1) * keysPagination.pageSize
// 构建激活码列表查询参数
const listParams = new URLSearchParams()
listParams.append('skip', skip)
listParams.append('limit', keysPagination.pageSize)
if (keySearch.search) listParams.append('search', keySearch.search)
if (keySearch.status) listParams.append('status', keySearch.status)
if (keySearch.activated) listParams.append('activated', keySearch.activated)
if (keySearch.membership_type) listParams.append('membership_type', keySearch.membership_type)
// 构建计数查询参数不包含skip和limit
const countParams = new URLSearchParams()
if (keySearch.search) countParams.append('search', keySearch.search)
if (keySearch.status) countParams.append('status', keySearch.status)
if (keySearch.activated) countParams.append('activated', keySearch.activated)
if (keySearch.membership_type) countParams.append('membership_type', keySearch.membership_type)
const [keysRes, countRes] = await Promise.all([
api.get('/keys?' + listParams.toString()),
api.get('/keys/count' + (countParams.toString() ? '?' + countParams.toString() : ''))
])
keys.value = keysRes.data
keysTotal.value = countRes.data.total
keysPagination.totalPages = Math.ceil(keysTotal.value / keysPagination.pageSize)
selectedKeys.value = []
} catch (e) {
console.error('搜索失败', e)
}
@@ -1140,7 +1441,74 @@
keySearch.status = ''
keySearch.activated = ''
keySearch.membership_type = ''
loadData()
keysPagination.currentPage = 1
searchKeys()
}
// 全选/取消全选激活码
const isAllKeysSelected = computed(() => {
return keys.value.length > 0 && selectedKeys.value.length === keys.value.length
})
const toggleSelectAllKeys = (event) => {
if (event.target.checked) {
selectedKeys.value = keys.value.map(k => k.id)
} else {
selectedKeys.value = []
}
}
// 批量复制激活码
const batchCopyKeys = async () => {
if (selectedKeys.value.length === 0) return
try {
// 获取选中的激活码
const selectedKeyObjects = keys.value.filter(k => selectedKeys.value.includes(k.id))
const keyStrings = selectedKeyObjects.map(k => k.key).join('\n')
await navigator.clipboard.writeText(keyStrings)
alert(`已复制 ${selectedKeys.value.length} 个激活码到剪贴板`)
} catch (e) {
alert('复制失败: ' + e.message)
}
}
// 批量启用激活码
const batchEnableKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定启用选中的 ${selectedKeys.value.length} 个激活码?`)) return
try {
const res = await api.post('/keys/batch-enable', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量启用失败')
}
}
// 批量禁用激活码
const batchDisableKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定禁用选中的 ${selectedKeys.value.length} 个激活码?`)) return
try {
const res = await api.post('/keys/batch-disable', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量禁用失败')
}
}
// 批量删除激活码
const batchDeleteKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定删除选中的 ${selectedKeys.value.length} 个激活码?此操作不可撤销!`)) return
try {
const res = await api.post('/keys/batch-delete', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量删除失败')
}
}
// 禁用/启用激活码
@@ -1217,7 +1585,9 @@
return {
isLoggedIn, currentUser, currentTab, loginError, loginForm,
stats, accounts, keys, logs, logFilter, keySearch,
stats, accounts, accountsTotal, selectedAccounts, accountsPagination, isAllSelected,
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
logs, logFilter, keySearch,
accountForm, keyForm,
showAccountModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
@@ -1227,9 +1597,11 @@
extendTarget, extendDays,
globalSettings,
compensateForm, compensatePreview, compensateResult,
login, logout, loadData, loadLogs, searchKeys, resetKeySearch,
editAccount, saveAccount, deleteAccount, importAccounts,
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey,
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
toggleSelectAllKeys, batchCopyKeys, batchEnableKeys, batchDisableKeys, batchDeleteKeys,
editAccount, saveAccount, deleteAccount, importAccounts, toggleAccountStatus,
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey, copyToken,
viewKeyDetail, deleteDevice, disableKey, enableKey,
extendKey, submitExtend,
loadSettings, saveSettings,