初步增加 web 界面

This commit is contained in:
yml2213
2026-06-22 13:11:15 +08:00
parent 4c924375aa
commit 347edb8103
66 changed files with 6816 additions and 21 deletions
View File
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.
+53
View File
@@ -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()
+50
View File
@@ -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
+51
View File
@@ -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()
+89
View File
@@ -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)
+70
View File
@@ -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)
View File
Binary file not shown.
+152
View File
@@ -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}
+57
View File
@@ -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": "已登出"}
+137
View File
@@ -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)
+146
View File
@@ -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}"}
+117
View File
@@ -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}
+116
View File
@@ -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
+38
View File
@@ -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
View File
+139
View File
@@ -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} 完成")