审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** —— 充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记, 于是「一共充了多少」算得出来,「花哪儿了」算不出来。 按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。 ## A. 开通类事件补金额 `elite_opened` / `esports_opened` 以前只记「开通后的积分余额」,不记**花了多少**。 现在补 `amount = -(开通前后积分差)`,并用**实测余额差**而不是"开通要多少分"的常量 (活动价、折扣会让实际扣减不同)。任一侧拿不到就不写金额,不编数字。 ## B. 鱼翅消耗事件(新 `gold_spent`) 送礼以前只发「积分到账」,**花掉的鱼翅没有任何记录**。现在: - 送礼**前后各刷一次**鱼翅(原来只在送礼后刷),取**实测余额差**; - 发 `gold_spent`,`amount` 用负数(与 `exchange` 一致); - 余额没减少或拿不到 → **不发**:宁可少一条,也不要记一笔没发生的消耗。 ## C. 积分到期(新 `points_expire`) 积分到期是资产真实减少,但**斗鱼不会通知** —— 只在积分查询里给 `willExpireTime`。 所以: - 新增 `accounts.points_expire_at`(迁移 `0046`),送礼流程学到就写下来; - 新增作业 `points_expire_check`:扫「已到期且积分未归零」的账号,补写事件并清标记 (不重复记);积分已为 0 则只清标记、不发事件(避免 `-0` 噪声)。 ⚠️ **金额的诚实边界**:到期那一刻**到底扣了多少,我们没亲眼看到**。 所以默认 `confirmed=False`、标题带「(预计)」,记的是「当时余额」这个**预计要归零**的数量, **不假装它是确认过的扣减**。作业刻意不发网络请求(纯 DB 可跑、不需要 CK), 要变成已确认只能由「到期后重查、真看到余额下降」的调用方补。 ## 顺带修掉一个会静默丢事件的坑 同一个任务可能产生**两条事件**(送礼:既「积分到账」又「消耗鱼翅」)。 `source_ref` 是唯一键 —— 共用 `task_ref(task_id)` 会让**后写的那条被静默丢弃**。 已让副事件带后缀(`douyu_task:{id}:gold`),主事件保持原形态(与旧收集器一致)。 有用例专门钉住「一个任务两条事件都不丢」。 ## 验证 - `TZ=Asia/Shanghai`:**550 passed / 6 skipped**(+18 条) - `TZ=UTC`(事件 + 迁移 + 绑定)95 passed;ruff 全绿;前端 build 通过、lint 0 errors - 真实开发库:迁移 `0046` 已应用;作业烟测 → 扫到 1 个到期账号, 写入「积分到期 -500(预计)」`confirmed=False`,标记被清、重跑不再重复 ## 现在的资产流水(斗鱼侧) | 方向 | 覆盖 | | --- | --- | | 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ | | 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** | | 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
428 lines
17 KiB
Python
428 lines
17 KiB
Python
"""斗鱼任务执行器:送礼(由 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
|
||
|
||
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,
|
||
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,
|
||
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,
|
||
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,
|
||
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
|