type: 收敛测试 schemas 与协议层类型
This commit is contained in:
+5
-2
@@ -34,9 +34,10 @@ def get_current_user(
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exc
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
raise credentials_exc
|
||||
user_id = int(user_id_raw)
|
||||
except JWTError:
|
||||
raise credentials_exc
|
||||
|
||||
@@ -48,6 +49,7 @@ def get_current_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="账号已禁用")
|
||||
@@ -55,6 +57,7 @@ def require_permission(permission: str):
|
||||
if permission not in perms:
|
||||
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
||||
return current_user
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
|
||||
+193
-73
@@ -1,16 +1,32 @@
|
||||
"""账号管理路由"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, defer, joinedload
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount
|
||||
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
||||
from ..models import (
|
||||
User,
|
||||
Account,
|
||||
AuditLog,
|
||||
LoginTask,
|
||||
DouyuTask,
|
||||
DouyuWorkbenchAccount,
|
||||
)
|
||||
from ..schemas import (
|
||||
AccountBulkSelection,
|
||||
AccountBulkTag,
|
||||
AccountImport,
|
||||
AccountAssign,
|
||||
AccountTag,
|
||||
AccountOut,
|
||||
BatchAssign,
|
||||
)
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import user_has_permission
|
||||
from ..services.account_service import (
|
||||
cookie_account_ids_query, parse_and_build_accounts,
|
||||
cookie_account_ids_query,
|
||||
parse_and_build_accounts,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||
@@ -46,15 +62,19 @@ def _filter_accounts_query(
|
||||
search_text = (search or "").strip()
|
||||
if search_text:
|
||||
pattern = f"%{search_text}%"
|
||||
query = query.filter(or_(
|
||||
Account.username.ilike(pattern),
|
||||
Account.tag.ilike(pattern),
|
||||
Account.remark.ilike(pattern),
|
||||
))
|
||||
query = query.filter(
|
||||
or_(
|
||||
Account.username.ilike(pattern),
|
||||
Account.tag.ilike(pattern),
|
||||
Account.remark.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection) -> list[int]:
|
||||
def _selected_account_ids(
|
||||
db: Session, current: User, req: AccountBulkSelection
|
||||
) -> list[int]:
|
||||
"""解析批量操作目标:当前筛选全部或显式选择的 ID。"""
|
||||
if req.all_matching:
|
||||
rows = (
|
||||
@@ -71,7 +91,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
|
||||
.order_by(None)
|
||||
.all()
|
||||
)
|
||||
return [account_id for account_id, in rows]
|
||||
return [account_id for (account_id,) in rows]
|
||||
|
||||
seen = set()
|
||||
ids = []
|
||||
@@ -88,7 +108,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
|
||||
.order_by(None)
|
||||
.all()
|
||||
)
|
||||
allowed = {account_id for account_id, in rows}
|
||||
allowed = {account_id for (account_id,) in rows}
|
||||
return [account_id for account_id in ids if account_id in allowed]
|
||||
|
||||
|
||||
@@ -131,19 +151,25 @@ def list_accounts(
|
||||
if page is not None:
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
can_include_sensitive = include_sensitive and user_has_permission(current, "account:view_full")
|
||||
can_include_sensitive = include_sensitive and user_has_permission(
|
||||
current, "account:view_full"
|
||||
)
|
||||
options = [joinedload(Account.assigned_user)]
|
||||
if not can_include_sensitive:
|
||||
options.extend([
|
||||
defer(Account.password),
|
||||
defer(Account.email),
|
||||
defer(Account.email_password),
|
||||
])
|
||||
options.extend(
|
||||
[
|
||||
defer(Account.password),
|
||||
defer(Account.email),
|
||||
defer(Account.email_password),
|
||||
]
|
||||
)
|
||||
accounts = query.options(*options).all()
|
||||
result = []
|
||||
for acc in accounts:
|
||||
item = AccountOut(
|
||||
id=acc.id, username=acc.username, remark=acc.remark or "",
|
||||
id=acc.id,
|
||||
username=acc.username,
|
||||
remark=acc.remark or "",
|
||||
tag=acc.tag or "",
|
||||
assigned_to=acc.assigned_to,
|
||||
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
|
||||
@@ -156,7 +182,12 @@ def list_accounts(
|
||||
item.email_password = acc.email_password
|
||||
result.append(item)
|
||||
if page is not None:
|
||||
return {"items": result, "total": total or 0, "page": page, "page_size": page_size}
|
||||
return {
|
||||
"items": result,
|
||||
"total": total or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -209,8 +240,14 @@ def import_accounts(
|
||||
|
||||
if accounts:
|
||||
db.add_all(accounts)
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:import", target=f"导入{len(accounts)}个"))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:import",
|
||||
target=f"导入{len(accounts)}个",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
duplicated_note = f",重复跳过 {duplicated} 个" if duplicated else ""
|
||||
@@ -240,17 +277,27 @@ def assign_account(
|
||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||
|
||||
# 只能分配已成功登录过的账号(有cookie)
|
||||
has_success = db.query(LoginTask).filter(
|
||||
LoginTask.account_id == account_id,
|
||||
LoginTask.status == 'success',
|
||||
LoginTask.cookie != '',
|
||||
).first()
|
||||
has_success = (
|
||||
db.query(LoginTask)
|
||||
.filter(
|
||||
LoginTask.account_id == account_id,
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not has_success:
|
||||
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.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:assign",
|
||||
target=acc.username,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": "已分配", "success": True}
|
||||
|
||||
@@ -274,31 +321,52 @@ def batch_assign_accounts(
|
||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||
|
||||
# 只能分配已成功登录过的账号(有cookie)
|
||||
cookie_ids_query = cookie_account_ids_query(db).subquery()
|
||||
invalid_ids = db.query(Account.id).filter(
|
||||
Account.id.in_(req.account_ids),
|
||||
Account.id.notin_(cookie_ids_query),
|
||||
).all()
|
||||
cookie_ids_query = select(LoginTask.account_id).where(
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
LoginTask.cookie.isnot(None),
|
||||
)
|
||||
invalid_ids = (
|
||||
db.query(Account.id)
|
||||
.filter(
|
||||
Account.id.in_(req.account_ids),
|
||||
Account.id.notin_(cookie_ids_query),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if invalid_ids:
|
||||
names = db.query(Account.username).filter(Account.id.in_([i[0] for i in invalid_ids])).all()
|
||||
name_list = ', '.join([n[0] for n in names[:5]])
|
||||
suffix = '...' if len(invalid_ids) > 5 else ''
|
||||
names = (
|
||||
db.query(Account.username)
|
||||
.filter(Account.id.in_([i[0] for i in invalid_ids]))
|
||||
.all()
|
||||
)
|
||||
name_list = ", ".join([n[0] for n in names[:5]])
|
||||
suffix = "..." if len(invalid_ids) > 5 else ""
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
|
||||
)
|
||||
|
||||
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
|
||||
{Account.assigned_to: req.assigned_to}, synchronize_session=False
|
||||
count = (
|
||||
db.query(Account)
|
||||
.filter(Account.id.in_(req.account_ids))
|
||||
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
|
||||
)
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:assign",
|
||||
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号",
|
||||
)
|
||||
)
|
||||
db.add(AuditLog(
|
||||
user_id=current.id, username=current.username,
|
||||
action="account:assign",
|
||||
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号"
|
||||
))
|
||||
db.commit()
|
||||
action = "分配" if req.assigned_to else "取消分配"
|
||||
return {"message": f"已批量{action} {count} 个账号", "success": True, "count": count}
|
||||
return {
|
||||
"message": f"已批量{action} {count} 个账号",
|
||||
"success": True,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/assignments/summary")
|
||||
@@ -307,11 +375,17 @@ def assignments_summary(
|
||||
current: User = Depends(require_permission("account:assign")),
|
||||
):
|
||||
"""分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。"""
|
||||
cookie_subq = cookie_account_ids_query(db).subquery()
|
||||
cookie_subq = select(LoginTask.account_id).where(
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
LoginTask.cookie.isnot(None),
|
||||
)
|
||||
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||
|
||||
results = (
|
||||
db.query(User.id, User.username, func.count(cookie_accounts.c.id).label("count"))
|
||||
db.query(
|
||||
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
|
||||
)
|
||||
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||
.filter(User.role == "support")
|
||||
.group_by(User.id, User.username)
|
||||
@@ -345,8 +419,15 @@ def set_account_tag(
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
acc.tag = (req.tag or "").strip()
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:tag", target=acc.username, detail=acc.tag))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:tag",
|
||||
target=acc.username,
|
||||
detail=acc.tag,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": "标签已更新", "success": True}
|
||||
|
||||
@@ -362,8 +443,10 @@ def batch_tag(
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择账号")
|
||||
tag = (req.tag or "").strip()
|
||||
count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update(
|
||||
{Account.tag: tag}, synchronize_session=False
|
||||
count = (
|
||||
_visible_accounts_query(db, current)
|
||||
.filter(Account.id.in_(req.account_ids))
|
||||
.update({Account.tag: tag}, synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"已为 {count} 个账号设置标签", "success": True}
|
||||
@@ -388,7 +471,11 @@ def batch_tag_selection(
|
||||
)
|
||||
db.commit()
|
||||
scope = "当前筛选下" if req.all_matching else "选中的"
|
||||
return {"message": f"已为{scope} {count} 个账号设置标签", "success": True, "count": count}
|
||||
return {
|
||||
"message": f"已为{scope} {count} 个账号设置标签",
|
||||
"success": True,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tags/list")
|
||||
@@ -422,17 +509,29 @@ def batch_delete_accounts(
|
||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||
|
||||
# 先删除关联的登录任务
|
||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuWorkbenchAccount).filter(
|
||||
DouyuWorkbenchAccount.account_id.in_(ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# 删除账号
|
||||
deleted = db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
|
||||
deleted = (
|
||||
db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
|
||||
)
|
||||
|
||||
db.add(AuditLog(
|
||||
user_id=current.id, username=current.username,
|
||||
action="account:delete", target=f"批量删除{deleted}个账号"
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:delete",
|
||||
target=f"批量删除{deleted}个账号",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
||||
|
||||
@@ -448,19 +547,28 @@ def batch_delete_accounts_selection(
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="请选择账号")
|
||||
|
||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
|
||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuWorkbenchAccount).filter(
|
||||
DouyuWorkbenchAccount.account_id.in_(ids)
|
||||
).delete(synchronize_session=False)
|
||||
deleted = (
|
||||
_visible_accounts_query(db, current)
|
||||
.filter(Account.id.in_(ids))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.add(AuditLog(
|
||||
user_id=current.id, username=current.username,
|
||||
action="account:delete",
|
||||
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="account:delete",
|
||||
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
||||
|
||||
@@ -476,12 +584,24 @@ def delete_account(
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
# 先删除关联的登录任务,避免外键约束失败
|
||||
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False)
|
||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id == account_id).delete(synchronize_session=False)
|
||||
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(DouyuWorkbenchAccount).filter(
|
||||
DouyuWorkbenchAccount.account_id == account_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="account:delete", target=acc.username))
|
||||
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}
|
||||
|
||||
+148
-77
@@ -27,6 +27,7 @@ def _fmt_dt(dt) -> str | None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
||||
cookie_relogin_registry = BatchRegistry()
|
||||
|
||||
@@ -44,7 +45,9 @@ def _parse_account_names(raw_names: str) -> list[str]:
|
||||
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
|
||||
names = []
|
||||
seen = set()
|
||||
for value in (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
for value in (
|
||||
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
):
|
||||
name = value.strip()
|
||||
if name and name not in seen:
|
||||
names.append(name)
|
||||
@@ -60,7 +63,9 @@ def _order_cookie_tasks(query, selected_names: list[str]):
|
||||
value=Account.username,
|
||||
else_=len(selected_names),
|
||||
)
|
||||
return query.order_by(input_order, LoginTask.finished_at.desc(), LoginTask.id.desc())
|
||||
return query.order_by(
|
||||
input_order, LoginTask.finished_at.desc(), LoginTask.id.desc()
|
||||
)
|
||||
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
|
||||
|
||||
|
||||
@@ -88,7 +93,9 @@ def _visible_cookie_operation_tasks_query(db: Session, current: User):
|
||||
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
|
||||
query = db.query(LoginTask).filter(
|
||||
LoginTask.cookie != "",
|
||||
LoginTask.status.in_(("success", "relogin_pending", "relogin_running", "relogin_failed")),
|
||||
LoginTask.status.in_(
|
||||
("success", "relogin_pending", "relogin_running", "relogin_failed")
|
||||
),
|
||||
)
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
||||
@@ -112,7 +119,9 @@ def list_cookies(
|
||||
if not user_has_permission(current, "cookie:view"):
|
||||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||||
if not include_cookie:
|
||||
query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie))
|
||||
query = _visible_cookie_tasks_query(db, current).options(
|
||||
defer(LoginTask.cookie)
|
||||
)
|
||||
else:
|
||||
query = _visible_cookie_tasks_query(db, current).options(
|
||||
joinedload(LoginTask.account).joinedload(Account.assigned_user),
|
||||
@@ -137,15 +146,19 @@ def list_cookies(
|
||||
if not account_joined:
|
||||
query = query.join(Account, LoginTask.account_id == Account.id)
|
||||
account_joined = True
|
||||
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_(
|
||||
Account.username.ilike(pattern),
|
||||
Account.tag.ilike(pattern),
|
||||
User.username.ilike(pattern),
|
||||
))
|
||||
query = query.outerjoin(User, Account.assigned_to == User.id).filter(
|
||||
or_(
|
||||
Account.username.ilike(pattern),
|
||||
Account.tag.ilike(pattern),
|
||||
User.username.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
total = None
|
||||
if page is not None:
|
||||
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
|
||||
total = (
|
||||
query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
|
||||
)
|
||||
query = _order_cookie_tasks(query, selected_names)
|
||||
if page is not None:
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
@@ -175,8 +188,10 @@ def list_cookies(
|
||||
"batch_id": t.batch_id,
|
||||
"account_id": t.account_id,
|
||||
"account_username": acc.username if acc else "",
|
||||
"assigned_to": acc.assigned_to,
|
||||
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
|
||||
"assigned_to": acc.assigned_to if acc else None,
|
||||
"assigned_username": acc.assigned_user.username
|
||||
if acc and acc.assigned_user
|
||||
else None,
|
||||
"created_at": _fmt_dt(t.finished_at),
|
||||
"ck_check_status": t.ck_check_status or "",
|
||||
"ck_check_result": t.ck_check_result,
|
||||
@@ -194,7 +209,12 @@ def list_cookies(
|
||||
item["account_password"] = ""
|
||||
result.append(item)
|
||||
if page is not None:
|
||||
return {"items": result, "total": total or 0, "page": page, "page_size": page_size}
|
||||
return {
|
||||
"items": result,
|
||||
"total": total or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -242,10 +262,12 @@ def list_cookie_operations(
|
||||
if tag_value:
|
||||
query = query.filter(Account.tag == tag_value)
|
||||
if search_text:
|
||||
query = query.filter(or_(
|
||||
Account.username.ilike(f"%{search_text}%"),
|
||||
Account.tag.ilike(f"%{search_text}%"),
|
||||
))
|
||||
query = query.filter(
|
||||
or_(
|
||||
Account.username.ilike(f"%{search_text}%"),
|
||||
Account.tag.ilike(f"%{search_text}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
|
||||
tasks = (
|
||||
@@ -265,7 +287,9 @@ def list_cookie_operations(
|
||||
"created_at": _fmt_dt(task.finished_at),
|
||||
"relogin_status": task.status if task.status != "success" else "",
|
||||
"relogin_message": task.message or "",
|
||||
"relogin_batch_id": task.batch_id if task.status in {"relogin_pending", "relogin_running"} else "",
|
||||
"relogin_batch_id": task.batch_id
|
||||
if task.status in {"relogin_pending", "relogin_running"}
|
||||
else "",
|
||||
}
|
||||
for task in tasks
|
||||
],
|
||||
@@ -292,7 +316,7 @@ def list_cookie_operation_tags(
|
||||
.order_by(Account.tag.asc())
|
||||
.all()
|
||||
)
|
||||
return [tag for tag, in rows if tag]
|
||||
return [tag for (tag,) in rows if tag]
|
||||
|
||||
|
||||
@router.get("/duplicates")
|
||||
@@ -317,7 +341,9 @@ def find_duplicate_cookies(
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
|
||||
rows = query.order_by(Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()).all()
|
||||
rows = query.order_by(
|
||||
Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()
|
||||
).all()
|
||||
grouped: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
username = (row.username or "").strip()
|
||||
@@ -337,26 +363,30 @@ def find_duplicate_cookies(
|
||||
group["account_names"].add(username)
|
||||
group["account_ids"].add(row.account_id)
|
||||
group["cookie_ids"].append(row.cookie_id)
|
||||
group["records"].append({
|
||||
"id": row.cookie_id,
|
||||
"account_id": row.account_id,
|
||||
"batch_id": row.batch_id,
|
||||
"finished_at": _fmt_dt(row.finished_at),
|
||||
})
|
||||
group["records"].append(
|
||||
{
|
||||
"id": row.cookie_id,
|
||||
"account_id": row.account_id,
|
||||
"batch_id": row.batch_id,
|
||||
"finished_at": _fmt_dt(row.finished_at),
|
||||
}
|
||||
)
|
||||
|
||||
duplicate_groups = []
|
||||
for group in grouped.values():
|
||||
if len(group["cookie_ids"]) < 2:
|
||||
continue
|
||||
duplicate_groups.append({
|
||||
"account_key": group["account_key"],
|
||||
"account_names": sorted(group["account_names"]),
|
||||
"cookie_count": len(group["cookie_ids"]),
|
||||
"account_count": len(group["account_ids"]),
|
||||
"cookie_ids": group["cookie_ids"],
|
||||
"account_ids": sorted(group["account_ids"]),
|
||||
"records": group["records"],
|
||||
})
|
||||
duplicate_groups.append(
|
||||
{
|
||||
"account_key": group["account_key"],
|
||||
"account_names": sorted(group["account_names"]),
|
||||
"cookie_count": len(group["cookie_ids"]),
|
||||
"account_count": len(group["account_ids"]),
|
||||
"cookie_ids": group["cookie_ids"],
|
||||
"account_ids": sorted(group["account_ids"]),
|
||||
"records": group["records"],
|
||||
}
|
||||
)
|
||||
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
|
||||
|
||||
return {
|
||||
@@ -432,10 +462,16 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
|
||||
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
|
||||
if not id_list:
|
||||
raise HTTPException(status_code=400, detail="无效的ID")
|
||||
base_query = _visible_cookie_tasks_query(db, current) if detailed else _visible_cookie_operation_tasks_query(db, current)
|
||||
tasks = base_query.filter(LoginTask.id.in_(id_list)).filter(
|
||||
LoginTask.status.in_(("success", "relogin_failed"))
|
||||
).all()
|
||||
base_query = (
|
||||
_visible_cookie_tasks_query(db, current)
|
||||
if detailed
|
||||
else _visible_cookie_operation_tasks_query(db, current)
|
||||
)
|
||||
tasks = (
|
||||
base_query.filter(LoginTask.id.in_(id_list))
|
||||
.filter(LoginTask.status.in_(("success", "relogin_failed")))
|
||||
.all()
|
||||
)
|
||||
if not tasks:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
@@ -447,15 +483,17 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
|
||||
results.append(future.result())
|
||||
except Exception as exc:
|
||||
task = futures[future]
|
||||
results.append({
|
||||
"id": task.id,
|
||||
"valid": False,
|
||||
"message": f"检测异常: {exc}",
|
||||
"fish_ball": None,
|
||||
"nickname": None,
|
||||
"level": None,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": task.id,
|
||||
"valid": False,
|
||||
"message": f"检测异常: {exc}",
|
||||
"fish_ball": None,
|
||||
"nickname": None,
|
||||
"level": None,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
results.sort(key=lambda item: item["id"])
|
||||
|
||||
# 持久化检测结果,刷新/翻页不丢失
|
||||
@@ -472,12 +510,14 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
|
||||
"message": item.get("message", ""),
|
||||
}
|
||||
task.ck_checked_at = datetime.now(timezone.utc)
|
||||
db.add(AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="cookie:check",
|
||||
target=f"检测 {len(results)} 条已分配账号 CK",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="cookie:check",
|
||||
target=f"检测 {len(results)} 条已分配账号 CK",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
if not detailed:
|
||||
results = [
|
||||
@@ -510,7 +550,13 @@ def check_cookie_operations(
|
||||
return _check_cookies(ids, db, current, detailed=False)
|
||||
|
||||
|
||||
def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, action: str = "cookie:relogin"):
|
||||
def _start_relogin_tasks(
|
||||
tasks: list[LoginTask],
|
||||
db: Session,
|
||||
current: User,
|
||||
*,
|
||||
action: str = "cookie:relogin",
|
||||
):
|
||||
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
|
||||
if not tasks:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
@@ -538,7 +584,9 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
|
||||
|
||||
if not task_ids:
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
|
||||
)
|
||||
|
||||
proxy = db.query(ProxyConfigModel).first()
|
||||
thread_db = SessionLocal()
|
||||
@@ -558,13 +606,15 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
|
||||
relogin_task_ids=task_ids,
|
||||
)
|
||||
batch_id = runner.batch_id
|
||||
db.add(AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action=action,
|
||||
target=f"重登 {len(task_ids)} 条账号 CK",
|
||||
detail="旧 Cookie 将在新登录成功后替换",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action=action,
|
||||
target=f"重登 {len(task_ids)} 条账号 CK",
|
||||
detail="旧 Cookie 将在新登录成功后替换",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
|
||||
|
||||
@@ -599,19 +649,28 @@ def stop_cookie_relogin(
|
||||
_require_cookie_operation_perm(current)
|
||||
batch = cookie_relogin_registry.get(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启")
|
||||
if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"):
|
||||
raise HTTPException(
|
||||
status_code=404, detail="重登批次不存在、已结束或服务已重启"
|
||||
)
|
||||
if batch.get("owner_id") != current.id and not user_has_permission(
|
||||
current, "login:view_all"
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="无权限停止该重登批次")
|
||||
|
||||
batch["runner"].stop()
|
||||
db.add(AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="cookie:relogin_stop",
|
||||
target=f"停止 CK 重登批次 {batch_id}",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="cookie:relogin_stop",
|
||||
target=f"停止 CK 重登批次 {batch_id}",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True}
|
||||
return {
|
||||
"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止",
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
|
||||
@@ -667,7 +726,9 @@ def relogin_invalid_cookie_operations(
|
||||
query = query.filter(Account.tag == tag.strip())
|
||||
if search.strip():
|
||||
pattern = f"%{search.strip()}%"
|
||||
query = query.filter(or_(Account.username.ilike(pattern), Account.tag.ilike(pattern)))
|
||||
query = query.filter(
|
||||
or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))
|
||||
)
|
||||
tasks = query.order_by(LoginTask.id.asc()).all()
|
||||
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
|
||||
|
||||
@@ -681,7 +742,9 @@ def get_cookie(
|
||||
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
|
||||
if not user_has_permission(current, "cookie:view"):
|
||||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||||
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
|
||||
task = (
|
||||
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
acc = db.query(Account).filter(Account.id == task.account_id).first()
|
||||
@@ -691,7 +754,9 @@ def get_cookie(
|
||||
"account_id": task.account_id,
|
||||
"account_username": acc.username if acc else "",
|
||||
"assigned_to": acc.assigned_to if acc else None,
|
||||
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
|
||||
"assigned_username": acc.assigned_user.username
|
||||
if acc and acc.assigned_user
|
||||
else None,
|
||||
"created_at": _fmt_dt(task.finished_at),
|
||||
"ck_check_status": task.ck_check_status or "",
|
||||
"ck_check_result": task.ck_check_result,
|
||||
@@ -726,7 +791,11 @@ def delete_cookies_batch(
|
||||
t.status = "failed"
|
||||
t.message = "Cookie已清除"
|
||||
db.commit()
|
||||
return {"message": f"已删除 {len(tasks)} 条", "deleted": len(tasks), "success": True}
|
||||
return {
|
||||
"message": f"已删除 {len(tasks)} 条",
|
||||
"deleted": len(tasks),
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
@@ -736,7 +805,9 @@ def delete_cookie(
|
||||
current: User = Depends(require_permission("cookie:export")),
|
||||
):
|
||||
"""删除一条 Cookie 记录。"""
|
||||
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
|
||||
task = (
|
||||
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
task.cookie = ""
|
||||
|
||||
+494
-238
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ if not SECRET_KEY:
|
||||
stacklevel=2,
|
||||
)
|
||||
SECRET_KEY = secrets.token_urlsafe(32)
|
||||
assert SECRET_KEY is not None
|
||||
SECRET_KEY_TYPED: str = SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
|
||||
|
||||
@@ -35,15 +37,17 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS) -> str:
|
||||
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)
|
||||
return jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@@ -10,9 +10,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from typing import Optional, cast
|
||||
|
||||
from core.douyu import DouyuLogin, WgapiLoginAPI
|
||||
from core.douyu.login import AccountLike
|
||||
from core.douyu.proxy_fetcher import ProxyFetcher
|
||||
|
||||
from ..models import ProxyConfig as ProxyConfigModel
|
||||
@@ -118,13 +119,15 @@ def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
|
||||
f"或 账号|密码|邮箱|邮箱密码"
|
||||
)
|
||||
|
||||
accounts.append(AccountCheckInput(
|
||||
line=line_no,
|
||||
username=parts[0],
|
||||
password=parts[1],
|
||||
email=parts[2],
|
||||
email_password=parts[3],
|
||||
))
|
||||
accounts.append(
|
||||
AccountCheckInput(
|
||||
line=line_no,
|
||||
username=parts[0],
|
||||
password=parts[1],
|
||||
email=parts[2],
|
||||
email_password=parts[3],
|
||||
)
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
@@ -158,9 +161,15 @@ class AccountCheckRunner:
|
||||
wl_platform = "xiequ"
|
||||
wl_credentials = None
|
||||
if self.proxy_config.whitelist_enabled:
|
||||
wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
|
||||
wl_platform = (
|
||||
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
|
||||
)
|
||||
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
|
||||
if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey:
|
||||
if (
|
||||
not wl_credentials
|
||||
and self.proxy_config.whitelist_uid
|
||||
and self.proxy_config.whitelist_ukey
|
||||
):
|
||||
wl_credentials = {
|
||||
"uid": self.proxy_config.whitelist_uid,
|
||||
"ukey": self.proxy_config.whitelist_ukey,
|
||||
@@ -209,25 +218,38 @@ class AccountCheckRunner:
|
||||
|
||||
def _run_one(self, index: int, account: AccountCheckInput):
|
||||
if self._stop.is_set():
|
||||
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
|
||||
self._set_item(
|
||||
index, status="stopped", message="已停止", finished_at=_now()
|
||||
)
|
||||
return
|
||||
|
||||
proxy_dict, proxy_error = self._resolve_static_proxy()
|
||||
if proxy_error:
|
||||
self._set_item(index, status="error", message=proxy_error, finished_at=_now())
|
||||
self._set_item(
|
||||
index, status="error", message=proxy_error, finished_at=_now()
|
||||
)
|
||||
return
|
||||
|
||||
self._set_item(index, status="running", message="检测中", started_at=_now(), finished_at=None)
|
||||
self._set_item(
|
||||
index,
|
||||
status="running",
|
||||
message="检测中",
|
||||
started_at=_now(),
|
||||
finished_at=None,
|
||||
)
|
||||
try:
|
||||
result = DouyuLogin(
|
||||
SimpleNamespace(
|
||||
username=account.username,
|
||||
password=account.password,
|
||||
email=account.email,
|
||||
email_password=account.email_password,
|
||||
email_imap_server="",
|
||||
email_imap_port=993,
|
||||
email_imap_ssl=True,
|
||||
cast(
|
||||
AccountLike,
|
||||
SimpleNamespace(
|
||||
username=account.username,
|
||||
password=account.password,
|
||||
email=account.email,
|
||||
email_password=account.email_password,
|
||||
email_imap_server="",
|
||||
email_imap_port=993,
|
||||
email_imap_ssl=True,
|
||||
),
|
||||
),
|
||||
proxy=proxy_dict,
|
||||
max_login_retries=self.batch.max_login_retries,
|
||||
@@ -237,15 +259,24 @@ class AccountCheckRunner:
|
||||
api_strategy=WgapiLoginAPI(),
|
||||
).check_account()
|
||||
except Exception as exc:
|
||||
self._set_item(index, status="error", message=f"检测异常: {exc}", finished_at=_now())
|
||||
self._set_item(
|
||||
index, status="error", message=f"检测异常: {exc}", finished_at=_now()
|
||||
)
|
||||
return
|
||||
|
||||
if self._stop.is_set() and not result.success:
|
||||
self._set_item(index, status="stopped", message=result.message or "已停止", finished_at=_now())
|
||||
self._set_item(
|
||||
index,
|
||||
status="stopped",
|
||||
message=result.message or "已停止",
|
||||
finished_at=_now(),
|
||||
)
|
||||
return
|
||||
|
||||
if result.success:
|
||||
status = result.code if result.code in STATUS_LABELS else "account_auth_unknown"
|
||||
status = (
|
||||
result.code if result.code in STATUS_LABELS else "account_auth_unknown"
|
||||
)
|
||||
message = result.message or STATUS_LABELS.get(status, "认证状态未知")
|
||||
else:
|
||||
status = "error"
|
||||
@@ -260,7 +291,9 @@ class AccountCheckRunner:
|
||||
status: sum(1 for item in self.batch.items if item.status == status)
|
||||
for status in STATUS_LABELS
|
||||
}
|
||||
running_count = sum(1 for item in self.batch.items if item.status in {"pending", "running"})
|
||||
running_count = sum(
|
||||
1 for item in self.batch.items if item.status in {"pending", "running"}
|
||||
)
|
||||
finished_count = len(self.batch.items) - running_count
|
||||
return {
|
||||
"batch_id": self.batch.batch_id,
|
||||
@@ -296,7 +329,10 @@ class AccountCheckRunner:
|
||||
if item.status != status:
|
||||
continue
|
||||
line = item.export_text
|
||||
if status in {"error", "stopped", "account_auth_unknown"} and item.message:
|
||||
if (
|
||||
status in {"error", "stopped", "account_auth_unknown"}
|
||||
and item.message
|
||||
):
|
||||
line = f"{line}----{item.message}"
|
||||
lines.append(line)
|
||||
content = "\n".join(lines)
|
||||
@@ -309,7 +345,9 @@ class AccountCheckRunner:
|
||||
|
||||
def run(self):
|
||||
"""线程入口。"""
|
||||
self._set_batch(status="running", message="批次运行中", started_at=_now(), finished_at=None)
|
||||
self._set_batch(
|
||||
status="running", message="批次运行中", started_at=_now(), finished_at=None
|
||||
)
|
||||
try:
|
||||
if self._shared_proxy_fetcher:
|
||||
self._shared_proxy_fetcher.warmup_whitelist()
|
||||
@@ -318,14 +356,21 @@ class AccountCheckRunner:
|
||||
futures = []
|
||||
for index, account in enumerate(self.accounts):
|
||||
if self._stop.is_set():
|
||||
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
|
||||
self._set_item(
|
||||
index,
|
||||
status="stopped",
|
||||
message="已停止",
|
||||
finished_at=_now(),
|
||||
)
|
||||
continue
|
||||
futures.append(executor.submit(self._run_one, index, account))
|
||||
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
self._set_batch(status="error", message=f"批次执行异常: {exc}", finished_at=_now())
|
||||
self._set_batch(
|
||||
status="error", message=f"批次执行异常: {exc}", finished_at=_now()
|
||||
)
|
||||
return
|
||||
|
||||
if self._stop.is_set():
|
||||
@@ -370,7 +415,9 @@ class AccountCheckRegistry:
|
||||
for account in accounts
|
||||
],
|
||||
)
|
||||
runner = AccountCheckRunner(batch=batch, accounts=accounts, proxy_config=proxy_config)
|
||||
runner = AccountCheckRunner(
|
||||
batch=batch, accounts=accounts, proxy_config=proxy_config
|
||||
)
|
||||
with self._lock:
|
||||
self._runners[batch_id] = runner
|
||||
return runner
|
||||
|
||||
@@ -42,9 +42,17 @@ def check_douyu_cookie(cookie: str) -> dict:
|
||||
).json()
|
||||
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
|
||||
fish_ok = True
|
||||
fish_ball = (fish_data.get("data") or {}).get("count") if isinstance(fish_data.get("data"), dict) else None
|
||||
fish_ball = (
|
||||
(fish_data.get("data") or {}).get("count")
|
||||
if isinstance(fish_data.get("data"), dict)
|
||||
else None
|
||||
)
|
||||
else:
|
||||
fish_msg = str(fish_data.get("msg") or fish_data.get("error") or "响应异常") if isinstance(fish_data, dict) else "响应异常"
|
||||
fish_msg = (
|
||||
str(fish_data.get("msg") or fish_data.get("error") or "响应异常")
|
||||
if isinstance(fish_data, dict)
|
||||
else "响应异常"
|
||||
)
|
||||
except Exception as exc:
|
||||
fish_msg = f"请求失败: {exc}"
|
||||
|
||||
@@ -66,11 +74,16 @@ def check_douyu_cookie(cookie: str) -> dict:
|
||||
).json()
|
||||
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
|
||||
level_ok = True
|
||||
info = level_data.get("data") if isinstance(level_data.get("data"), dict) else {}
|
||||
info_raw = level_data.get("data")
|
||||
info = info_raw if isinstance(info_raw, dict) else {}
|
||||
nickname = str(info.get("nn") or "") or None
|
||||
level = info.get("lv")
|
||||
else:
|
||||
level_msg = str(level_data.get("msg") or level_data.get("error") or "响应异常") if isinstance(level_data, dict) else "响应异常"
|
||||
level_msg = (
|
||||
str(level_data.get("msg") or level_data.get("error") or "响应异常")
|
||||
if isinstance(level_data, dict)
|
||||
else "响应异常"
|
||||
)
|
||||
except Exception as exc:
|
||||
level_msg = f"请求失败: {exc}"
|
||||
|
||||
@@ -78,10 +91,12 @@ def check_douyu_cookie(cookie: str) -> dict:
|
||||
if valid:
|
||||
message = "有效"
|
||||
else:
|
||||
message = ";".join([
|
||||
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
|
||||
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
|
||||
])
|
||||
message = ";".join(
|
||||
[
|
||||
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
|
||||
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
|
||||
]
|
||||
)
|
||||
return {
|
||||
**base,
|
||||
"valid": valid,
|
||||
|
||||
@@ -21,7 +21,13 @@ from .douyu_runner_xpd import XpdMixin
|
||||
|
||||
|
||||
class DouyuBatchRunner(
|
||||
DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin,
|
||||
DouyuBatchRunnerCore,
|
||||
BindMixin,
|
||||
ManualMixin,
|
||||
GoldMixin,
|
||||
DonateMixin,
|
||||
GoodsMixin,
|
||||
XpdMixin,
|
||||
):
|
||||
"""批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。"""
|
||||
|
||||
@@ -43,13 +49,18 @@ class DouyuBatchRunner(
|
||||
self._started += 1
|
||||
current = self._started
|
||||
|
||||
self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}")
|
||||
self._push_log(
|
||||
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
|
||||
)
|
||||
login_task = latest_success_login_task(worker_db, account.id)
|
||||
cookie = login_task.cookie if login_task else ""
|
||||
if not cookie:
|
||||
self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie")
|
||||
self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie")
|
||||
self._push_log(
|
||||
"warning", f"[{current}] {self._account_name(account)} 无 Cookie"
|
||||
)
|
||||
return
|
||||
assert login_task is not None
|
||||
|
||||
cookie_check = check_douyu_cookie(cookie)
|
||||
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
|
||||
@@ -62,7 +73,9 @@ class DouyuBatchRunner(
|
||||
if not cookie_check["valid"]:
|
||||
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
|
||||
self._mark_task(worker_db, task, "failed", message)
|
||||
self._push_log("warning", f"[{current}] {self._account_name(account)} {message}")
|
||||
self._push_log(
|
||||
"warning", f"[{current}] {self._account_name(account)} {message}"
|
||||
)
|
||||
return
|
||||
|
||||
update_account_profile_from_cookie(account, cookie)
|
||||
@@ -109,7 +122,9 @@ class DouyuBatchRunner(
|
||||
return
|
||||
|
||||
handler(worker_db, task, account, cookie, config)
|
||||
self._push_log("success", f"[{current}] {self._account_name(account)} {task.message}")
|
||||
self._push_log(
|
||||
"success", f"[{current}] {self._account_name(account)} {task.message}"
|
||||
)
|
||||
except DouyuActivityError as exc:
|
||||
if "task" in locals() and task:
|
||||
self._mark_task(worker_db, task, "failed", str(exc))
|
||||
@@ -128,7 +143,9 @@ class DouyuBatchRunner(
|
||||
config = self._config_info(self.db)
|
||||
tasks = (
|
||||
self.db.query(DouyuTask)
|
||||
.filter(DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned")
|
||||
.filter(
|
||||
DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned"
|
||||
)
|
||||
.order_by(DouyuTask.id.asc())
|
||||
.all()
|
||||
)
|
||||
@@ -150,7 +167,9 @@ class DouyuBatchRunner(
|
||||
for task in tasks:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
futures.append(executor.submit(self._execute_one, task.id, config, total))
|
||||
futures.append(
|
||||
executor.submit(self._execute_one, task.id, config, total)
|
||||
)
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
future.result()
|
||||
|
||||
@@ -21,7 +21,12 @@ from ..models import (
|
||||
DouyuXpdGoodsSnapshot,
|
||||
ProxyConfig as ProxyConfigModel,
|
||||
)
|
||||
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload
|
||||
from .douyu_service import (
|
||||
DOUYU_CONFIG_FIELDS,
|
||||
douyu_config_value,
|
||||
ensure_douyu_config,
|
||||
douyu_task_payload,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .douyu_runner import DouyuBatchRunner
|
||||
@@ -78,7 +83,9 @@ class DouyuBatchRunnerCore:
|
||||
self._proxy_fetcher = self._create_proxy_fetcher()
|
||||
self._static_proxies = self._resolve_static_proxies()
|
||||
if self._static_proxies:
|
||||
logger.info(f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}")
|
||||
logger.info(
|
||||
f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}"
|
||||
)
|
||||
elif self._proxy_fetcher:
|
||||
logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理")
|
||||
|
||||
@@ -89,7 +96,11 @@ class DouyuBatchRunnerCore:
|
||||
return None
|
||||
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
|
||||
wl_credentials = getattr(cfg, "whitelist_credentials", None)
|
||||
if not wl_credentials and getattr(cfg, "whitelist_uid", "") and getattr(cfg, "whitelist_ukey", ""):
|
||||
if (
|
||||
not wl_credentials
|
||||
and getattr(cfg, "whitelist_uid", "")
|
||||
and getattr(cfg, "whitelist_ukey", "")
|
||||
):
|
||||
wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
|
||||
return ProxyFetcher(
|
||||
api_url=cfg.api_url,
|
||||
@@ -267,8 +278,7 @@ class DouyuBatchRunnerCore:
|
||||
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
commodity_ids = {
|
||||
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
||||
for raw in goods
|
||||
str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods
|
||||
}
|
||||
commodity_ids.discard("")
|
||||
query = db.query(DouyuXpdGoodsSnapshot)
|
||||
@@ -304,11 +314,15 @@ class DouyuBatchRunnerCore:
|
||||
|
||||
def _config_info(self, db: Session) -> dict:
|
||||
config = ensure_douyu_config(db)
|
||||
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
|
||||
return {
|
||||
field: douyu_config_value(field, getattr(config, field, None))
|
||||
for field in DOUYU_CONFIG_FIELDS
|
||||
}
|
||||
|
||||
def _task_payload(self, task: DouyuTask) -> dict:
|
||||
result = task.result if isinstance(task.result, dict) else {}
|
||||
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
|
||||
payload_raw = result.get("payload")
|
||||
payload = payload_raw if isinstance(payload_raw, dict) else {}
|
||||
return {**payload, **self.payload}
|
||||
|
||||
def _client(self, cookie: str) -> DouyuActivityClient:
|
||||
@@ -338,8 +352,13 @@ class DouyuBatchRegistry:
|
||||
def __init__(self):
|
||||
self._batches: dict[str, dict] = {}
|
||||
|
||||
def register(self, batch_id: str, log_queue: asyncio.Queue,
|
||||
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
|
||||
def register(
|
||||
self,
|
||||
batch_id: str,
|
||||
log_queue: asyncio.Queue,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
runner: DouyuBatchRunner,
|
||||
):
|
||||
self._batches[batch_id] = {
|
||||
"log_queue": log_queue,
|
||||
"loop": loop,
|
||||
@@ -368,4 +387,3 @@ class DouyuBatchRegistry:
|
||||
|
||||
|
||||
douyu_batch_registry = DouyuBatchRegistry()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.douyu.activity_client import DouyuActivityClient
|
||||
@@ -13,7 +14,8 @@ from core.douyu.cookie_utils import cookie_value
|
||||
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
|
||||
|
||||
|
||||
SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
||||
SUPPORTED_DOUYU_TASK_TYPES = {
|
||||
"get_bind_qr": "获取绑定二维码",
|
||||
"confirm_bind": "确认绑定",
|
||||
"create_elite_qr": "开通精英宝典30",
|
||||
"prepare_esports_bind": "绑定电竞手册角色",
|
||||
@@ -53,26 +55,55 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
||||
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
|
||||
DOUYU_HANDBOOK_TASK_TYPES = {
|
||||
"elite": {
|
||||
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
|
||||
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time",
|
||||
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
|
||||
"get_bind_qr",
|
||||
"confirm_bind",
|
||||
"create_elite_qr",
|
||||
"create_gold_qr",
|
||||
"donate_elite_gift",
|
||||
"query_points",
|
||||
"lock_goods",
|
||||
"pay_locked_order",
|
||||
"exchange_goods",
|
||||
"query_game_name",
|
||||
"query_change_bind_time",
|
||||
"query_limited_goods",
|
||||
"query_gold_balance",
|
||||
"refresh_goods",
|
||||
"query_exchange_records",
|
||||
"prefetch_csrf_token",
|
||||
},
|
||||
"esports": {
|
||||
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind",
|
||||
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time",
|
||||
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr",
|
||||
"donate_esports_chicken_gift", "donate_esports_firework_gift",
|
||||
"prepare_esports_bind",
|
||||
"get_esports_bind_qr",
|
||||
"query_esports_game_name",
|
||||
"confirm_esports_bind",
|
||||
"create_esports_qr",
|
||||
"query_esports_points",
|
||||
"query_gold_balance",
|
||||
"query_change_bind_time",
|
||||
"query_limited_goods",
|
||||
"refresh_esports_goods",
|
||||
"exchange_esports_goods",
|
||||
"create_gold_qr",
|
||||
"donate_esports_chicken_gift",
|
||||
"donate_esports_firework_gift",
|
||||
},
|
||||
"peace": {
|
||||
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role",
|
||||
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments",
|
||||
"query_xpd_purchase_records", "exchange_xpd_goods",
|
||||
"get_xpd_bind_qr",
|
||||
"query_xpd_bind_info",
|
||||
"confirm_xpd_bind",
|
||||
"query_xpd_role",
|
||||
"refresh_xpd_goods",
|
||||
"query_xpd_balance",
|
||||
"query_xpd_fragments",
|
||||
"query_xpd_purchase_records",
|
||||
"exchange_xpd_goods",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
|
||||
DOUYU_CONFIG_DEFAULTS = {
|
||||
"manual_id": "G4KA4Qnz4LDp7",
|
||||
"rid": "9263298",
|
||||
"bind_act_alias": "20260120QYOOB",
|
||||
"confirm_act_alias": "20260120QYOOB",
|
||||
@@ -117,12 +148,18 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
|
||||
"""补齐斗鱼配置默认值,返回是否发生变更。"""
|
||||
changed = False
|
||||
for field in DOUYU_CONFIG_FIELDS:
|
||||
if field == "bind_act_alias" and str(getattr(config, field, "") or "").strip() == "20250213NQCYX":
|
||||
if (
|
||||
field == "bind_act_alias"
|
||||
and str(getattr(config, field, "") or "").strip() == "20250213NQCYX"
|
||||
):
|
||||
setattr(config, field, DOUYU_CONFIG_DEFAULTS[field])
|
||||
changed = True
|
||||
continue
|
||||
normalized = douyu_config_value(field, getattr(config, field, None))
|
||||
if field == "gold_recharge_channel" and normalized not in {"wechat_qr", "supplier_api"}:
|
||||
if field == "gold_recharge_channel" and normalized not in {
|
||||
"wechat_qr",
|
||||
"supplier_api",
|
||||
}:
|
||||
normalized = DOUYU_CONFIG_DEFAULTS[field]
|
||||
if getattr(config, field, None) != normalized:
|
||||
setattr(config, field, normalized)
|
||||
@@ -178,7 +215,11 @@ def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Acc
|
||||
"""只保留存在成功 Cookie 的斗鱼账号。"""
|
||||
if not account_ids:
|
||||
return []
|
||||
cookie_ids = cookie_account_ids_query(db).subquery()
|
||||
cookie_ids = select(LoginTask.account_id).where(
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
LoginTask.cookie.isnot(None),
|
||||
)
|
||||
return (
|
||||
db.query(Account)
|
||||
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
|
||||
@@ -217,23 +258,28 @@ def create_douyu_planned_tasks(
|
||||
raise ValueError("该任务不属于当前工作台")
|
||||
|
||||
accounts = visible_douyu_task_accounts(db, account_ids)
|
||||
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts:
|
||||
if (
|
||||
task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"}
|
||||
and accounts
|
||||
):
|
||||
# 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
|
||||
accounts = accounts[:1]
|
||||
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
payload = payload or {}
|
||||
for account in accounts:
|
||||
db.add(DouyuTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
))
|
||||
db.add(
|
||||
DouyuTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return batch_id, len(accounts)
|
||||
|
||||
@@ -272,7 +318,17 @@ def slim_douyu_goods(goods: object) -> object:
|
||||
return {
|
||||
key: value
|
||||
for key, value in goods.items()
|
||||
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"}
|
||||
if key
|
||||
in {
|
||||
"commodityId",
|
||||
"commodity_id",
|
||||
"commodityName",
|
||||
"name",
|
||||
"webPic",
|
||||
"pic",
|
||||
"score",
|
||||
"status",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,11 +340,13 @@ def slim_douyu_limited_goods(goods: object) -> list[dict[str, object]]:
|
||||
for item in goods[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
result.append({
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -305,7 +363,9 @@ def strip_douyu_raw_snapshots(value: object) -> object:
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None:
|
||||
def sanitize_douyu_task_result(
|
||||
result: dict | None, task_type: str, *, include_detail: bool = False
|
||||
) -> dict | None:
|
||||
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
@@ -346,7 +406,12 @@ def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_d
|
||||
if "limited_goods" in data:
|
||||
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
|
||||
|
||||
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}:
|
||||
if task_type not in {
|
||||
"get_bind_qr",
|
||||
"prepare_esports_bind",
|
||||
"get_esports_bind_qr",
|
||||
"get_xpd_bind_qr",
|
||||
}:
|
||||
data.pop("url", None)
|
||||
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
||||
data.pop("pay_url", None)
|
||||
|
||||
@@ -8,12 +8,13 @@ import uuid
|
||||
from types import SimpleNamespace
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import Optional, cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
|
||||
from core.douyu.login import AccountLike
|
||||
from core.douyu.proxy_fetcher import ProxyFetcher
|
||||
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
||||
from .cookie_check_service import check_douyu_cookie
|
||||
@@ -60,21 +61,28 @@ def get_relogin_limits() -> tuple[int, int]:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_proxy_config(proxy_config: Optional[ProxyConfigModel]) -> Optional[SimpleNamespace]:
|
||||
def _snapshot_proxy_config(
|
||||
proxy_config: Optional[ProxyConfigModel],
|
||||
) -> Optional[ProxyConfigModel]:
|
||||
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
|
||||
if proxy_config is None:
|
||||
return None
|
||||
credentials = getattr(proxy_config, "whitelist_credentials", None)
|
||||
return SimpleNamespace(
|
||||
enabled=bool(getattr(proxy_config, "enabled", False)),
|
||||
http=getattr(proxy_config, "http", "") or "",
|
||||
https=getattr(proxy_config, "https", "") or "",
|
||||
api_url=getattr(proxy_config, "api_url", "") or "",
|
||||
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
|
||||
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
|
||||
whitelist_credentials=dict(credentials) if isinstance(credentials, dict) else credentials,
|
||||
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
|
||||
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
|
||||
return cast(
|
||||
ProxyConfigModel,
|
||||
SimpleNamespace(
|
||||
enabled=bool(getattr(proxy_config, "enabled", False)),
|
||||
http=getattr(proxy_config, "http", "") or "",
|
||||
https=getattr(proxy_config, "https", "") or "",
|
||||
api_url=getattr(proxy_config, "api_url", "") or "",
|
||||
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
|
||||
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
|
||||
whitelist_credentials=dict(credentials)
|
||||
if isinstance(credentials, dict)
|
||||
else credentials,
|
||||
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
|
||||
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,12 +132,21 @@ class LoginBatchRunner:
|
||||
wl_platform = "xiequ"
|
||||
wl_credentials = None
|
||||
if proxy_config.whitelist_enabled:
|
||||
wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ"
|
||||
wl_credentials = getattr(proxy_config, 'whitelist_credentials', None)
|
||||
wl_platform = (
|
||||
getattr(proxy_config, "whitelist_platform", None) or "xiequ"
|
||||
)
|
||||
wl_credentials = getattr(proxy_config, "whitelist_credentials", None)
|
||||
# 向后兼容
|
||||
if not wl_credentials and proxy_config.whitelist_uid and proxy_config.whitelist_ukey:
|
||||
if (
|
||||
not wl_credentials
|
||||
and proxy_config.whitelist_uid
|
||||
and proxy_config.whitelist_ukey
|
||||
):
|
||||
wl_platform = "xiequ"
|
||||
wl_credentials = {"uid": proxy_config.whitelist_uid, "ukey": proxy_config.whitelist_ukey}
|
||||
wl_credentials = {
|
||||
"uid": proxy_config.whitelist_uid,
|
||||
"ukey": proxy_config.whitelist_ukey,
|
||||
}
|
||||
|
||||
self._shared_proxy_fetcher = ProxyFetcher(
|
||||
api_url=proxy_config.api_url,
|
||||
@@ -170,7 +187,11 @@ class LoginBatchRunner:
|
||||
def _push_log(self, level: str, message: str):
|
||||
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
|
||||
if message:
|
||||
log_level = level if level in {"debug", "info", "warning", "error", "success"} else "debug"
|
||||
log_level = (
|
||||
level
|
||||
if level in {"debug", "info", "warning", "error", "success"}
|
||||
else "debug"
|
||||
)
|
||||
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
|
||||
if self.log_queue and self.loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
@@ -181,15 +202,15 @@ class LoginBatchRunner:
|
||||
def _resolve_static_proxy(self) -> tuple[Optional[dict], str]:
|
||||
"""解析静态代理配置。"""
|
||||
if not self.proxy_config or not self.proxy_config.enabled:
|
||||
return None, ''
|
||||
return None, ""
|
||||
|
||||
# 静态代理
|
||||
if self.proxy_config.http or self.proxy_config.https:
|
||||
proxy_url = self.proxy_config.http or self.proxy_config.https
|
||||
return {'http': proxy_url, 'https': proxy_url}, f'使用静态代理: {proxy_url}'
|
||||
return {"http": proxy_url, "https": proxy_url}, f"使用静态代理: {proxy_url}"
|
||||
|
||||
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
|
||||
return None, ''
|
||||
return None, ""
|
||||
|
||||
def _execute_one(self, task_id: int, acc_info: dict, total: int):
|
||||
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
|
||||
@@ -216,8 +237,16 @@ class LoginBatchRunner:
|
||||
self._completed += 1
|
||||
current = self._completed
|
||||
|
||||
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
|
||||
self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}")
|
||||
action_name = (
|
||||
"检测"
|
||||
if self.mode == "check"
|
||||
else "重新登录"
|
||||
if self.mode == "relogin"
|
||||
else "登录"
|
||||
)
|
||||
self._push_log(
|
||||
"info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
|
||||
@@ -226,26 +255,39 @@ class LoginBatchRunner:
|
||||
self._push_log("info", f"[{current}] {proxy_msg}")
|
||||
|
||||
# 静态代理启用但配置为空 → 不可用
|
||||
if self.proxy_config and self.proxy_config.enabled and not (self.proxy_config.http or self.proxy_config.https) and not self._shared_proxy_fetcher and not proxy_dict:
|
||||
if (
|
||||
self.proxy_config
|
||||
and self.proxy_config.enabled
|
||||
and not (self.proxy_config.http or self.proxy_config.https)
|
||||
and not self._shared_proxy_fetcher
|
||||
and not proxy_dict
|
||||
):
|
||||
if self.mode == "relogin":
|
||||
task.status = "relogin_failed"
|
||||
task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
|
||||
task.message = (
|
||||
"重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
|
||||
)
|
||||
else:
|
||||
task.status = "error"
|
||||
task.message = "代理不可用: 未配置代理"
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
worker_db.commit()
|
||||
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用")
|
||||
self._push_log(
|
||||
"error", f"[{current}] {acc_info['username']} 代理不可用"
|
||||
)
|
||||
return
|
||||
|
||||
account = SimpleNamespace(
|
||||
username=acc_info["username"],
|
||||
password=acc_info["password"],
|
||||
email=acc_info["email"],
|
||||
email_password=acc_info["email_password"],
|
||||
email_imap_server=acc_info["email_imap_server"] or "",
|
||||
email_imap_port=acc_info["email_imap_port"] or 993,
|
||||
email_imap_ssl=acc_info["email_imap_ssl"],
|
||||
account = cast(
|
||||
AccountLike,
|
||||
SimpleNamespace(
|
||||
username=acc_info["username"],
|
||||
password=acc_info["password"],
|
||||
email=acc_info["email"],
|
||||
email_password=acc_info["email_password"],
|
||||
email_imap_server=acc_info["email_imap_server"] or "",
|
||||
email_imap_port=acc_info["email_imap_port"] or 993,
|
||||
email_imap_ssl=acc_info["email_imap_ssl"],
|
||||
),
|
||||
)
|
||||
|
||||
loginer = DouyuLogin(
|
||||
@@ -257,22 +299,33 @@ class LoginBatchRunner:
|
||||
stop_event=self._stop,
|
||||
api_strategy=self.api_strategy,
|
||||
)
|
||||
result = loginer.check_account() if self.mode == "check" else loginer.login()
|
||||
result = (
|
||||
loginer.check_account() if self.mode == "check" else loginer.login()
|
||||
)
|
||||
|
||||
if self.mode == "check" and result.success:
|
||||
status = result.code if result.code in CHECK_STATUS_MESSAGES else "account_auth_unknown"
|
||||
status = (
|
||||
result.code
|
||||
if result.code in CHECK_STATUS_MESSAGES
|
||||
else "account_auth_unknown"
|
||||
)
|
||||
task.status = status
|
||||
task.cookie = ""
|
||||
task.message = result.message or CHECK_STATUS_MESSAGES[status]
|
||||
level = CHECK_STATUS_LOG_LEVELS.get(status, "info")
|
||||
self._push_log(level, f"[{current}] {acc_info['username']} 检测结果: {task.message}")
|
||||
self._push_log(
|
||||
level,
|
||||
f"[{current}] {acc_info['username']} 检测结果: {task.message}",
|
||||
)
|
||||
elif result.success:
|
||||
task.status = "success"
|
||||
task.cookie = result.cookie
|
||||
task.message = result.message or "登录成功"
|
||||
if self.mode == "relogin":
|
||||
check_result = check_douyu_cookie(result.cookie)
|
||||
task.ck_check_status = "valid" if check_result["valid"] else "invalid"
|
||||
task.ck_check_status = (
|
||||
"valid" if check_result["valid"] else "invalid"
|
||||
)
|
||||
task.ck_check_result = {
|
||||
"fish_ball": check_result["fish_ball"],
|
||||
"nickname": check_result["nickname"],
|
||||
@@ -282,32 +335,54 @@ class LoginBatchRunner:
|
||||
task.ck_checked_at = check_result["checked_at"]
|
||||
if check_result["valid"]:
|
||||
task.message = "重新登录成功,Cookie 有效"
|
||||
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效")
|
||||
self._push_log(
|
||||
"success",
|
||||
f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效",
|
||||
)
|
||||
else:
|
||||
task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
|
||||
self._push_log("warning", f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}")
|
||||
self._push_log(
|
||||
"warning",
|
||||
f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}",
|
||||
)
|
||||
else:
|
||||
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}")
|
||||
self._push_log(
|
||||
"success",
|
||||
f"[{current}] {acc_info['username']} {task.message}",
|
||||
)
|
||||
else:
|
||||
if self.mode == "relogin":
|
||||
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
|
||||
task.status = "relogin_failed"
|
||||
task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)"
|
||||
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}")
|
||||
task.message = (
|
||||
f"重新登录失败: {result.message}(旧 Cookie 已保留)"
|
||||
)
|
||||
self._push_log(
|
||||
"error",
|
||||
f"[{current}] {acc_info['username']} 重新登录失败: {result.message}",
|
||||
)
|
||||
else:
|
||||
task.status = "failed"
|
||||
task.message = result.message
|
||||
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}")
|
||||
self._push_log(
|
||||
"error",
|
||||
f"[{current}] {acc_info['username']} {action_name}失败: {result.message}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.mode == "relogin":
|
||||
task.status = "relogin_failed"
|
||||
task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
|
||||
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}")
|
||||
self._push_log(
|
||||
"error", f"[{current}] {acc_info['username']} 重新登录异常: {e}"
|
||||
)
|
||||
else:
|
||||
task.status = "error"
|
||||
task.message = str(e)
|
||||
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}")
|
||||
self._push_log(
|
||||
"error",
|
||||
f"[{current}] {acc_info['username']} {action_name}异常: {e}",
|
||||
)
|
||||
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
worker_db.commit()
|
||||
@@ -319,8 +394,17 @@ class LoginBatchRunner:
|
||||
"""在线程中执行批量登录。"""
|
||||
batch_id = self.batch_id
|
||||
concurrency = self.concurrency
|
||||
action_name = "账号检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
|
||||
self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}")
|
||||
action_name = (
|
||||
"账号检测"
|
||||
if self.mode == "check"
|
||||
else "重新登录"
|
||||
if self.mode == "relogin"
|
||||
else "登录"
|
||||
)
|
||||
self._push_log(
|
||||
"info",
|
||||
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}",
|
||||
)
|
||||
|
||||
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
|
||||
if self._shared_proxy_fetcher:
|
||||
@@ -339,18 +423,22 @@ class LoginBatchRunner:
|
||||
task.message = ""
|
||||
task.finished_at = None
|
||||
self.db.flush()
|
||||
task_infos.append({
|
||||
"task_id": task.id,
|
||||
"acc_info": {
|
||||
"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,
|
||||
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
|
||||
},
|
||||
})
|
||||
task_infos.append(
|
||||
{
|
||||
"task_id": task.id,
|
||||
"acc_info": {
|
||||
"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,
|
||||
"email_imap_ssl": acc.email_imap_ssl
|
||||
if acc.email_imap_ssl is not None
|
||||
else True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# 创建或复用任务记录(顺序执行,线程安全)
|
||||
@@ -358,10 +446,16 @@ class LoginBatchRunner:
|
||||
if self.relogin_task_ids:
|
||||
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
|
||||
for task_id in self.relogin_task_ids:
|
||||
task = self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
||||
task = (
|
||||
self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
||||
)
|
||||
if not task:
|
||||
continue
|
||||
acc = self.db.query(AccountModel).filter(AccountModel.id == task.account_id).first()
|
||||
acc = (
|
||||
self.db.query(AccountModel)
|
||||
.filter(AccountModel.id == task.account_id)
|
||||
.first()
|
||||
)
|
||||
if not acc:
|
||||
self._push_log("warning", f"跳过无账号的任务 #{task_id}")
|
||||
continue
|
||||
@@ -393,7 +487,9 @@ class LoginBatchRunner:
|
||||
# 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。
|
||||
latest_success_task = (
|
||||
self.db.query(LoginTask)
|
||||
.filter(LoginTask.account_id == aid, LoginTask.status == "success")
|
||||
.filter(
|
||||
LoginTask.account_id == aid, LoginTask.status == "success"
|
||||
)
|
||||
.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
|
||||
.first()
|
||||
)
|
||||
@@ -414,7 +510,10 @@ class LoginBatchRunner:
|
||||
# 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。
|
||||
existing_task = (
|
||||
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())
|
||||
.first()
|
||||
)
|
||||
@@ -479,9 +578,14 @@ class BatchRegistry:
|
||||
def __init__(self):
|
||||
self._batches: dict[str, dict] = {}
|
||||
|
||||
def register(self, batch_id: str, log_queue: Optional[asyncio.Queue],
|
||||
loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner,
|
||||
owner_id: Optional[int] = None):
|
||||
def register(
|
||||
self,
|
||||
batch_id: str,
|
||||
log_queue: Optional[asyncio.Queue],
|
||||
loop: Optional[asyncio.AbstractEventLoop],
|
||||
runner: LoginBatchRunner,
|
||||
owner_id: Optional[int] = None,
|
||||
):
|
||||
self._batches[batch_id] = {
|
||||
"log_queue": log_queue,
|
||||
"loop": loop,
|
||||
|
||||
Reference in New Issue
Block a user