refactor: 修复9项中等架构问题
安全修复: - WebSocket 端点添加认证(cookie/token),防止未授权窃听日志 - SPA serve_spa 添加路径遍历防护(resolve + relative_to 检查) - Token 改用 httpOnly Cookie 存储,移除前端 localStorage token(防 XSS 窃取) - 添加安全响应头中间件(X-Content-Type-Options/X-Frame-Options/Referrer-Policy) - HTTP 请求日志脱敏请求体中的 password/secret/token 等敏感字段 - 权限检查统一使用 user_has_permission(考虑自定义权限,修复 has_permission 忽略 custom_permissions 的缺陷) 性能与稳定性: - cookies.py 列表接口修复 N+1 查询(改为批量查询 Account) - login_service.py run() 结束时关闭 DB Session(防止连接泄漏) - _active_batches/_active_tests 全局字典添加 threading.Lock(防止并发竞态) 配置优化: - CORS 源支持环境变量 CORS_ORIGINS 配置 - Uvicorn reload 支持环境变量 UVICORN_RELOAD 控制(生产环境默认关闭) - Cookie 安全标志支持环境变量 COOKIE_SECURE 配置(HTTPS 部署时启用) - logs.py 权限不足返回 HTTP 403(原来返回 200 + message)
This commit is contained in:
@@ -9,3 +9,12 @@ JWT_SECRET_KEY=
|
|||||||
# 默认管理员账号(仅首次启动建库时生效)
|
# 默认管理员账号(仅首次启动建库时生效)
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Cookie 安全标志(生产环境 HTTPS 部署时设为 true)
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# CORS 允许的源(逗号分隔,不设则默认开发环境)
|
||||||
|
# CORS_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
|
||||||
|
# Uvicorn reload(开发模式设为 true,生产环境保持 false)
|
||||||
|
UVICORN_RELOAD=false
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ services:
|
|||||||
# 默认管理员账号密码(仅首次启动建库时生效)
|
# 默认管理员账号密码(仅首次启动建库时生效)
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
# Cookie 安全标志(HTTPS 部署时设为 true)
|
||||||
|
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||||
|
# CORS 允许的源(逗号分隔)
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
|
# Uvicorn reload(生产环境保持 false)
|
||||||
|
- UVICORN_RELOAD=${UVICORN_RELOAD:-false}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
+5
-1
@@ -45,7 +45,11 @@ trap cleanup EXIT INT TERM
|
|||||||
|
|
||||||
# 启动后端
|
# 启动后端
|
||||||
echo "启动后端 (FastAPI :$BACKEND_PORT)..."
|
echo "启动后端 (FastAPI :$BACKEND_PORT)..."
|
||||||
"$PYTHON" -m uvicorn web.backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload &
|
RELOAD_FLAG=""
|
||||||
|
if [ "${UVICORN_RELOAD:-true}" = "true" ]; then
|
||||||
|
RELOAD_FLAG="--reload"
|
||||||
|
fi
|
||||||
|
"$PYTHON" -m uvicorn web.backend.main:app --host 0.0.0.0 --port $BACKEND_PORT $RELOAD_FLAG &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# 等后端就绪
|
# 等后端就绪
|
||||||
|
|||||||
+42
-1
@@ -51,6 +51,47 @@ def _safe_headers(headers: Any) -> dict:
|
|||||||
return safe
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
# 请求体中需要脱敏的字段名(小写匹配,包含即遮罩)
|
||||||
|
_SENSITIVE_BODY_KEYS = {
|
||||||
|
'password', 'pwd', 'passwd', 'secret', 'token', 'apikey', 'api_key',
|
||||||
|
'email_password', 'mm', 'authorization', 'credential',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_body(body: Any) -> str:
|
||||||
|
"""清理请求体中的敏感字段值。
|
||||||
|
|
||||||
|
支持 dict、JSON 字符串、其他类型。
|
||||||
|
敏感字段的值会被替换为 ***,其余内容保留(仍受 _truncate 限制)。
|
||||||
|
"""
|
||||||
|
if body is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 尝试解析 JSON 字符串
|
||||||
|
parsed = body
|
||||||
|
if isinstance(body, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# 非 JSON 字符串,直接截断
|
||||||
|
return _truncate(body)
|
||||||
|
|
||||||
|
# dict 类型:遮罩敏感字段
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
safe = {}
|
||||||
|
for k, v in parsed.items():
|
||||||
|
if any(s in k.lower() for s in _SENSITIVE_BODY_KEYS):
|
||||||
|
safe[k] = '***'
|
||||||
|
elif isinstance(v, (dict, list)):
|
||||||
|
safe[k] = _safe_body(v) if isinstance(v, dict) else _truncate(str(v))
|
||||||
|
else:
|
||||||
|
safe[k] = v
|
||||||
|
return _truncate(json.dumps(safe, ensure_ascii=False))
|
||||||
|
|
||||||
|
# 其他类型:直接截断
|
||||||
|
return _truncate(str(body))
|
||||||
|
|
||||||
|
|
||||||
def log_http(
|
def log_http(
|
||||||
category: str,
|
category: str,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -93,7 +134,7 @@ def log_http(
|
|||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
"request": {
|
"request": {
|
||||||
"headers": _safe_headers(request_headers),
|
"headers": _safe_headers(request_headers),
|
||||||
"body": _truncate(request_body),
|
"body": _safe_body(request_body),
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
|
|||||||
+34
-4
@@ -1,20 +1,23 @@
|
|||||||
"""FastAPI 依赖注入"""
|
"""FastAPI 依赖注入"""
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from typing import Optional
|
||||||
|
from fastapi import Depends, HTTPException, Request, status, WebSocket
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
|
|
||||||
from .database import get_db
|
from .database import get_db, SessionLocal
|
||||||
from .security import decode_access_token
|
from .security import decode_access_token
|
||||||
from .models import User
|
from .models import User
|
||||||
from .permissions import get_user_permissions
|
from .permissions import get_user_permissions
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
# auto_error=False: 允许 token 为空(后续从 cookie 读取)
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
token: str = Depends(oauth2_scheme),
|
request: Request,
|
||||||
|
token: Optional[str] = Depends(oauth2_scheme),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
credentials_exc = HTTPException(
|
credentials_exc = HTTPException(
|
||||||
@@ -22,6 +25,11 @@ def get_current_user(
|
|||||||
detail="无效的认证凭据",
|
detail="无效的认证凭据",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
|
||||||
|
if not token:
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
if not token:
|
||||||
|
raise credentials_exc
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@@ -48,3 +56,25 @@ def require_permission(permission: str):
|
|||||||
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
||||||
return current_user
|
return current_user
|
||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_websocket(websocket: WebSocket) -> Optional[User]:
|
||||||
|
"""WebSocket 认证:从 cookie 或 query param token 中验证用户身份。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User 如果认证成功,None 如果失败。
|
||||||
|
"""
|
||||||
|
token = websocket.cookies.get("access_token") or websocket.query_params.get("token")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload or not payload.get("sub"):
|
||||||
|
return None
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == int(payload["sub"])).first()
|
||||||
|
if user and user.is_active:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
+31
-5
@@ -8,6 +8,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import auth, users, accounts, login, proxy, cookies, logs
|
from .routers import auth, users, accounts, login, proxy, cookies, logs
|
||||||
@@ -25,15 +26,34 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS(开发期允许前端 localhost:5173)
|
# CORS(支持通过环境变量配置,逗号分隔;默认开发环境)
|
||||||
|
_cors_env = os.getenv("CORS_ORIGINS", "")
|
||||||
|
if _cors_env:
|
||||||
|
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||||
|
else:
|
||||||
|
_cors_origins = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
allow_origins=_cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 安全响应头中间件
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
@@ -66,8 +86,13 @@ if _INDEX_HTML.exists():
|
|||||||
# 排除 API 路径
|
# 排除 API 路径
|
||||||
if full_path.startswith("api"):
|
if full_path.startswith("api"):
|
||||||
return {"detail": "Not Found"}
|
return {"detail": "Not Found"}
|
||||||
# 尝试返回静态文件
|
# 尝试返回静态文件(防护路径遍历)
|
||||||
file_path = _FRONTEND_DIST / full_path
|
file_path = (_FRONTEND_DIST / full_path).resolve()
|
||||||
|
try:
|
||||||
|
file_path.relative_to(_FRONTEND_DIST.resolve())
|
||||||
|
except ValueError:
|
||||||
|
# 路径逃逸出 dist 目录,拒绝访问
|
||||||
|
return {"detail": "Not Found"}
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
return FileResponse(str(file_path))
|
return FileResponse(str(file_path))
|
||||||
# SPA fallback 到 index.html
|
# SPA fallback 到 index.html
|
||||||
@@ -75,7 +100,8 @@ if _INDEX_HTML.exists():
|
|||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=True)
|
_reload = os.getenv("UVICORN_RELOAD", "false").lower() == "true"
|
||||||
|
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=_reload)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -75,5 +75,16 @@ def get_user_permissions(user) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def has_permission(role: str, permission: str) -> bool:
|
def has_permission(role: str, permission: str) -> bool:
|
||||||
"""检查角色是否拥有某权限。"""
|
"""检查角色是否拥有某权限(仅检查角色默认权限,不考虑自定义权限)。
|
||||||
|
|
||||||
|
注意:对于需要考虑用户自定义权限的场景,请使用 user_has_permission()。
|
||||||
|
"""
|
||||||
return permission in get_role_permissions(role)
|
return permission in get_role_permissions(role)
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_permission(user, permission: str) -> bool:
|
||||||
|
"""检查用户是否拥有某权限(考虑自定义权限)。
|
||||||
|
|
||||||
|
优先使用用户的 custom_permissions,若为 None 则回退到角色默认权限。
|
||||||
|
"""
|
||||||
|
return permission in get_user_permissions(user)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ..database import get_db
|
|||||||
from ..models import User, Account, AuditLog, LoginTask
|
from ..models import User, Account, AuditLog, LoginTask
|
||||||
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
||||||
from ..deps import get_current_user, require_permission
|
from ..deps import get_current_user, require_permission
|
||||||
from ..permissions import has_permission
|
from ..permissions import user_has_permission
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ def list_accounts(
|
|||||||
query = query.filter(Account.id.in_(_cookie_account_ids_query(db)))
|
query = query.filter(Account.id.in_(_cookie_account_ids_query(db)))
|
||||||
|
|
||||||
# 权限控制:客服只能看分配给自己的
|
# 权限控制:客服只能看分配给自己的
|
||||||
if not has_permission(current.role, "account:view_all"):
|
if not user_has_permission(current, "account:view_all"):
|
||||||
if has_permission(current.role, "account:view_assigned"):
|
if user_has_permission(current, "account:view_assigned"):
|
||||||
query = query.filter(Account.assigned_to == current.id)
|
query = query.filter(Account.assigned_to == current.id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=403, detail="无权查看账号")
|
raise HTTPException(status_code=403, detail="无权查看账号")
|
||||||
|
|
||||||
if assigned_only and has_permission(current.role, "account:view_all"):
|
if assigned_only and user_has_permission(current, "account:view_all"):
|
||||||
query = query.filter(Account.assigned_to.isnot(None))
|
query = query.filter(Account.assigned_to.isnot(None))
|
||||||
|
|
||||||
if tag:
|
if tag:
|
||||||
@@ -76,7 +76,7 @@ def list_accounts(
|
|||||||
created_at=acc.created_at,
|
created_at=acc.created_at,
|
||||||
)
|
)
|
||||||
# 运营+超管可看完整字段
|
# 运营+超管可看完整字段
|
||||||
if has_permission(current.role, "account:view_all"):
|
if user_has_permission(current, "account:view_all"):
|
||||||
item.password = acc.password
|
item.password = acc.password
|
||||||
item.email = acc.email
|
item.email = acc.email
|
||||||
item.email_password = acc.email_password
|
item.email_password = acc.email_password
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
"""认证路由"""
|
"""认证路由"""
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, AuditLog
|
from ..models import User, AuditLog
|
||||||
from ..security import verify_password, create_access_token
|
from ..security import verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_HOURS
|
||||||
from ..permissions import get_user_permissions, ROLE_LABELS
|
from ..permissions import get_user_permissions, ROLE_LABELS
|
||||||
from ..schemas import LoginRequest, TokenResponse
|
from ..schemas import LoginRequest, TokenResponse
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
|
||||||
|
# Cookie 安全配置(生产环境 HTTPS 部署时设置 COOKIE_SECURE=true)
|
||||||
|
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||||
user = db.query(User).filter(User.username == req.username).first()
|
user = db.query(User).filter(User.username == req.username).first()
|
||||||
if not user or not verify_password(req.password, user.password_hash):
|
if not user or not verify_password(req.password, user.password_hash):
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
@@ -25,6 +29,17 @@ def login(req: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||||
perms = get_user_permissions(user)
|
perms = get_user_permissions(user)
|
||||||
|
|
||||||
|
# 设置 httpOnly cookie(前端无需 JS 读取 token,防 XSS 窃取)
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
# 审计
|
# 审计
|
||||||
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
|
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -51,7 +66,8 @@ def me(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
def logout(response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
response.delete_cookie(key="access_token", path="/")
|
||||||
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
|
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已登出"}
|
return {"message": "已登出"}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import csv
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, LoginTask, Account
|
from ..models import User, LoginTask, Account
|
||||||
from ..deps import get_current_user, require_permission
|
from ..deps import get_current_user, require_permission
|
||||||
from ..permissions import has_permission
|
from ..permissions import user_has_permission
|
||||||
|
|
||||||
|
|
||||||
def _fmt_dt(dt) -> str | None:
|
def _fmt_dt(dt) -> str | None:
|
||||||
@@ -33,15 +33,23 @@ def list_cookies(
|
|||||||
query = db.query(LoginTask).filter(LoginTask.status == "success")
|
query = db.query(LoginTask).filter(LoginTask.status == "success")
|
||||||
|
|
||||||
# 客服只能看自己账号的
|
# 客服只能看自己账号的
|
||||||
if not has_permission(current.role, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
||||||
Account.assigned_to == current.id
|
Account.assigned_to == current.id
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks = query.order_by(LoginTask.finished_at.desc()).all()
|
tasks = query.order_by(LoginTask.finished_at.desc()).all()
|
||||||
|
|
||||||
|
# 批量查账号,避免 N+1 查询
|
||||||
|
account_ids = [t.account_id for t in tasks]
|
||||||
|
accounts_map = {}
|
||||||
|
if account_ids:
|
||||||
|
accs = db.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||||
|
accounts_map = {a.id: a for a in accs}
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
acc = db.query(Account).filter(Account.id == t.account_id).first()
|
acc = accounts_map.get(t.account_id)
|
||||||
item = {
|
item = {
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
"batch_id": t.batch_id,
|
"batch_id": t.batch_id,
|
||||||
@@ -52,7 +60,7 @@ def list_cookies(
|
|||||||
"created_at": _fmt_dt(t.finished_at),
|
"created_at": _fmt_dt(t.finished_at),
|
||||||
}
|
}
|
||||||
# 只有有 cookie:view 权限才返回 cookie 内容
|
# 只有有 cookie:view 权限才返回 cookie 内容
|
||||||
if has_permission(current.role, "cookie:view"):
|
if user_has_permission(current, "cookie:view"):
|
||||||
cookie = t.cookie or ""
|
cookie = t.cookie or ""
|
||||||
item["cookie"] = cookie
|
item["cookie"] = cookie
|
||||||
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
|
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db, SessionLocal
|
from ..database import get_db, SessionLocal
|
||||||
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
||||||
from ..schemas import LoginBatchRequest, LoginTaskOut
|
from ..schemas import LoginBatchRequest, LoginTaskOut
|
||||||
from ..deps import get_current_user, require_permission
|
from ..deps import get_current_user, require_permission, authenticate_websocket
|
||||||
from ..permissions import has_permission
|
from ..permissions import user_has_permission, get_user_permissions
|
||||||
from ..services.login_service import LoginBatchRunner
|
from ..services.login_service import LoginBatchRunner
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
||||||
|
|
||||||
# 运行中的批次: batch_id -> {log_queue, loop, runner}
|
# 运行中的批次: batch_id -> {log_queue, loop, runner}
|
||||||
_active_batches: dict[str, dict] = {}
|
_active_batches: dict[str, dict] = {}
|
||||||
|
_active_batches_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
@@ -38,7 +39,7 @@ async def create_batch(
|
|||||||
acc = db.query(Account).filter(Account.id == aid).first()
|
acc = db.query(Account).filter(Account.id == aid).first()
|
||||||
if not acc:
|
if not acc:
|
||||||
continue
|
continue
|
||||||
if not has_permission(current.role, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
if acc.assigned_to != current.id:
|
if acc.assigned_to != current.id:
|
||||||
continue
|
continue
|
||||||
valid_ids.append(aid)
|
valid_ids.append(aid)
|
||||||
@@ -56,7 +57,7 @@ async def create_batch(
|
|||||||
db=thread_db,
|
db=thread_db,
|
||||||
account_ids=valid_ids,
|
account_ids=valid_ids,
|
||||||
created_by=current.id,
|
created_by=current.id,
|
||||||
creator_role=current.role,
|
creator_permissions=get_user_permissions(current),
|
||||||
max_geetest_retries=req.max_geetest_retries,
|
max_geetest_retries=req.max_geetest_retries,
|
||||||
max_proxy_retries=req.max_proxy_retries,
|
max_proxy_retries=req.max_proxy_retries,
|
||||||
proxy_config=proxy,
|
proxy_config=proxy,
|
||||||
@@ -68,11 +69,12 @@ async def create_batch(
|
|||||||
batch_id = runner.batch_id
|
batch_id = runner.batch_id
|
||||||
|
|
||||||
# 先注册到全局,再启动线程,确保 WebSocket 连接时能找到
|
# 先注册到全局,再启动线程,确保 WebSocket 连接时能找到
|
||||||
_active_batches[batch_id] = {
|
with _active_batches_lock:
|
||||||
"log_queue": log_queue,
|
_active_batches[batch_id] = {
|
||||||
"loop": loop,
|
"log_queue": log_queue,
|
||||||
"runner": runner,
|
"loop": loop,
|
||||||
}
|
"runner": runner,
|
||||||
|
}
|
||||||
|
|
||||||
# 启动线程
|
# 启动线程
|
||||||
thread = threading.Thread(target=runner.run, daemon=True)
|
thread = threading.Thread(target=runner.run, daemon=True)
|
||||||
@@ -91,7 +93,7 @@ def list_tasks(
|
|||||||
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
||||||
|
|
||||||
# 客服只能看自己账号的任务
|
# 客服只能看自己账号的任务
|
||||||
if not has_permission(current.role, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
query = query.filter(Account.assigned_to == current.id)
|
query = query.filter(Account.assigned_to == current.id)
|
||||||
|
|
||||||
if batch_id:
|
if batch_id:
|
||||||
@@ -153,7 +155,8 @@ def stop_batch(
|
|||||||
batch_id: str,
|
batch_id: str,
|
||||||
current: User = Depends(require_permission("login:batch")),
|
current: User = Depends(require_permission("login:batch")),
|
||||||
):
|
):
|
||||||
batch = _active_batches.get(batch_id)
|
with _active_batches_lock:
|
||||||
|
batch = _active_batches.get(batch_id)
|
||||||
if batch:
|
if batch:
|
||||||
batch["runner"].stop()
|
batch["runner"].stop()
|
||||||
return {"message": "已发送停止信号", "success": True}
|
return {"message": "已发送停止信号", "success": True}
|
||||||
@@ -162,11 +165,18 @@ def stop_batch(
|
|||||||
|
|
||||||
@router.websocket("/ws/login/{batch_id}")
|
@router.websocket("/ws/login/{batch_id}")
|
||||||
async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||||
"""WebSocket 推送登录实时日志。"""
|
"""WebSocket 推送登录实时日志(需认证)。"""
|
||||||
|
# 认证:从 cookie 或 token query param 验证用户身份
|
||||||
|
user = authenticate_websocket(websocket)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=1008, reason="未授权")
|
||||||
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
# 从已注册的批次中获取 log_queue(由 create_batch 创建)
|
# 从已注册的批次中获取 log_queue(由 create_batch 创建)
|
||||||
batch = _active_batches.get(batch_id)
|
with _active_batches_lock:
|
||||||
|
batch = _active_batches.get(batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
|
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
@@ -188,4 +198,5 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
_active_batches.pop(batch_id, None)
|
with _active_batches_lock:
|
||||||
|
_active_batches.pop(batch_id, None)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""请求日志路由 - 查看 HTTP 请求/响应详情日志"""
|
"""请求日志路由 - 查看 HTTP 请求/响应详情日志"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..permissions import has_permission
|
from ..permissions import user_has_permission
|
||||||
from utils.http_logger import read_http_logs, clear_http_logs
|
from utils.http_logger import read_http_logs, clear_http_logs
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["日志"])
|
router = APIRouter(prefix="/api/logs", tags=["日志"])
|
||||||
@@ -20,10 +20,10 @@ def list_http_logs(
|
|||||||
current=Depends(get_current_user),
|
current=Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""查看 HTTP 请求/响应详情日志(需要审计日志查看权限)"""
|
"""查看 HTTP 请求/响应详情日志(需要审计日志查看权限)"""
|
||||||
if not has_permission(current.role, "audit:view"):
|
if not user_has_permission(current, "audit:view"):
|
||||||
# 运营也可以查看请求日志(用于排查登录问题)
|
# 运营也可以查看请求日志(用于排查登录问题)
|
||||||
if not has_permission(current.role, "login:batch"):
|
if not user_has_permission(current, "login:batch"):
|
||||||
return {"items": [], "total": 0, "message": "无权限"}
|
raise HTTPException(status_code=403, detail="无权限")
|
||||||
|
|
||||||
items, total = read_http_logs(
|
items, total = read_http_logs(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -38,9 +38,9 @@ def list_http_logs(
|
|||||||
@router.delete("/http")
|
@router.delete("/http")
|
||||||
def clear_http_logs_api(current=Depends(get_current_user)):
|
def clear_http_logs_api(current=Depends(get_current_user)):
|
||||||
"""清空 HTTP 请求/响应详情日志"""
|
"""清空 HTTP 请求/响应详情日志"""
|
||||||
if not has_permission(current.role, "audit:view"):
|
if not user_has_permission(current, "audit:view"):
|
||||||
if not has_permission(current.role, "login:batch"):
|
if not user_has_permission(current, "login:batch"):
|
||||||
return {"success": False, "message": "无权限"}
|
raise HTTPException(status_code=403, detail="无权限")
|
||||||
|
|
||||||
count = clear_http_logs()
|
count = clear_http_logs()
|
||||||
return {"success": True, "cleared": count}
|
return {"success": True, "cleared": count}
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
|
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
|
||||||
from ..schemas import ProxyConfigOut, ProxyConfigUpdate
|
from ..schemas import ProxyConfigOut, ProxyConfigUpdate
|
||||||
from ..deps import require_permission
|
from ..deps import require_permission, authenticate_websocket
|
||||||
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
||||||
|
|
||||||
# 运行中的测试: test_id -> {log_queue, loop, result}
|
# 运行中的测试: test_id -> {log_queue, loop, result}
|
||||||
_active_tests: dict[str, dict] = {}
|
_active_tests: dict[str, dict] = {}
|
||||||
|
_active_tests_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create(db: Session) -> ProxyConfigModel:
|
def _get_or_create(db: Session) -> ProxyConfigModel:
|
||||||
@@ -63,10 +64,17 @@ def update_proxy_config(
|
|||||||
|
|
||||||
@router.websocket("/ws/test/{test_id}")
|
@router.websocket("/ws/test/{test_id}")
|
||||||
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||||
"""WebSocket 推送代理/白名单测试实时日志。"""
|
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
|
||||||
|
# 认证:从 cookie 或 token query param 验证用户身份
|
||||||
|
user = authenticate_websocket(websocket)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=1008, reason="未授权")
|
||||||
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
test = _active_tests.get(test_id)
|
with _active_tests_lock:
|
||||||
|
test = _active_tests.get(test_id)
|
||||||
if not test:
|
if not test:
|
||||||
await websocket.send_json({"level": "error", "message": "测试任务不存在"})
|
await websocket.send_json({"level": "error", "message": "测试任务不存在"})
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
@@ -88,7 +96,8 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
_active_tests.pop(test_id, None)
|
with _active_tests_lock:
|
||||||
|
_active_tests.pop(test_id, None)
|
||||||
|
|
||||||
|
|
||||||
# ---- 异步测试执行 ----
|
# ---- 异步测试执行 ----
|
||||||
@@ -263,7 +272,8 @@ async def test_proxy(
|
|||||||
log_queue = asyncio.Queue()
|
log_queue = asyncio.Queue()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
with _active_tests_lock:
|
||||||
|
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||||
|
|
||||||
thread = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True)
|
thread = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -283,7 +293,8 @@ async def test_whitelist(
|
|||||||
log_queue = asyncio.Queue()
|
log_queue = asyncio.Queue()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
with _active_tests_lock:
|
||||||
|
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||||
|
|
||||||
thread = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True)
|
thread = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from core.douyu import DouyuLogin
|
|||||||
from core.models import Account, ProxyConfig as DouyuProxyConfig
|
from core.models import Account, ProxyConfig as DouyuProxyConfig
|
||||||
from core.douyu.proxy import resolve_working_proxy, get_proxy_manager
|
from core.douyu.proxy import resolve_working_proxy, get_proxy_manager
|
||||||
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
||||||
from ..permissions import has_permission
|
|
||||||
|
|
||||||
|
|
||||||
class LoginBatchRunner:
|
class LoginBatchRunner:
|
||||||
@@ -24,7 +23,7 @@ class LoginBatchRunner:
|
|||||||
db: Session,
|
db: Session,
|
||||||
account_ids: list[int],
|
account_ids: list[int],
|
||||||
created_by: int,
|
created_by: int,
|
||||||
creator_role: str,
|
creator_permissions: list[str],
|
||||||
max_geetest_retries: int = 5,
|
max_geetest_retries: int = 5,
|
||||||
max_proxy_retries: int = 10,
|
max_proxy_retries: int = 10,
|
||||||
proxy_config: Optional[ProxyConfigModel] = None,
|
proxy_config: Optional[ProxyConfigModel] = None,
|
||||||
@@ -35,7 +34,7 @@ class LoginBatchRunner:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.account_ids = account_ids
|
self.account_ids = account_ids
|
||||||
self.created_by = created_by
|
self.created_by = created_by
|
||||||
self.creator_role = creator_role
|
self.creator_permissions = creator_permissions
|
||||||
self.max_geetest_retries = max_geetest_retries
|
self.max_geetest_retries = max_geetest_retries
|
||||||
self.max_proxy_retries = max_proxy_retries
|
self.max_proxy_retries = max_proxy_retries
|
||||||
self.proxy_config = proxy_config
|
self.proxy_config = proxy_config
|
||||||
@@ -175,87 +174,91 @@ class LoginBatchRunner:
|
|||||||
concurrency = self.concurrency
|
concurrency = self.concurrency
|
||||||
self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}")
|
self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}")
|
||||||
|
|
||||||
# 创建或复用任务记录(顺序执行,线程安全)
|
try:
|
||||||
task_infos: list[dict] = [] # {task_id, acc_info}
|
# 创建或复用任务记录(顺序执行,线程安全)
|
||||||
for aid in self.account_ids:
|
task_infos: list[dict] = [] # {task_id, acc_info}
|
||||||
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first()
|
for aid in self.account_ids:
|
||||||
if not acc:
|
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first()
|
||||||
continue
|
if not acc:
|
||||||
# 权限检查:客服只能跑分配给自己的
|
|
||||||
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
|
continue
|
||||||
|
# 权限检查:客服只能跑分配给自己的
|
||||||
|
if "login:view_all" not in self.creator_permissions:
|
||||||
|
if acc.assigned_to != self.created_by:
|
||||||
|
self._push_log("warning", f"跳过无权账号: {acc.username}")
|
||||||
|
continue
|
||||||
|
|
||||||
# 复用该账号最近一条失败任务记录,避免重复产生多条
|
# 复用该账号最近一条失败任务记录,避免重复产生多条
|
||||||
existing_task = (
|
existing_task = (
|
||||||
self.db.query(LoginTask)
|
self.db.query(LoginTask)
|
||||||
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"]))
|
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"]))
|
||||||
.order_by(LoginTask.id.desc())
|
.order_by(LoginTask.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
|
||||||
if existing_task:
|
|
||||||
existing_task.batch_id = batch_id
|
|
||||||
existing_task.status = "pending"
|
|
||||||
existing_task.cookie = ""
|
|
||||||
existing_task.message = ""
|
|
||||||
existing_task.finished_at = None
|
|
||||||
task = existing_task
|
|
||||||
else:
|
|
||||||
task = LoginTask(
|
|
||||||
batch_id=batch_id,
|
|
||||||
account_id=aid,
|
|
||||||
status="pending",
|
|
||||||
created_by=self.created_by,
|
|
||||||
)
|
)
|
||||||
self.db.add(task)
|
if existing_task:
|
||||||
|
existing_task.batch_id = batch_id
|
||||||
|
existing_task.status = "pending"
|
||||||
|
existing_task.cookie = ""
|
||||||
|
existing_task.message = ""
|
||||||
|
existing_task.finished_at = None
|
||||||
|
task = existing_task
|
||||||
|
else:
|
||||||
|
task = LoginTask(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_id=aid,
|
||||||
|
status="pending",
|
||||||
|
created_by=self.created_by,
|
||||||
|
)
|
||||||
|
self.db.add(task)
|
||||||
|
|
||||||
self.db.flush() # 获取 task.id
|
self.db.flush() # 获取 task.id
|
||||||
|
|
||||||
task_infos.append({
|
task_infos.append({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"acc_info": {
|
"acc_info": {
|
||||||
"username": acc.username,
|
"username": acc.username,
|
||||||
"password": acc.password,
|
"password": acc.password,
|
||||||
"email": acc.email,
|
"email": acc.email,
|
||||||
"email_password": acc.email_password,
|
"email_password": acc.email_password,
|
||||||
"email_imap_server": acc.email_imap_server or "",
|
"email_imap_server": acc.email_imap_server or "",
|
||||||
"email_imap_port": acc.email_imap_port or 993,
|
"email_imap_port": acc.email_imap_port or 993,
|
||||||
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
|
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
total = len(task_infos)
|
total = len(task_infos)
|
||||||
if total == 0:
|
if total == 0:
|
||||||
self._push_log("warning", "没有可执行的账号")
|
self._push_log("warning", "没有可执行的账号")
|
||||||
|
self._push_log("result", "")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 并发执行登录,每个账号独立获取代理
|
||||||
|
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||||
|
futures = []
|
||||||
|
for item in task_infos:
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._push_log("warning", "任务已停止,跳过剩余账号")
|
||||||
|
break
|
||||||
|
future = executor.submit(
|
||||||
|
self._execute_one,
|
||||||
|
item["task_id"],
|
||||||
|
item["acc_info"],
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
futures.append(future)
|
||||||
|
|
||||||
|
# 等待所有任务完成
|
||||||
|
for future in as_completed(futures):
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except Exception as e:
|
||||||
|
self._push_log("error", f"Worker 异常: {e}")
|
||||||
|
|
||||||
|
self._push_log("info", f"批量登录任务 {batch_id} 完成")
|
||||||
self._push_log("result", "")
|
self._push_log("result", "")
|
||||||
return
|
finally:
|
||||||
|
# 确保 DB Session 被关闭,避免连接泄漏
|
||||||
# 并发执行登录,每个账号独立获取代理
|
self.db.close()
|
||||||
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
||||||
futures = []
|
|
||||||
for item in task_infos:
|
|
||||||
if self._stop.is_set():
|
|
||||||
self._push_log("warning", "任务已停止,跳过剩余账号")
|
|
||||||
break
|
|
||||||
future = executor.submit(
|
|
||||||
self._execute_one,
|
|
||||||
item["task_id"],
|
|
||||||
item["acc_info"],
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
futures.append(future)
|
|
||||||
|
|
||||||
# 等待所有任务完成
|
|
||||||
for future in as_completed(futures):
|
|
||||||
try:
|
|
||||||
future.result()
|
|
||||||
except Exception as e:
|
|
||||||
self._push_log("error", f"Worker 异常: {e}")
|
|
||||||
|
|
||||||
self._push_log("info", f"批量登录任务 {batch_id} 完成")
|
|
||||||
self._push_log("result", "")
|
|
||||||
|
|
||||||
|
|
||||||
# 在模块末尾导入 SessionLocal(避免循环导入)
|
# 在模块末尾导入 SessionLocal(避免循环导入)
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import ProxyPage from './pages/ProxyPage';
|
|||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import CookiePage from './pages/CookiePage';
|
import CookiePage from './pages/CookiePage';
|
||||||
import HttpLogsPage from './pages/HttpLogsPage';
|
import HttpLogsPage from './pages/HttpLogsPage';
|
||||||
import { getToken } from './store/auth';
|
import { getUser } from './store/auth';
|
||||||
import { ThemeProvider, useTheme } from './store/theme';
|
import { ThemeProvider, useTheme } from './store/theme';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
|
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
|
||||||
const [authVersion, setAuthVersion] = useState(0);
|
const [authVersion, setAuthVersion] = useState(0);
|
||||||
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
|
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
|
||||||
const isLoggedIn = !!getToken();
|
const isLoggedIn = !!getUser();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ import axios from 'axios';
|
|||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
withCredentials: true, // 携带 httpOnly cookie
|
||||||
|
|
||||||
// 请求拦截:携带 token
|
|
||||||
api.interceptors.request.use((config) => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 响应拦截:统一错误处理
|
// 响应拦截:统一错误处理
|
||||||
@@ -19,7 +11,6 @@ api.interceptors.response.use(
|
|||||||
(response) => response.data,
|
(response) => response.data,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
|
|||||||
role: result.role,
|
role: result.role,
|
||||||
permissions: result.permissions,
|
permissions: result.permissions,
|
||||||
};
|
};
|
||||||
setAuth(result.access_token, user);
|
setAuth(user);
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
onLogin?.(); // 触发 App 重渲染
|
onLogin?.(); // 触发 App 重渲染
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ export interface AuthUser {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'token';
|
|
||||||
const USER_KEY = 'user';
|
const USER_KEY = 'user';
|
||||||
|
|
||||||
export function getToken(): string | null {
|
// Token 存储在 httpOnly cookie 中,JS 无法读取,防 XSS 窃取
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
// 前端仅用 localStorage 存储用户信息(非敏感),用于判断登录状态和显示
|
||||||
}
|
|
||||||
|
|
||||||
export function getUser(): AuthUser | null {
|
export function getUser(): AuthUser | null {
|
||||||
const raw = localStorage.getItem(USER_KEY);
|
const raw = localStorage.getItem(USER_KEY);
|
||||||
@@ -25,28 +23,27 @@ export function getUser(): AuthUser | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuth(token: string, user: AuthUser) {
|
export function setAuth(user: AuthUser) {
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurrentUser(): Promise<AuthUser | null> {
|
export async function fetchCurrentUser(): Promise<AuthUser | null> {
|
||||||
const token = getToken();
|
// 通过 /me 接口验证 cookie 是否有效
|
||||||
if (!token) return null;
|
|
||||||
try {
|
try {
|
||||||
const data = await authApi.me();
|
const data = await authApi.me();
|
||||||
return {
|
const user: AuthUser = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
role_label: data.role_label,
|
role_label: data.role_label,
|
||||||
permissions: data.permissions,
|
permissions: data.permissions,
|
||||||
};
|
};
|
||||||
|
setAuth(user);
|
||||||
|
return user;
|
||||||
} catch {
|
} catch {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user