初步增加 web 界面
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
"""数据库引擎与会话管理"""
|
||||
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent.parent / "data" / "web.db"
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_PATH}",
|
||||
connect_args={"check_same_thread": False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI 依赖:提供数据库会话,请求结束自动关闭。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""建表 + 写入初始数据。"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_seed()
|
||||
|
||||
|
||||
def _seed():
|
||||
"""写入默认超管账号和角色。"""
|
||||
from .models import User
|
||||
from .security import hash_password
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not db.query(User).first():
|
||||
admin = User(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
remark="默认超级管理员",
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""FastAPI 依赖注入"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError
|
||||
|
||||
from .database import get_db
|
||||
from .security import decode_access_token
|
||||
from .models import User
|
||||
from .permissions import get_role_permissions
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exc
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exc
|
||||
except JWTError:
|
||||
raise credentials_exc
|
||||
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exc
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
|
||||
def checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
perms = get_role_permissions(current_user.role)
|
||||
if permission not in perms:
|
||||
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
||||
return current_user
|
||||
return checker
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FastAPI 入口"""
|
||||
|
||||
import uvicorn
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .database import init_db
|
||||
from .routers import auth, users, accounts, login, proxy
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="斗鱼批量登录后台",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS(开发期允许前端 localhost:5173)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(login.router)
|
||||
app.include_router(proxy.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ORM 模型"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""系统用户"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(256), nullable=False)
|
||||
role = Column(String(32), nullable=False, default="support") # super_admin / operation / support
|
||||
is_active = Column(Boolean, default=True)
|
||||
remark = Column(String(256), default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 客服被分配的账号
|
||||
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
||||
|
||||
|
||||
class Account(Base):
|
||||
"""斗鱼账号"""
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(128), nullable=False)
|
||||
password = Column(String(256), nullable=False)
|
||||
email = Column(String(128), nullable=False)
|
||||
email_password = Column(String(256), nullable=False)
|
||||
email_imap_server = Column(String(128), default="")
|
||||
email_imap_port = Column(Integer, default=993)
|
||||
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
remark = Column(String(256), default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
|
||||
login_tasks = relationship("LoginTask", back_populates="account")
|
||||
|
||||
|
||||
class LoginTask(Base):
|
||||
"""单次登录任务"""
|
||||
__tablename__ = "login_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id = Column(String(64), nullable=False, index=True) # 批次ID
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
||||
status = Column(String(32), default="pending") # pending / running / success / failed / error
|
||||
cookie = Column(Text, default="")
|
||||
message = Column(String(512), default="")
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
account = relationship("Account", back_populates="login_tasks")
|
||||
|
||||
|
||||
class ProxyConfig(Base):
|
||||
"""代理配置(全局单条记录)"""
|
||||
__tablename__ = "proxy_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
enabled = Column(Boolean, default=False)
|
||||
api_url = Column(String(512), default="")
|
||||
http = Column(String(256), default="")
|
||||
https = Column(String(256), default="")
|
||||
# 白名单
|
||||
whitelist_enabled = Column(Boolean, default=False)
|
||||
whitelist_uid = Column(String(64), default="")
|
||||
whitelist_ukey = Column(String(128), default="")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""操作审计日志"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, nullable=True)
|
||||
username = Column(String(64), default="")
|
||||
action = Column(String(128), nullable=False)
|
||||
target = Column(String(256), default="")
|
||||
detail = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""权限点定义与角色默认权限"""
|
||||
|
||||
# ---- 权限点 ----
|
||||
PERMISSIONS = {
|
||||
# 用户管理
|
||||
"user:view": "查看用户列表",
|
||||
"user:create": "创建用户",
|
||||
"user:edit": "编辑用户",
|
||||
"user:delete": "删除用户",
|
||||
"user:assign_permissions": "分配权限",
|
||||
# 账号管理
|
||||
"account:view_all": "查看所有账号(完整字段)",
|
||||
"account:view_assigned": "查看分配给自己的账号(仅用户名)",
|
||||
"account:import": "导入账号",
|
||||
"account:assign": "分配账号给客服",
|
||||
"account:delete": "删除账号",
|
||||
# 登录任务
|
||||
"login:batch": "发起批量登录",
|
||||
"login:view_all": "查看所有登录任务",
|
||||
"login:view_assigned": "查看自己账号的登录任务",
|
||||
# Cookie
|
||||
"cookie:view": "查看 Cookie",
|
||||
"cookie:export": "导出 Cookie",
|
||||
# 代理 & 白名单
|
||||
"proxy:manage": "代理配置管理",
|
||||
"whitelist:manage": "白名单配置管理",
|
||||
"whitelist:test": "测试白名单",
|
||||
# 系统
|
||||
"system:settings": "系统设置",
|
||||
"audit:view": "查看审计日志",
|
||||
}
|
||||
|
||||
# ---- 角色默认权限 ----
|
||||
ROLE_PERMISSIONS = {
|
||||
"super_admin": list(PERMISSIONS.keys()), # 全部权限
|
||||
"operation": [
|
||||
"account:view_all",
|
||||
"account:import",
|
||||
"account:assign",
|
||||
"account:delete",
|
||||
"login:batch",
|
||||
"login:view_all",
|
||||
"cookie:view",
|
||||
"cookie:export",
|
||||
"proxy:manage",
|
||||
"whitelist:manage",
|
||||
"whitelist:test",
|
||||
],
|
||||
"support": [
|
||||
"account:view_assigned",
|
||||
"login:view_assigned",
|
||||
],
|
||||
}
|
||||
|
||||
# 角色中文映射
|
||||
ROLE_LABELS = {
|
||||
"super_admin": "超级管理员",
|
||||
"operation": "运营",
|
||||
"support": "客服",
|
||||
}
|
||||
|
||||
|
||||
def get_role_permissions(role: str) -> list[str]:
|
||||
"""获取角色拥有的权限列表。"""
|
||||
return ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
|
||||
def has_permission(role: str, permission: str) -> bool:
|
||||
"""检查角色是否拥有某权限。"""
|
||||
return permission in get_role_permissions(role)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,152 @@
|
||||
"""账号管理路由"""
|
||||
|
||||
import re
|
||||
import csv
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Account, AuditLog
|
||||
from ..schemas import AccountImport, AccountAssign, AccountOut
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import has_permission
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||
|
||||
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
||||
|
||||
|
||||
def _split_account_line(line: str) -> list[str]:
|
||||
if '|' in line:
|
||||
return line.split('|')
|
||||
if '\t' in line:
|
||||
return line.split('\t')
|
||||
if ',' in line:
|
||||
return next(csv.reader([line]))
|
||||
return line.split()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut])
|
||||
def list_accounts(
|
||||
assigned_only: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""列表:按角色返回不同字段和范围。"""
|
||||
query = db.query(Account)
|
||||
|
||||
# 权限控制:客服只能看分配给自己的
|
||||
if not has_permission(current.role, "account:view_all"):
|
||||
if has_permission(current.role, "account:view_assigned"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="无权查看账号")
|
||||
|
||||
if assigned_only and has_permission(current.role, "account:view_all"):
|
||||
query = query.filter(Account.assigned_to.isnot(None))
|
||||
|
||||
accounts = query.order_by(Account.id).all()
|
||||
result = []
|
||||
for acc in accounts:
|
||||
item = AccountOut(
|
||||
id=acc.id, username=acc.username, remark=acc.remark or "",
|
||||
assigned_to=acc.assigned_to,
|
||||
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
|
||||
created_at=acc.created_at,
|
||||
)
|
||||
# 运营+超管可看完整字段
|
||||
if has_permission(current.role, "account:view_all"):
|
||||
item.password = acc.password
|
||||
item.email = acc.email
|
||||
item.email_password = acc.email_password
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_accounts(
|
||||
req: AccountImport,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("account:import")),
|
||||
):
|
||||
"""批量导入账号。格式:用户名|密码|邮箱|邮箱密码"""
|
||||
from douyu.email_verifier import get_email_config_for_account
|
||||
|
||||
accounts = []
|
||||
skipped = 0
|
||||
for line_num, line in enumerate(req.text.strip().split('\n'), 1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
parts = _split_account_line(line)
|
||||
if len(parts) != 4:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
username, password, email, email_password = [p.strip() for p in parts]
|
||||
if not all([username, password, email, email_password]):
|
||||
skipped += 1
|
||||
continue
|
||||
if not EMAIL_PATTERN.match(email):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
email_cfg = get_email_config_for_account(email)
|
||||
accounts.append(Account(
|
||||
username=username,
|
||||
password=password,
|
||||
email=email,
|
||||
email_password=email_password,
|
||||
email_imap_server=email_cfg['server'],
|
||||
email_imap_port=email_cfg.get('port', 993),
|
||||
))
|
||||
|
||||
if accounts:
|
||||
db.add_all(accounts)
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:import", target=f"导入{len(accounts)}个"))
|
||||
db.commit()
|
||||
|
||||
return {"message": f"导入成功 {len(accounts)} 个,跳过 {skipped} 个", "success": True, "count": len(accounts)}
|
||||
|
||||
|
||||
@router.put("/{account_id}/assign")
|
||||
def assign_account(
|
||||
account_id: int,
|
||||
req: AccountAssign,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("account:assign")),
|
||||
):
|
||||
acc = db.query(Account).filter(Account.id == account_id).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
if req.assigned_to:
|
||||
target = db.query(User).filter(User.id == req.assigned_to).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="目标用户不存在")
|
||||
if target.role != "support":
|
||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||
|
||||
acc.assigned_to = req.assigned_to
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:assign", target=acc.username))
|
||||
db.commit()
|
||||
return {"message": "已分配", "success": True}
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("account:delete")),
|
||||
):
|
||||
acc = db.query(Account).filter(Account.id == account_id).first()
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:delete", target=acc.username))
|
||||
db.delete(acc)
|
||||
db.commit()
|
||||
return {"message": "已删除", "success": True}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""认证路由"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, AuditLog
|
||||
from ..security import verify_password, create_access_token
|
||||
from ..permissions import get_role_permissions, ROLE_LABELS
|
||||
from ..schemas import LoginRequest, TokenResponse
|
||||
from ..deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == req.username).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="账号已禁用,请联系管理员")
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
perms = get_role_permissions(user.role)
|
||||
|
||||
# 审计
|
||||
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
|
||||
db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=user.role,
|
||||
username=user.username,
|
||||
permissions=perms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"role_label": ROLE_LABELS.get(current_user.role, current_user.role),
|
||||
"is_active": current_user.is_active,
|
||||
"remark": current_user.remark,
|
||||
"permissions": get_role_permissions(current_user.role),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
|
||||
db.commit()
|
||||
return {"message": "已登出"}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""登录任务路由 + WebSocket 实时日志"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db, SessionLocal
|
||||
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
||||
from ..schemas import LoginBatchRequest, LoginTaskOut
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import has_permission
|
||||
from ..services.login_service import LoginBatchRunner
|
||||
|
||||
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
||||
|
||||
# 运行中的批次: batch_id -> {runner, log_queue, loop}
|
||||
_active_batches: dict[str, dict] = {}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def create_batch(
|
||||
req: LoginBatchRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("login:batch")),
|
||||
):
|
||||
"""创建批量登录任务。"""
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择账号")
|
||||
|
||||
# 读取代理配置
|
||||
proxy = db.query(ProxyConfigModel).first()
|
||||
|
||||
# 权限过滤账号
|
||||
valid_ids = []
|
||||
for aid in req.account_ids:
|
||||
acc = db.query(Account).filter(Account.id == aid).first()
|
||||
if not acc:
|
||||
continue
|
||||
if not has_permission(current.role, "login:view_all"):
|
||||
if acc.assigned_to != current.id:
|
||||
continue
|
||||
valid_ids.append(aid)
|
||||
|
||||
if not valid_ids:
|
||||
raise HTTPException(status_code=403, detail="没有可登录的账号")
|
||||
|
||||
# 创建执行器(用独立的 DB 会话,因为在线程中运行)
|
||||
thread_db = SessionLocal()
|
||||
runner = LoginBatchRunner(
|
||||
db=thread_db,
|
||||
account_ids=valid_ids,
|
||||
created_by=current.id,
|
||||
creator_role=current.role,
|
||||
max_geetest_retries=req.max_geetest_retries,
|
||||
proxy_config=proxy,
|
||||
)
|
||||
|
||||
batch_id = runner.batch_id
|
||||
|
||||
# 启动线程
|
||||
thread = threading.Thread(target=runner.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return {"batch_id": batch_id, "count": len(valid_ids), "success": True}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[LoginTaskOut])
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看登录任务列表。"""
|
||||
query = db.query(LoginTask)
|
||||
|
||||
# 客服只能看自己账号的任务
|
||||
if not has_permission(current.role, "login:view_all"):
|
||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
||||
Account.assigned_to == current.id
|
||||
)
|
||||
|
||||
if batch_id:
|
||||
query = query.filter(LoginTask.batch_id == batch_id)
|
||||
|
||||
tasks = query.order_by(LoginTask.id.desc()).limit(200).all()
|
||||
result = []
|
||||
for t in tasks:
|
||||
acc = db.query(Account).filter(Account.id == t.account_id).first()
|
||||
result.append(LoginTaskOut(
|
||||
id=t.id, batch_id=t.batch_id, account_id=t.account_id,
|
||||
account_username=acc.username if acc else "",
|
||||
status=t.status, cookie=t.cookie or "", message=t.message or "",
|
||||
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/stop/{batch_id}")
|
||||
def stop_batch(
|
||||
batch_id: str,
|
||||
current: User = Depends(require_permission("login:batch")),
|
||||
):
|
||||
batch = _active_batches.get(batch_id)
|
||||
if batch:
|
||||
batch["runner"].stop()
|
||||
return {"message": "已发送停止信号", "success": True}
|
||||
raise HTTPException(status_code=404, detail="批次不存在或已结束")
|
||||
|
||||
|
||||
@router.websocket("/ws/login/{batch_id}")
|
||||
async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||
"""WebSocket 推送登录实时日志。"""
|
||||
await websocket.accept()
|
||||
|
||||
log_queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# 查找已运行的批次,或等待新批次
|
||||
# 简化:直接把 log_queue 注册到全局,前端创建批次后连 ws
|
||||
_active_batches[batch_id] = {
|
||||
"log_queue": log_queue,
|
||||
"loop": loop,
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
|
||||
await websocket.send_json(msg)
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_json({"level": "heartbeat", "message": ""})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_active_batches.pop(batch_id, None)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""代理 & 白名单配置路由"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
|
||||
from ..schemas import ProxyConfigOut, ProxyConfigUpdate, MessageResponse
|
||||
from ..deps import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> ProxyConfigModel:
|
||||
cfg = db.query(ProxyConfigModel).first()
|
||||
if not cfg:
|
||||
cfg = ProxyConfigModel()
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("", response_model=ProxyConfigOut)
|
||||
def get_proxy_config(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("proxy:manage")),
|
||||
):
|
||||
return _get_or_create(db)
|
||||
|
||||
|
||||
@router.put("", response_model=ProxyConfigOut)
|
||||
def update_proxy_config(
|
||||
req: ProxyConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("proxy:manage")),
|
||||
):
|
||||
cfg = _get_or_create(db)
|
||||
cfg.enabled = req.enabled
|
||||
cfg.api_url = req.api_url
|
||||
cfg.http = req.http
|
||||
cfg.https = req.https
|
||||
cfg.whitelist_enabled = req.whitelist_enabled
|
||||
cfg.whitelist_uid = req.whitelist_uid
|
||||
cfg.whitelist_ukey = req.whitelist_ukey
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="proxy:update", target="proxy_config"))
|
||||
db.commit()
|
||||
return cfg
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_proxy(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("proxy:manage")),
|
||||
):
|
||||
"""测试代理连通性。"""
|
||||
import requests as req_lib
|
||||
cfg = _get_or_create(db)
|
||||
if not cfg.enabled:
|
||||
return {"success": False, "message": "代理未启用"}
|
||||
|
||||
proxy_url = cfg.http or cfg.https
|
||||
if cfg.api_url and not proxy_url:
|
||||
try:
|
||||
resp = req_lib.get(cfg.api_url, timeout=10)
|
||||
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text)
|
||||
if match:
|
||||
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"代理API请求失败: {e}"}
|
||||
|
||||
if not proxy_url:
|
||||
return {"success": False, "message": "无可用代理地址"}
|
||||
|
||||
try:
|
||||
resp = req_lib.get(
|
||||
"https://qifu-api.baidubce.com/ip/local/geo/v1/district",
|
||||
proxies={"http": proxy_url, "https": proxy_url},
|
||||
timeout=(4, 6),
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {"success": True, "message": f"代理可用,响应: {resp.text[:100]}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"代理验证失败: {e}"}
|
||||
|
||||
|
||||
@router.post("/whitelist/test")
|
||||
def test_whitelist(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("whitelist:test")),
|
||||
):
|
||||
"""测试白名单连接并自动同步出口IP。"""
|
||||
from douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
|
||||
import requests as req_lib
|
||||
|
||||
cfg = _get_or_create(db)
|
||||
if not cfg.whitelist_enabled:
|
||||
return {"success": False, "message": "白名单未启用"}
|
||||
if not cfg.whitelist_uid or not cfg.whitelist_ukey:
|
||||
return {"success": False, "message": "未配置白名单UID/UKEY"}
|
||||
|
||||
manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey)
|
||||
|
||||
# 测试API连接
|
||||
ok, msg = manager.test_connection()
|
||||
if not ok:
|
||||
return {"success": False, "message": msg}
|
||||
|
||||
# 获取出口IP
|
||||
proxy_url = cfg.http or cfg.https
|
||||
if cfg.api_url and not proxy_url:
|
||||
try:
|
||||
resp = req_lib.get(cfg.api_url, timeout=10)
|
||||
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text)
|
||||
if match:
|
||||
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
exit_ip = None
|
||||
if proxy_url:
|
||||
exit_ip = get_exit_ip_via_proxy(proxy_url)
|
||||
if not exit_ip:
|
||||
try:
|
||||
resp = req_lib.get("https://4.ipw.cn", timeout=6, headers={"User-Agent": "Mozilla/5.0"})
|
||||
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text)
|
||||
if match:
|
||||
exit_ip = match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not exit_ip:
|
||||
return {"success": False, "message": "API连接正常但无法获取出口IP"}
|
||||
|
||||
# 同步白名单
|
||||
sync_ok, sync_msg = manager.sync_ip(exit_ip)
|
||||
if sync_ok:
|
||||
return {"success": True, "message": f"出口IP {exit_ip} 已同步: {sync_msg}"}
|
||||
return {"success": False, "message": f"同步失败: {sync_msg}"}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""用户管理路由(超管)"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, AuditLog
|
||||
from ..security import hash_password
|
||||
from ..permissions import ROLE_LABELS, get_role_permissions
|
||||
from ..schemas import UserCreate, UserUpdate, UserInfo
|
||||
from ..deps import require_permission, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserInfo])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("user:view")),
|
||||
):
|
||||
users = db.query(User).order_by(User.id).all()
|
||||
result = []
|
||||
for u in users:
|
||||
result.append(UserInfo(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
remark=u.remark or "",
|
||||
created_at=u.created_at,
|
||||
permissions=get_role_permissions(u.role),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=UserInfo)
|
||||
def create_user(
|
||||
req: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:create")),
|
||||
):
|
||||
if db.query(User).filter(User.username == req.username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
user = User(
|
||||
username=req.username,
|
||||
password_hash=hash_password(req.password),
|
||||
role=req.role,
|
||||
remark=req.remark,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:create", target=user.username))
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_role_permissions(user.role),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserInfo)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
req: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:edit")),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if req.password:
|
||||
user.password_hash = hash_password(req.password)
|
||||
if req.role is not None:
|
||||
user.role = req.role
|
||||
if req.is_active is not None:
|
||||
user.is_active = req.is_active
|
||||
if req.remark is not None:
|
||||
user.remark = req.remark
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:edit", target=user.username))
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_role_permissions(user.role),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:delete")),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.role == "super_admin":
|
||||
raise HTTPException(status_code=400, detail="不能删除超级管理员")
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:delete", target=user.username))
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "已删除", "success": True}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pydantic 请求/响应模型"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---- 认证 ----
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
username: str
|
||||
permissions: list[str]
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool
|
||||
remark: str = ""
|
||||
created_at: Optional[datetime] = None
|
||||
permissions: list[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ---- 用户管理 ----
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(..., min_length=2, max_length=64)
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
role: str = Field("support", pattern="^(super_admin|operation|support)$")
|
||||
remark: str = ""
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
# ---- 账号 ----
|
||||
class AccountImport(BaseModel):
|
||||
"""批量导入,文本格式:用户名|密码|邮箱|邮箱密码"""
|
||||
text: str
|
||||
|
||||
|
||||
class AccountAssign(BaseModel):
|
||||
assigned_to: Optional[int] = None
|
||||
|
||||
|
||||
class AccountOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
# 敏感字段根据角色决定是否返回
|
||||
password: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
email_password: Optional[str] = None
|
||||
assigned_to: Optional[int] = None
|
||||
assigned_username: Optional[str] = None
|
||||
remark: str = ""
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ---- 登录任务 ----
|
||||
class LoginBatchRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
max_geetest_retries: int = 5
|
||||
|
||||
|
||||
class LoginTaskOut(BaseModel):
|
||||
id: int
|
||||
batch_id: str
|
||||
account_id: int
|
||||
account_username: str = ""
|
||||
status: str
|
||||
cookie: str = ""
|
||||
message: str = ""
|
||||
created_by: int
|
||||
created_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ---- 代理配置 ----
|
||||
class ProxyConfigOut(BaseModel):
|
||||
enabled: bool = False
|
||||
api_url: str = ""
|
||||
http: str = ""
|
||||
https: str = ""
|
||||
whitelist_enabled: bool = False
|
||||
whitelist_uid: str = ""
|
||||
whitelist_ukey: str = ""
|
||||
|
||||
|
||||
class ProxyConfigUpdate(ProxyConfigOut):
|
||||
pass
|
||||
|
||||
|
||||
# ---- 通用 ----
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
success: bool = True
|
||||
@@ -0,0 +1,38 @@
|
||||
"""安全模块:密码哈希 + JWT"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
from jose import jwt, JWTError
|
||||
|
||||
SECRET_KEY = "douyu-web-secret-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
pwd_bytes = password.encode("utf-8")
|
||||
# bcrypt 限制72字节,截断
|
||||
pwd_bytes = pwd_bytes[:72]
|
||||
return bcrypt.hashpw(pwd_bytes, bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
"""登录服务:复用 douyu/ 核心模块,在线程池中执行登录并推送日志。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from douyu import DouyuLogin
|
||||
from douyu.config import Account, ProxyConfig as DouyuProxyConfig
|
||||
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
||||
from ..permissions import has_permission
|
||||
|
||||
|
||||
class LoginBatchRunner:
|
||||
"""批量登录执行器,在线程中运行,通过 asyncio.Queue 推送日志。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
account_ids: list[int],
|
||||
created_by: int,
|
||||
creator_role: str,
|
||||
max_geetest_retries: int = 5,
|
||||
proxy_config: Optional[ProxyConfigModel] = None,
|
||||
log_queue: Optional[asyncio.Queue] = None,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
):
|
||||
self.db = db
|
||||
self.account_ids = account_ids
|
||||
self.created_by = created_by
|
||||
self.creator_role = creator_role
|
||||
self.max_geetest_retries = max_geetest_retries
|
||||
self.proxy_config = proxy_config
|
||||
self.log_queue = log_queue
|
||||
self.loop = loop
|
||||
self.batch_id = uuid.uuid4().hex[:12]
|
||||
self._stop = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def _push_log(self, level: str, message: str):
|
||||
if self.log_queue and self.loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.log_queue.put({"level": level, "message": message}),
|
||||
self.loop,
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""在线程中执行批量登录。"""
|
||||
batch_id = self.batch_id
|
||||
self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号")
|
||||
|
||||
# 创建任务记录
|
||||
tasks = []
|
||||
for aid in self.account_ids:
|
||||
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first()
|
||||
if not acc:
|
||||
continue
|
||||
# 权限检查:客服只能跑分配给自己的
|
||||
if not has_permission(self.creator_role, "login:view_all"):
|
||||
if acc.assigned_to != self.created_by:
|
||||
self._push_log("warning", f"跳过无权账号: {acc.username}")
|
||||
continue
|
||||
|
||||
task = LoginTask(
|
||||
batch_id=batch_id,
|
||||
account_id=aid,
|
||||
status="pending",
|
||||
created_by=self.created_by,
|
||||
)
|
||||
self.db.add(task)
|
||||
tasks.append((task, acc))
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 构建代理配置
|
||||
proxy_url = None
|
||||
proxy_api_url = None
|
||||
if self.proxy_config and self.proxy_config.enabled:
|
||||
if self.proxy_config.http or self.proxy_config.https:
|
||||
proxy_url = {
|
||||
'http': self.proxy_config.http or self.proxy_config.https,
|
||||
'https': self.proxy_config.https or self.proxy_config.http,
|
||||
}
|
||||
elif self.proxy_config.api_url:
|
||||
proxy_api_url = self.proxy_config.api_url
|
||||
|
||||
for i, (task, acc) in enumerate(tasks):
|
||||
if self._stop.is_set():
|
||||
self._push_log("warning", "任务已停止")
|
||||
break
|
||||
|
||||
task.status = "running"
|
||||
self.db.commit()
|
||||
|
||||
self._push_log("info", f"[{i+1}/{len(tasks)}] 开始登录: {acc.username}")
|
||||
|
||||
try:
|
||||
account = Account(
|
||||
username=acc.username,
|
||||
password=acc.password,
|
||||
email=acc.email,
|
||||
email_password=acc.email_password,
|
||||
email_imap_server=acc.email_imap_server or "",
|
||||
email_imap_port=acc.email_imap_port or 993,
|
||||
)
|
||||
|
||||
loginer = DouyuLogin(
|
||||
account,
|
||||
proxy=proxy_url,
|
||||
proxy_api_url=proxy_api_url,
|
||||
max_geetest_retries=self.max_geetest_retries,
|
||||
)
|
||||
result = loginer.login()
|
||||
|
||||
if result.success:
|
||||
task.status = "success"
|
||||
task.cookie = result.cookie
|
||||
task.message = "登录成功"
|
||||
self._push_log("success", f"[{i+1}] {acc.username} 登录成功")
|
||||
else:
|
||||
task.status = "failed"
|
||||
task.message = result.message
|
||||
self._push_log("error", f"[{i+1}] {acc.username} 登录失败: {result.message}")
|
||||
|
||||
except Exception as e:
|
||||
task.status = "error"
|
||||
task.message = str(e)
|
||||
self._push_log("error", f"[{i+1}] {acc.username} 登录异常: {e}")
|
||||
|
||||
task.finished_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
self._push_log("info", f"批量登录任务 {batch_id} 完成")
|
||||
Reference in New Issue
Block a user