`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。
## 收敛成四值 + 三列一义
`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:
| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |
- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
(动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。
## 回填不猜(设计文档 §5.5)
只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。
## ⚠️ 顺带救回一个差点被我自己丢掉的状态
「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。
## 前端不再从任务日志反推账号状态
工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。
现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。
## 踩到并修掉的东西
- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
干脆改成源码级解析比对(不造数据)。
## 验证
- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)
未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
931 lines
32 KiB
Python
931 lines
32 KiB
Python
"""斗鱼活动任务路由。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import threading
|
||
from datetime import UTC, datetime
|
||
from typing import Any, cast
|
||
|
||
from fastapi import (
|
||
APIRouter,
|
||
Depends,
|
||
HTTPException,
|
||
Query,
|
||
Request,
|
||
WebSocket,
|
||
WebSocketDisconnect,
|
||
)
|
||
from loguru import logger
|
||
from sqlalchemy import case, func, or_, select
|
||
from sqlalchemy.orm import Session, joinedload
|
||
|
||
from core.douyu import (
|
||
FishFinRechargeClient,
|
||
FishFinRechargeConfig,
|
||
FishFinRechargeConfigError,
|
||
)
|
||
|
||
from ..database import SessionLocal, get_db
|
||
from ..deps import authenticate_websocket, get_current_user, require_permission
|
||
from ..models import (
|
||
Account,
|
||
DouyuConfig,
|
||
DouyuEsportsGoodsSnapshot,
|
||
DouyuGoodsSnapshot,
|
||
DouyuTask,
|
||
DouyuWorkbench,
|
||
DouyuWorkbenchAccount,
|
||
DouyuXpdGoodsSnapshot,
|
||
User,
|
||
)
|
||
from ..permissions import user_has_permission
|
||
from ..schemas import (
|
||
DouyuConfigOut,
|
||
DouyuConfigUpdate,
|
||
DouyuGoodsOut,
|
||
DouyuTaskAccountOut,
|
||
DouyuTaskBatchRequest,
|
||
DouyuTaskOut,
|
||
DouyuWorkbenchAccountsUpdate,
|
||
DouyuXpdGoodsOut,
|
||
)
|
||
from ..services.account_binding import bind_payload
|
||
from ..services.audit_service import record_audit
|
||
from ..services.business_events import record_recharge
|
||
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
|
||
from ..services.douyu_service import (
|
||
DOUYU_CONFIG_FIELDS,
|
||
DOUYU_HANDBOOK_TASK_TYPES,
|
||
SUPPORTED_DOUYU_TASK_TYPES,
|
||
apply_douyu_config_defaults,
|
||
cleanup_orphan_douyu_tasks,
|
||
cookie_holding_filter,
|
||
create_douyu_planned_tasks,
|
||
douyu_config_value,
|
||
douyu_task_payload,
|
||
ensure_douyu_config,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/douyu", tags=["斗鱼活动"])
|
||
|
||
|
||
def _supplier_response_value(payload: dict, *keys: str):
|
||
"""兼容供应商把订单字段放在根节点、data 或 result 中。"""
|
||
data_raw = payload.get("data")
|
||
data = cast(dict[str, Any], data_raw) if isinstance(data_raw, dict) else {}
|
||
result_raw = payload.get("result")
|
||
result = cast(dict[str, Any], result_raw) if isinstance(result_raw, dict) else {}
|
||
for source in (payload, data, result):
|
||
for key in keys:
|
||
if source.get(key) is not None:
|
||
return source[key]
|
||
return None
|
||
|
||
|
||
@router.post("/supplier-recharge/callback")
|
||
async def supplier_recharge_callback(request: Request, db: Session = Depends(get_db)):
|
||
"""接收供应商直充终态通知,验签后按 out_order_id 幂等更新任务。"""
|
||
try:
|
||
payload = await request.json()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="供应商回调不是 JSON") from exc
|
||
if not isinstance(payload, dict):
|
||
raise HTTPException(status_code=400, detail="供应商回调格式无效")
|
||
try:
|
||
client = FishFinRechargeClient(FishFinRechargeConfig.from_env())
|
||
except FishFinRechargeConfigError as exc:
|
||
logger.error("[douyu] 供应商直充回调配置无效: {}", exc)
|
||
raise HTTPException(status_code=503, detail="供应商直充回调未配置") from exc
|
||
if not client.verify_response_sign(payload, "POST"):
|
||
logger.warning("[douyu] 供应商直充回调验签失败: keys={}", sorted(payload))
|
||
raise HTTPException(status_code=401, detail="供应商回调签名无效")
|
||
|
||
out_order_id = str(_supplier_response_value(payload, "out_order_id") or "").strip()
|
||
if not out_order_id:
|
||
raise HTTPException(status_code=400, detail="供应商回调缺少 out_order_id")
|
||
task = (
|
||
db.query(DouyuTask)
|
||
.filter(
|
||
DouyuTask.supplier_out_order_id == out_order_id,
|
||
DouyuTask.task_type == "create_gold_qr",
|
||
)
|
||
.first()
|
||
)
|
||
if not task:
|
||
raise HTTPException(status_code=404, detail="供应商回调订单不存在")
|
||
|
||
status = DouyuBatchRunner._to_int(
|
||
_supplier_response_value(payload, "order_status", "orderStatus")
|
||
)
|
||
result = dict(task.result) if isinstance(task.result, dict) else {}
|
||
result.update(
|
||
{
|
||
"out_order_id": out_order_id,
|
||
"order_id": _supplier_response_value(payload, "order_id", "orderId")
|
||
or result.get("order_id"),
|
||
"supplier_order_status": status,
|
||
"supplier_order": DouyuBatchRunner._supplier_result(payload),
|
||
"supplier_callback_received": True,
|
||
}
|
||
)
|
||
# 这笔充值到没到账,用**回调自己说的终态**判断,而不是「任务状态有没有被改」。
|
||
# 否则会漏掉一个真实黑洞:runner 轮询超时先判了 failed(gold.py 末尾),
|
||
# 回调随后才到达成功 —— 守卫会跳过状态更新,这笔真金白银永远不进时间线。
|
||
recharge_confirmed = status == 2
|
||
if task.status not in {"success", "failed", "stopped"}:
|
||
if status == 2:
|
||
task.status = "success"
|
||
task.message = "供应商直充成功(异步通知)"
|
||
task.finished_at = datetime.now(UTC)
|
||
if task.account:
|
||
task.account.updated_at = datetime.now(UTC)
|
||
elif status in {3, 4}:
|
||
reason = str(
|
||
_supplier_response_value(payload, "fail_reason", "message", "msg")
|
||
or "供应商直充失败"
|
||
)
|
||
task.status = "failed"
|
||
task.message = reason[:512]
|
||
task.finished_at = datetime.now(UTC)
|
||
else:
|
||
task.status = "running"
|
||
task.message = (
|
||
f"供应商直充订单处理中(状态 {status if status is not None else '-'})"
|
||
)
|
||
task.result = result
|
||
callback_success = status not in {3, 4}
|
||
# 回调说成功了就补一条充值事件。幂等键与 runner 那条**完全一致**
|
||
# (business_events.task_ref),所以「runner 也观测到了」时不会重复记账;
|
||
# 而「runner 已判超时/失败、回调才到」这个黑洞则能被补上。
|
||
if recharge_confirmed and task.account is not None:
|
||
record_recharge(
|
||
db,
|
||
task.account,
|
||
task_id=task.id,
|
||
amount=_to_int_or_none(result.get("buy_num")),
|
||
order_id=str(result.get("out_order_id") or ""),
|
||
supplier_order_id=str(result.get("order_id") or ""),
|
||
channel="supplier_api",
|
||
douyu_uid=str(result.get("douyu_uid") or ""),
|
||
balance_after=task.account.gold_balance,
|
||
confirmed=False,
|
||
)
|
||
record_audit(
|
||
db,
|
||
None,
|
||
action="recharge:douyu:callback",
|
||
target=f"supplier_order:{out_order_id}",
|
||
detail={
|
||
"out_order_id": out_order_id,
|
||
"task_id": task.id,
|
||
"order_id": result.get("order_id"),
|
||
"supplier_order_status": status,
|
||
"task_status": task.status,
|
||
"message": task.message,
|
||
},
|
||
success=callback_success,
|
||
)
|
||
db.commit()
|
||
logger.info(
|
||
"[douyu] 供应商直充回调已处理: out_order_id={} status={}", out_order_id, status
|
||
)
|
||
acknowledgement = {"code": 200, "message": "success"}
|
||
acknowledgement["sign"] = client.sign(acknowledgement, "POST")
|
||
return acknowledgement
|
||
|
||
|
||
def _can_view_all(user: User) -> bool:
|
||
return user_has_permission(user, "account:view_all")
|
||
|
||
|
||
def _visible_task_accounts_query(db: Session, current: User):
|
||
"""返回当前用户可用于斗鱼任务的账号查询。
|
||
|
||
只要账号还持有 Cookie 就可见:排队中的普通登录(pending/running)和
|
||
重登失败(relogin_failed)都会保留旧 CK,不能让账号从工作台消失。
|
||
"""
|
||
cookie_ids = select(Account.id).where(cookie_holding_filter())
|
||
query = (
|
||
db.query(Account)
|
||
.options(joinedload(Account.assigned_user))
|
||
.filter(Account.id.in_(cookie_ids))
|
||
)
|
||
if _can_view_all(current):
|
||
return query
|
||
if user_has_permission(current, "account:view_assigned"):
|
||
return query.filter(Account.assigned_to == current.id)
|
||
raise HTTPException(status_code=403, detail="无权查看斗鱼账号")
|
||
|
||
|
||
def _visible_tasks_query(db: Session, current: User):
|
||
"""返回当前用户可查看的斗鱼任务查询。"""
|
||
# 任务列表只展示账号识别信息,避免 joinedload 把密码、邮箱等加密大字段带出。
|
||
account_loader = joinedload(DouyuTask.account).load_only(
|
||
Account.id,
|
||
Account.username,
|
||
Account.uid,
|
||
Account.nickname,
|
||
Account.assigned_to,
|
||
)
|
||
query = db.query(DouyuTask).options(account_loader)
|
||
if _can_view_all(current):
|
||
return query
|
||
if user_has_permission(current, "account:view_assigned"):
|
||
# 彻底消除全表 filesort:直接通过 douyu_tasks 上的 account_assigned_to 复合索引倒序定位
|
||
return query.filter(DouyuTask.account_assigned_to == current.id)
|
||
raise HTTPException(status_code=403, detail="无权查看斗鱼任务")
|
||
|
||
|
||
def _require_task_account_access(
|
||
db: Session, current: User, account_ids: list[int]
|
||
) -> None:
|
||
"""确保任务只会提交到当前用户可操作的账号。"""
|
||
requested_ids = set(account_ids)
|
||
query = db.query(Account.id).filter(Account.id.in_(requested_ids))
|
||
if not _can_view_all(current):
|
||
if not user_has_permission(current, "account:view_assigned"):
|
||
raise HTTPException(status_code=403, detail="无权操作斗鱼账号")
|
||
query = query.filter(Account.assigned_to == current.id)
|
||
allowed_ids = {account_id for (account_id,) in query.all()}
|
||
if allowed_ids != requested_ids:
|
||
raise HTTPException(status_code=403, detail="包含无权操作的斗鱼账号")
|
||
|
||
|
||
def _require_batch_owner(db: Session, current: User, batch_id: str) -> None:
|
||
"""客服只能停止或订阅自己创建的任务批次。"""
|
||
if _can_view_all(current):
|
||
return
|
||
exists = (
|
||
db.query(DouyuTask.id)
|
||
.filter(DouyuTask.batch_id == batch_id, DouyuTask.created_by == current.id)
|
||
.first()
|
||
)
|
||
if not exists:
|
||
raise HTTPException(status_code=403, detail="无权操作该斗鱼任务批次")
|
||
|
||
|
||
def _parse_account_names(raw_names: str) -> list[str]:
|
||
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
|
||
names: list[str] = []
|
||
seen: set[str] = set()
|
||
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)
|
||
seen.add(name)
|
||
return names
|
||
|
||
|
||
def _apply_task_account_filters(
|
||
query,
|
||
*,
|
||
search: str = "",
|
||
tag: str = "",
|
||
account_names: str = "",
|
||
ids: str = "",
|
||
):
|
||
"""按工作台导入弹窗的搜索、标签、多行账号名筛选任务账号。"""
|
||
id_list = [int(item) for item in ids.split(",") if item.strip().isdigit()]
|
||
if id_list:
|
||
query = query.filter(Account.id.in_(id_list))
|
||
tag_text = (tag or "").strip()
|
||
if tag_text:
|
||
query = query.filter(Account.tag == tag_text)
|
||
selected_names = _parse_account_names(account_names)
|
||
if selected_names:
|
||
query = query.filter(Account.username.in_(selected_names))
|
||
search_text = (search or "").strip()
|
||
if search_text:
|
||
pattern = f"%{search_text}%"
|
||
query = query.filter(
|
||
or_(
|
||
Account.username.ilike(pattern),
|
||
Account.uid.ilike(pattern),
|
||
Account.nickname.ilike(pattern),
|
||
Account.tag.ilike(pattern),
|
||
Account.game_name.ilike(pattern),
|
||
Account.esports_game_name.ilike(pattern),
|
||
Account.xpd_game_name.ilike(pattern),
|
||
)
|
||
)
|
||
return query, selected_names
|
||
|
||
|
||
def _order_task_accounts(query, selected_names: list[str]):
|
||
"""自定义账号名按粘贴顺序排序,未指定时保持最新账号优先。"""
|
||
if selected_names:
|
||
input_order = case(
|
||
{name: index for index, name in enumerate(selected_names)},
|
||
value=Account.username,
|
||
else_=len(selected_names),
|
||
)
|
||
return query.order_by(input_order, Account.id.desc())
|
||
return query.order_by(Account.id.desc())
|
||
|
||
|
||
def _account_out(account: Account) -> DouyuTaskAccountOut:
|
||
return DouyuTaskAccountOut(
|
||
id=account.id,
|
||
username=account.username,
|
||
uid=account.uid or "",
|
||
nickname=account.nickname or "",
|
||
tag=account.tag or "",
|
||
points=account.points,
|
||
game_name=account.game_name or "",
|
||
game_channel=account.game_channel or "",
|
||
gold_balance=account.gold_balance,
|
||
exchange_balance=account.exchange_balance,
|
||
**bind_payload(account),
|
||
change_role_wait_time=account.change_role_wait_time,
|
||
elite_opened=account.elite_opened,
|
||
elite_expire_time=account.elite_expire_time,
|
||
esports_points=account.esports_points,
|
||
esports_game_name=account.esports_game_name or "",
|
||
esports_game_channel=account.esports_game_channel or "",
|
||
esports_change_role_wait_time=account.esports_change_role_wait_time,
|
||
esports_can_change_time=account.esports_can_change_time,
|
||
xpd_game_name=account.xpd_game_name or "",
|
||
xpd_openid=account.xpd_openid or "",
|
||
xpd_role_id=account.xpd_role_id or "",
|
||
xpd_plat_id=account.xpd_plat_id,
|
||
xpd_area_id=account.xpd_area_id,
|
||
xpd_balance=account.xpd_balance,
|
||
xpd_fragments=account.xpd_fragments,
|
||
assigned_to=account.assigned_to,
|
||
assigned_username=account.assigned_user.username
|
||
if account.assigned_user
|
||
else None,
|
||
)
|
||
|
||
|
||
def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut:
|
||
return DouyuTaskOut(**douyu_task_payload(task, include_detail=include_detail))
|
||
|
||
|
||
def _config_out(config: DouyuConfig) -> DouyuConfigOut:
|
||
text = lambda field: str(douyu_config_value(field, getattr(config, field)))
|
||
number = lambda field: int(douyu_config_value(field, getattr(config, field)))
|
||
return DouyuConfigOut(
|
||
manual_id=text("manual_id"),
|
||
rid=text("rid"),
|
||
bind_act_alias=text("bind_act_alias"),
|
||
confirm_act_alias=text("confirm_act_alias"),
|
||
legacy_act_alias=text("legacy_act_alias"),
|
||
room_id=text("room_id"),
|
||
elite_amount=number("elite_amount"),
|
||
esports_manual_id=text("esports_manual_id"),
|
||
esports_act_alias=text("esports_act_alias"),
|
||
esports_amount=number("esports_amount"),
|
||
esports_chicken_gift_id=text("esports_chicken_gift_id"),
|
||
esports_chicken_skin_id=text("esports_chicken_skin_id"),
|
||
esports_firework_gift_id=text("esports_firework_gift_id"),
|
||
esports_firework_skin_id=text("esports_firework_skin_id"),
|
||
xpd_act_alias=text("xpd_act_alias"),
|
||
xpd_act_id=text("xpd_act_id"),
|
||
xpd_rid=text("xpd_rid"),
|
||
gold_pay_type=number("gold_pay_type"),
|
||
gold_recharge_channel=text("gold_recharge_channel"),
|
||
gold_api_product_id=text("gold_api_product_id"),
|
||
gold_api_account_template_name=text("gold_api_account_template_name"),
|
||
gift_id=text("gift_id"),
|
||
skin_id=text("skin_id"),
|
||
updated_at=config.updated_at,
|
||
)
|
||
|
||
|
||
@router.get("/task-types")
|
||
def task_types(current: User = Depends(require_permission("douyu:task"))):
|
||
"""返回斗鱼任务类型。"""
|
||
return SUPPORTED_DOUYU_TASK_TYPES
|
||
|
||
|
||
@router.get("/accounts")
|
||
def list_task_accounts(
|
||
search: str = Query(""),
|
||
tag: str = Query(""),
|
||
ids: str = Query(""),
|
||
account_names: str = Query(""),
|
||
page: int | None = Query(None, ge=1),
|
||
page_size: int = Query(50, ge=1, le=200),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""查看可执行斗鱼任务的账号(必须有成功 Cookie)。
|
||
|
||
tag 按账号标签精确筛选;ids 为逗号分隔的账号 ID,用于工作台按已导入账号过滤;
|
||
account_names 为多行斗鱼账户名(每行一个),按粘贴顺序精确匹配;为空时返回全部。
|
||
"""
|
||
query, selected_names = _apply_task_account_filters(
|
||
_visible_task_accounts_query(db, current),
|
||
search=search,
|
||
tag=tag,
|
||
account_names=account_names,
|
||
ids=ids,
|
||
)
|
||
total = None
|
||
if page is not None:
|
||
total = query.order_by(None).count()
|
||
query = _order_task_accounts(query, selected_names)
|
||
if page is not None:
|
||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||
else:
|
||
query = query.limit(500)
|
||
result = [_account_out(account) for account in query.all()]
|
||
if page is not None:
|
||
return {
|
||
"items": result,
|
||
"total": total or 0,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
return result
|
||
|
||
|
||
@router.get("/accounts/ids")
|
||
def list_task_account_ids(
|
||
search: str = Query(""),
|
||
tag: str = Query(""),
|
||
account_names: str = Query(""),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""返回当前用户可导入工作台的全部账号 ID,不受列表分页限制。"""
|
||
query, selected_names = _apply_task_account_filters(
|
||
_visible_task_accounts_query(db, current),
|
||
search=search,
|
||
tag=tag,
|
||
account_names=account_names,
|
||
)
|
||
account_ids = [
|
||
account_id
|
||
for (account_id,) in _order_task_accounts(
|
||
query.enable_eagerloads(False).with_entities(Account.id),
|
||
selected_names,
|
||
).all()
|
||
]
|
||
return {"account_ids": account_ids, "total": len(account_ids)}
|
||
|
||
|
||
@router.get("/workbench-accounts")
|
||
def list_workbench_accounts(
|
||
handbook_scope: str = Query(..., pattern="^(elite|esports|peace)$"),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(get_current_user),
|
||
):
|
||
"""返回当前用户在指定工作台启用的账号,供不同浏览器同步。"""
|
||
rows = (
|
||
db.query(DouyuWorkbenchAccount.account_id)
|
||
.join(Account, Account.id == DouyuWorkbenchAccount.account_id)
|
||
.filter(
|
||
DouyuWorkbenchAccount.user_id == current.id,
|
||
DouyuWorkbenchAccount.handbook_scope == handbook_scope,
|
||
)
|
||
)
|
||
if not _can_view_all(current):
|
||
rows = rows.filter(Account.assigned_to == current.id)
|
||
account_ids = [
|
||
account_id
|
||
for (account_id,) in rows.order_by(DouyuWorkbenchAccount.id.asc()).all()
|
||
]
|
||
configured = (
|
||
db.query(DouyuWorkbench.id)
|
||
.filter(
|
||
DouyuWorkbench.user_id == current.id,
|
||
DouyuWorkbench.handbook_scope == handbook_scope,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
return {"account_ids": account_ids, "configured": configured}
|
||
|
||
|
||
@router.put("/workbench-accounts")
|
||
def update_workbench_accounts(
|
||
req: DouyuWorkbenchAccountsUpdate,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""用当前完整账号集合覆盖一个工作台,作为跨浏览器的同步状态。"""
|
||
account_ids = sorted(set(req.account_ids))
|
||
if any(account_id < 1 for account_id in account_ids):
|
||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||
if account_ids:
|
||
_require_task_account_access(db, current, account_ids)
|
||
workbench = (
|
||
db.query(DouyuWorkbench)
|
||
.filter(
|
||
DouyuWorkbench.user_id == current.id,
|
||
DouyuWorkbench.handbook_scope == req.handbook_scope,
|
||
)
|
||
.first()
|
||
)
|
||
if workbench is None:
|
||
db.add(DouyuWorkbench(user_id=current.id, handbook_scope=req.handbook_scope))
|
||
else:
|
||
workbench.updated_at = datetime.now(UTC)
|
||
db.query(DouyuWorkbenchAccount).filter(
|
||
DouyuWorkbenchAccount.user_id == current.id,
|
||
DouyuWorkbenchAccount.handbook_scope == req.handbook_scope,
|
||
).delete(synchronize_session=False)
|
||
db.add_all(
|
||
[
|
||
DouyuWorkbenchAccount(
|
||
user_id=current.id,
|
||
handbook_scope=req.handbook_scope,
|
||
account_id=account_id,
|
||
)
|
||
for account_id in account_ids
|
||
]
|
||
)
|
||
db.commit()
|
||
return {"account_ids": account_ids, "success": True}
|
||
|
||
|
||
@router.get("/config", response_model=DouyuConfigOut)
|
||
def get_config(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:config")),
|
||
):
|
||
"""获取斗鱼活动配置。"""
|
||
return _config_out(ensure_douyu_config(db))
|
||
|
||
|
||
@router.get("/recharge-channel")
|
||
def get_recharge_channel(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""返回当前鱼翅充值渠道,不暴露供应商商品等配置细节。"""
|
||
config = ensure_douyu_config(db)
|
||
return {
|
||
"gold_recharge_channel": douyu_config_value(
|
||
"gold_recharge_channel",
|
||
config.gold_recharge_channel,
|
||
),
|
||
}
|
||
|
||
|
||
@router.put("/config", response_model=DouyuConfigOut)
|
||
def update_config(
|
||
req: DouyuConfigUpdate,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:config")),
|
||
):
|
||
"""更新斗鱼活动配置。"""
|
||
config = ensure_douyu_config(db)
|
||
changed_fields = []
|
||
for field in DOUYU_CONFIG_FIELDS:
|
||
value = getattr(req, field)
|
||
if value is None:
|
||
continue
|
||
setattr(config, field, value.strip() if isinstance(value, str) else value)
|
||
changed_fields.append(field)
|
||
apply_douyu_config_defaults(config)
|
||
config.updated_at = datetime.now(UTC)
|
||
recharge_fields = [field for field in changed_fields if field.startswith("gold_")]
|
||
if recharge_fields:
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="recharge:douyu:config",
|
||
target="douyu_config",
|
||
detail={"changed_fields": recharge_fields},
|
||
)
|
||
db.commit()
|
||
db.refresh(config)
|
||
return _config_out(config)
|
||
|
||
|
||
@router.get("/goods", response_model=list[DouyuGoodsOut])
|
||
def list_goods(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""查看已缓存的斗鱼商品快照。"""
|
||
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
|
||
return rows
|
||
|
||
|
||
@router.get("/esports-goods", response_model=list[DouyuGoodsOut])
|
||
def list_esports_goods(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""查看已缓存的电竞手册皮肤商城商品。"""
|
||
rows = (
|
||
db.query(DouyuEsportsGoodsSnapshot)
|
||
.order_by(DouyuEsportsGoodsSnapshot.id.asc())
|
||
.all()
|
||
)
|
||
return rows
|
||
|
||
|
||
@router.get("/xpd-goods", response_model=list[DouyuXpdGoodsOut])
|
||
def list_xpd_goods(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""查看已缓存的和平小店商品。"""
|
||
rows = (
|
||
db.query(DouyuXpdGoodsSnapshot).order_by(DouyuXpdGoodsSnapshot.id.asc()).all()
|
||
)
|
||
return rows
|
||
|
||
|
||
@router.post("/tasks/batch")
|
||
async def create_task_batch(
|
||
req: DouyuTaskBatchRequest,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""创建斗鱼任务记录并启动后台执行器。"""
|
||
if not req.account_ids:
|
||
raise HTTPException(status_code=400, detail="请选择斗鱼账号")
|
||
_require_task_account_access(db, current, req.account_ids)
|
||
|
||
cleanup_orphan_douyu_tasks(
|
||
db,
|
||
active_batch_ids=douyu_batch_registry.active_ids(),
|
||
statuses=("pending", "running"),
|
||
message="任务已中断(无执行器接管)",
|
||
)
|
||
|
||
try:
|
||
batch_id, count = create_douyu_planned_tasks(
|
||
db,
|
||
req.account_ids,
|
||
req.task_type,
|
||
req.handbook_scope,
|
||
current.id,
|
||
req.payload,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if count == 0:
|
||
raise HTTPException(
|
||
status_code=400, detail="没有可执行的斗鱼账号,请先登录获取 Cookie"
|
||
)
|
||
|
||
if req.task_type == "create_gold_qr":
|
||
config = ensure_douyu_config(db)
|
||
recharge_channel = str(
|
||
douyu_config_value("gold_recharge_channel", config.gold_recharge_channel)
|
||
).strip()
|
||
payment_method = (
|
||
"API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
|
||
)
|
||
recharge_accounts = (
|
||
db.query(DouyuTask, Account)
|
||
.join(Account, Account.id == DouyuTask.account_id)
|
||
.filter(
|
||
DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr"
|
||
)
|
||
.order_by(DouyuTask.id.asc())
|
||
.limit(100)
|
||
.all()
|
||
)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="recharge:douyu:create",
|
||
target=f"douyu_batch:{batch_id}",
|
||
detail={
|
||
"batch_id": batch_id,
|
||
"task_type": req.task_type,
|
||
"count": count,
|
||
"account_count": len(req.account_ids),
|
||
"handbook_scope": req.handbook_scope,
|
||
"recharge_channel": recharge_channel,
|
||
"payment_method": payment_method,
|
||
# 仅记录充值身份,禁止在审计中写入 Cookie、密码等凭据。
|
||
"recharge_accounts": [
|
||
{
|
||
"task_id": task.id,
|
||
"username": account.username,
|
||
"douyu_uid": account.uid or "",
|
||
"douyu_nickname": account.nickname or "",
|
||
}
|
||
for task, account in recharge_accounts
|
||
],
|
||
"recharge_accounts_truncated": count > len(recharge_accounts),
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
log_queue = asyncio.Queue()
|
||
loop = asyncio.get_running_loop()
|
||
thread_db = SessionLocal()
|
||
runner = DouyuBatchRunner(
|
||
db=thread_db,
|
||
batch_id=batch_id,
|
||
task_type=req.task_type,
|
||
payload=req.payload,
|
||
log_queue=log_queue,
|
||
loop=loop,
|
||
concurrency=req.concurrency,
|
||
)
|
||
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
|
||
|
||
thread = threading.Thread(target=runner.run, daemon=True)
|
||
thread.start()
|
||
|
||
return {"batch_id": batch_id, "count": count, "success": True}
|
||
|
||
|
||
@router.get("/tasks")
|
||
def list_tasks(
|
||
batch_id: str | None = None,
|
||
handbook_scope: str | None = Query(None, pattern="^(elite|esports|peace)$"),
|
||
include_detail: bool = Query(
|
||
False, description="是否返回完整任务结果(默认否,轮询请保持 false)"
|
||
),
|
||
page: int | None = Query(None, ge=1),
|
||
page_size: int = Query(100, ge=1, le=500),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""查看斗鱼任务记录,默认只返回最近 100 条。"""
|
||
query = _visible_tasks_query(db, current)
|
||
if batch_id:
|
||
query = query.filter(DouyuTask.batch_id == batch_id)
|
||
if handbook_scope:
|
||
# 旧任务没有归属字段,按历史任务类型继续展示,但绝不会触发自动二维码弹窗。
|
||
query = query.filter(
|
||
or_(
|
||
DouyuTask.handbook_scope == handbook_scope,
|
||
(DouyuTask.handbook_scope == "legacy")
|
||
& DouyuTask.task_type.in_(DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]),
|
||
)
|
||
)
|
||
total = None
|
||
if page is not None:
|
||
total = (
|
||
query.enable_eagerloads(False)
|
||
.order_by(None)
|
||
.with_entities(func.count(DouyuTask.id))
|
||
.scalar()
|
||
or 0
|
||
)
|
||
query = query.order_by(DouyuTask.id.desc())
|
||
if page is not None:
|
||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||
else:
|
||
query = query.limit(page_size)
|
||
result = [_task_out(task, include_detail=include_detail) for task in query.all()]
|
||
if page is not None:
|
||
return {
|
||
"items": result,
|
||
"total": total or 0,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
return result
|
||
|
||
|
||
@router.post("/tasks/cleanup-orphans")
|
||
def cleanup_orphan_tasks(
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""手动清理没有内存执行器接管的斗鱼任务。"""
|
||
cleaned = cleanup_orphan_douyu_tasks(
|
||
db,
|
||
active_batch_ids=douyu_batch_registry.active_ids(),
|
||
statuses=("pending", "running"),
|
||
message="任务已中断(无执行器接管)",
|
||
)
|
||
return {
|
||
"message": f"已清理 {cleaned} 个残留任务",
|
||
"cleaned": cleaned,
|
||
"success": True,
|
||
}
|
||
|
||
|
||
@router.get("/tasks/{task_id}", response_model=DouyuTaskOut)
|
||
def get_task(
|
||
task_id: int,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""获取单条斗鱼任务详情。"""
|
||
task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first()
|
||
if not task:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return _task_out(task, include_detail=True)
|
||
|
||
|
||
@router.post("/stop/{batch_id}")
|
||
def stop_batch(
|
||
batch_id: str,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("douyu:task")),
|
||
):
|
||
"""停止正在运行的斗鱼批次。"""
|
||
_require_batch_owner(db, current, batch_id)
|
||
is_recharge_batch = (
|
||
db.query(DouyuTask.id)
|
||
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
|
||
.first()
|
||
is not None
|
||
)
|
||
batch = douyu_batch_registry.get(batch_id)
|
||
if batch:
|
||
if batch.get("finished"):
|
||
douyu_batch_registry.pop(batch_id)
|
||
cleaned = cleanup_orphan_douyu_tasks(
|
||
db, batch_id=batch_id, message="批次已结束"
|
||
)
|
||
if cleaned:
|
||
return {
|
||
"message": f"批次已结束,已清理 {cleaned} 个残留任务",
|
||
"success": True,
|
||
}
|
||
raise HTTPException(status_code=404, detail="批次已结束")
|
||
batch["runner"].stop()
|
||
if is_recharge_batch:
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="recharge:douyu:stop",
|
||
target=f"douyu_batch:{batch_id}",
|
||
detail={"batch_id": batch_id, "mode": "running"},
|
||
)
|
||
db.commit()
|
||
return {"message": "已发送停止信号", "success": True}
|
||
|
||
cleaned = cleanup_orphan_douyu_tasks(
|
||
db, batch_id=batch_id, message="任务已停止(批次不存在)"
|
||
)
|
||
if cleaned:
|
||
if is_recharge_batch:
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="recharge:douyu:stop",
|
||
target=f"douyu_batch:{batch_id}",
|
||
detail={
|
||
"batch_id": batch_id,
|
||
"mode": "orphan_cleanup",
|
||
"cleaned": cleaned,
|
||
},
|
||
)
|
||
db.commit()
|
||
return {"message": f"已清理 {cleaned} 个残留任务", "success": True}
|
||
raise HTTPException(status_code=404, detail="批次不存在或已结束")
|
||
|
||
|
||
@router.websocket("/ws/{batch_id}")
|
||
async def ws_douyu_logs(websocket: WebSocket, batch_id: str):
|
||
"""斗鱼实时日志推送通道。"""
|
||
user = authenticate_websocket(websocket)
|
||
if not user:
|
||
await websocket.close(code=1008, reason="未授权")
|
||
return
|
||
if not user_has_permission(user, "douyu:task"):
|
||
await websocket.close(code=1008, reason="无权限")
|
||
return
|
||
db = SessionLocal()
|
||
try:
|
||
_require_batch_owner(db, user, batch_id)
|
||
except HTTPException:
|
||
await websocket.close(code=1008, reason="无权访问该任务批次")
|
||
return
|
||
finally:
|
||
db.close()
|
||
await websocket.accept()
|
||
|
||
batch = douyu_batch_registry.get(batch_id)
|
||
if not batch:
|
||
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
|
||
await websocket.close()
|
||
return
|
||
|
||
log_queue: asyncio.Queue = batch["log_queue"]
|
||
try:
|
||
while True:
|
||
try:
|
||
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
|
||
await websocket.send_json(msg)
|
||
if msg.get("level") == "result":
|
||
await asyncio.sleep(0.1)
|
||
break
|
||
except TimeoutError:
|
||
await websocket.send_json({"level": "heartbeat", "message": ""})
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
latest = douyu_batch_registry.get(batch_id)
|
||
if latest and latest.get("finished"):
|
||
douyu_batch_registry.pop(batch_id)
|
||
|
||
|
||
def _to_int_or_none(value: object) -> int | None:
|
||
"""回调里的 `buy_num` 可能是字符串/Decimal,安全转 int。"""
|
||
try:
|
||
return int(float(str(value)))
|
||
except (TypeError, ValueError):
|
||
return None
|