Files
live-hub-py/web/backend/services/douyu_runner_donate.py
T
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
2026-10-07 12:29:08 +08:00

432 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼任务执行器:送礼(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuTask
from .business_events import record_gold_spent, record_points_credit, task_actor
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
class DonateMixin:
"""送礼域:精英令/电竞任务礼物赠送与积分确认。"""
if TYPE_CHECKING:
_stop: Any
def _client(self, cookie: str) -> DouyuActivityClient: ...
def _task_payload(self, task: DouyuTask) -> dict: ...
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
def _refresh_account_points(
self,
client: DouyuActivityClient,
account: Account,
cookie: str,
*,
ctn: str | None = None,
) -> dict: ...
def _refresh_account_gold_balance(
self, client: DouyuActivityClient, account: Account
) -> dict: ...
def _refresh_esports_handbook(
self, client: DouyuActivityClient, account: Account, *, manual_id: str
) -> dict: ...
def _refresh_points_after_elite_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
client: DouyuActivityClient,
cookie: str,
ctn: str | None,
result: dict,
baseline_points: int | None,
gift_count: int,
) -> dict:
"""赠送精英令后短轮询积分;1 个精英令约等于 10 积分。"""
expected_gain = max(0, gift_count) * 10
target_points = (
baseline_points + expected_gain if baseline_points is not None else None
)
result["gift_points_baseline"] = baseline_points
result["gift_points_expected_gain"] = expected_gain
result["gift_points_target"] = target_points
last_points = None
refresh_result: dict = {}
for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1):
refresh_result = self._refresh_account_points(
client, account, cookie, ctn=ctn
)
db.commit()
last_points = refresh_result["points"]
result.update(refresh_result)
result["gift_points_refresh_count"] = index
if target_points is None or (
last_points is not None and last_points >= target_points
):
result["gift_points_confirmed"] = (
target_points is None or last_points is not None
)
return refresh_result
if index < DOUYU_GIFT_POINTS_REFRESH_TIMES:
self._update_task_progress(
db,
task,
"running",
f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points})",
result,
)
if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL):
break
result["gift_points_confirmed"] = False
result["points"] = last_points
return refresh_result
def _execute_donate_esports_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
*,
gift_name: str,
config_gift_id_key: str,
config_skin_id_key: str,
):
"""赠送电竞手册任务礼物并刷新独立积分。"""
payload = self._task_payload(task)
try:
gift_count = max(
1, int(payload.get("gift_count") or payload.get("count") or 1)
)
except (TypeError, ValueError):
self._mark_task(db, task, "failed", "赠送数量必须是正整数")
return
manual_id = str(config.get("esports_manual_id") or "").strip()
gift_id = str(
payload.get("gift_id") or config.get(config_gift_id_key) or ""
).strip()
skin_id = str(
payload.get("skin_id") or config.get(config_skin_id_key) or ""
).strip()
room_id = str(payload.get("room_id") or config.get("room_id") or "").strip()
if not manual_id or not gift_id or not skin_id or not room_id:
self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数")
return
client = self._client(cookie)
baseline_points = account.esports_points
try:
baseline = self._refresh_esports_handbook(
client, account, manual_id=manual_id
)
baseline_points = baseline["esports_points"]
db.commit()
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}")
baseline_gold = account.gold_balance
try:
gold_before = self._refresh_account_gold_balance(client, account)
db.commit()
baseline_gold = gold_before["gold_balance"]
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送{gift_name}前刷新鱼翅失败: {exc}")
result = client.donate_esports_gift(
gift_name=gift_name,
gift_count=gift_count,
room_id=room_id,
gift_id=gift_id,
skin_id=skin_id,
)
result.update(
{
"gift_name": gift_name,
"gift_id": gift_id,
"skin_id": skin_id,
"gift_count": gift_count,
"esports_points_baseline": baseline_points,
}
)
refresh_errors = []
try:
result.update(self._refresh_account_gold_balance(client, account))
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"鱼翅余额: {exc}")
try:
points_result = self._refresh_esports_handbook(
client, account, manual_id=manual_id
)
result.update(points_result)
result["esports_points_after_gift"] = points_result["esports_points"]
result["esports_points_changed"] = (
baseline_points is not None
and points_result["esports_points"] is not None
and points_result["esports_points"] != baseline_points
)
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"电竞积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.updated_at = datetime.now(UTC)
confirmed = bool(result.get("esports_points_changed"))
message = f"赠送{gift_name}成功: {gift_count}"
if account.gold_balance is not None:
message += f",鱼翅余额: {account.gold_balance}"
if account.esports_points is not None:
message += f",电竞积分: {account.esports_points}"
# 电竞礼物同样产生积分到账 —— 这一类此前**从来不在收集范围**
# (不在 COLLECTED_TASK_TYPES 里),时间线上一直是空白。
record_points_credit(
db,
account,
task_id=task.id,
**task_actor(task),
gain=_esports_gift_points(result, baseline_points),
points_after=account.esports_points,
confirmed=confirmed,
source_label=gift_name,
)
record_gold_spent(
db,
account,
task_id=task.id,
**task_actor(task),
amount=_gold_spent(baseline_gold, account.gold_balance),
reason=f"赠送{gift_name} x{gift_count}",
source_label=f"赠送{gift_name}",
balance_after=account.gold_balance,
)
self._mark_task(db, task, "success", message, result)
def _execute_donate_esports_chicken_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""赠送冠军鸡腿。"""
self._execute_donate_esports_gift(
db,
task,
account,
cookie,
config,
gift_name="冠军鸡腿",
config_gift_id_key="esports_chicken_gift_id",
config_skin_id_key="esports_chicken_skin_id",
)
def _execute_donate_esports_firework_gift(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
"""赠送冠军烟花。"""
self._execute_donate_esports_gift(
db,
task,
account,
cookie,
config,
gift_name="冠军烟花",
config_gift_id_key="esports_firework_gift_id",
config_skin_id_key="esports_firework_skin_id",
)
def _execute_donate_elite_gift(
self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict
):
payload = self._task_payload(task)
gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
client = self._client(cookie)
ctn = None
baseline_points = account.points
try:
ctn = client.acf_ccn(refresh_subscribe=False)
baseline_result = self._refresh_account_points(
client, account, cookie, ctn=ctn
)
db.commit()
baseline_points = baseline_result["points"]
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}")
# 鱼翅基线:送礼会**消耗鱼翅**,要记出项就必须有送礼前的余额。
# 直接读 account.gold_balance 是上次刷新的旧值,算出来的差会偏。
baseline_gold = account.gold_balance
try:
gold_before = self._refresh_account_gold_balance(client, account)
db.commit()
baseline_gold = gold_before["gold_balance"]
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._push_log("warning", f"赠送精英令前刷新鱼翅失败: {exc}")
result = client.donate_elite_gift(
gift_count=gift_count,
room_id=str(payload.get("room_id") or config["room_id"]),
gift_id=str(payload.get("gift_id") or config["gift_id"]),
skin_id=str(payload.get("skin_id") or config["skin_id"]),
)
result["gift_points_baseline"] = baseline_points
refresh_errors = []
try:
result.update(self._refresh_account_gold_balance(client, account))
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"鱼翅余额: {exc}")
try:
result.update(
self._refresh_points_after_elite_gift(
db,
task,
account,
client,
cookie,
ctn,
result,
baseline_points,
gift_count,
)
)
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
refresh_errors.append(f"积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
account.updated_at = datetime.now(UTC)
message = f"赠送精英令成功: {gift_count}"
if account.gold_balance is not None:
message += f",鱼翅余额: {account.gold_balance}"
confirmed = bool(result.get("gift_points_confirmed"))
# 顺手记下积分到期时刻:斗鱼只在这次查询里给,不记下来以后就没机会了。
# 到期是资产减少,但斗鱼不会通知 —— 由作业在到期后补写事件。
expire_at = _points_expire_at(result)
expire_dt = _parse_expire_at(expire_at)
if expire_dt is not None:
account.points_expire_at = expire_dt
if account.points is not None:
message += f",积分: {account.points}"
if result.get("gift_points_target") is not None and not confirmed:
message += f"(未确认涨到 {result['gift_points_target']})"
# 积分到账是业务事实。**未确认也记,但把 confirmed 明确写进 detail** ——
# 旧收集器把「预期涨幅」当既成事实写进台账(未确认时也记 +980),
# 那是把预期记成到账。这里宁可标清楚,也不假装它是确认过的数字。
# 金额用本地可等价重算的值(1 个精英令 ≈ 10 分),不再依赖 result 里的字段。
record_points_credit(
db,
account,
task_id=task.id,
**task_actor(task),
gain=max(0, gift_count) * 10,
points_after=account.points,
expire_at=expire_at,
confirmed=confirmed,
source_label="精英令",
)
# 鱼翅出项:以前只记「积分到账」,花掉的鱼翅没有任何记录 ——
# 充值有账、消耗没账,对不上。金额取实测余额差。
record_gold_spent(
db,
account,
task_id=task.id,
**task_actor(task),
amount=_gold_spent(baseline_gold, account.gold_balance),
reason=f"赠送精英令 x{gift_count}",
source_label="赠送精英令",
balance_after=account.gold_balance,
)
self._mark_task(db, task, "success", message, result)
def _points_expire_at(result: dict) -> object:
"""从积分查询结果里取出到期时间(同样是为了给时间线留全信息)。
旧收集器走的是 `result["points_query"]["raw"]["data"]["willExpireTime"]` 这条
「猜出来」的路径;这里收敛成一个具名函数,只有它知道那段结构。
"""
query = result.get("points_query")
if not isinstance(query, dict):
return None
raw = query.get("raw")
data = raw.get("data") if isinstance(raw, dict) else None
return data.get("willExpireTime") if isinstance(data, dict) else None
def _esports_gift_points(result: dict, baseline_points: int | None) -> int | None:
"""电竞礼物带来的积分增量。
以**实际观测到的积分变化**为准(`esports_points_after_gift` - 基线),
而不是像旧收集器那样去读一个「预期涨幅」。观测不到就返回 None ——
金额宁可空着,也不要写一个猜出来的数字进台账。
"""
after = result.get("esports_points_after_gift")
if not isinstance(after, (int, float)) or not isinstance(
baseline_points, (int, float)
):
return None
delta = int(after) - int(baseline_points)
return delta if delta > 0 else None
def _gold_spent(before: int | None, after: int | None) -> int | None:
"""送礼前后鱼翅余额的差(正数 = 花掉的)。
用**实测余额差**而不是礼物单价:单价会变、活动会打折,只有观测到的差是真的。
任一侧拿不到、或余额没减少(甚至变多,例如同时另一笔充值到账)→ 返回 None,
事件就不写金额 —— 宁可少记,不要编一个数进账。
"""
if not isinstance(before, (int, float)) or not isinstance(after, (int, float)):
return None
delta = int(before) - int(after)
return delta if delta > 0 else None
def _parse_expire_at(value: object) -> datetime | None:
"""把斗鱼给的到期时间解析成 datetime。
它可能是秒级时间戳(int/str),也可能是 `YYYY-MM-DD HH:MM:SS` 字符串 ——
两种都见过,所以都兜住;认不出来就返回 None(宁可不记,也不要猜一个时刻)。
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
seconds = float(value)
else:
text = str(value).strip()
if not text:
return None
if text.isdigit():
seconds = float(text)
else:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=UTC)
except ValueError:
continue
return None
# 毫秒时间戳也兜一下
if seconds > 10_000_000_000:
seconds /= 1000
try:
return datetime.fromtimestamp(seconds, tz=UTC)
except (OverflowError, OSError, ValueError):
return None