type: 收敛测试 schemas 与协议层类型

This commit is contained in:
yml2213
2026-08-30 20:35:08 +08:00
parent 92dc461e52
commit c891ac982e
26 changed files with 1846 additions and 882 deletions
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 = ""
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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
+77 -30
View File
@@ -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
+23 -8
View File
@@ -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,
+26 -7
View File
@@ -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()
+28 -10
View File
@@ -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()
+99 -34
View File
@@ -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)
+170 -66
View File
@@ -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,