diff --git a/tests/test_business_events.py b/tests/test_business_events.py index 9f35c69..dc31564 100644 --- a/tests/test_business_events.py +++ b/tests/test_business_events.py @@ -12,6 +12,8 @@ from datetime import UTC, datetime +import pytest + from tests.support.db import get_test_engine, make_test_session from web.backend.models import Account, AccountEvent, DouyuTask, User from web.backend.services import account_events_service as svc @@ -343,3 +345,179 @@ class TestTaskReadyForApi(_Base): self.session.add(task) self.session.commit() assert task.id is not None + + +class TestAssetOutflow(_Base): + """资产**出项**也要有金额 —— 否则进项有账、出项没账,对不了账。 + + 这是审「哪些操作需要进账号生命周期」时发现的:充值/积分到账都有金额, + 而开通(花积分)、送礼(花鱼翅)几乎没有 —— 能算出「一共充了多少」, + 算不出「花哪儿了」。 + """ + + def test_gold_spent_is_negative(self): + assert ( + biz.record_gold_spent( + self.session, + self.account, + task_id=701, + amount=100, + reason="赠送精英令 x1", + source_label="赠送精英令", + balance_after=50, + ) + is True + ) + self.session.commit() + event = self.events(svc.EVENT_GOLD_SPENT)[0] + assert event.amount == -100 # 出项用负数,和 exchange 一致 + assert event.balance_after == 50 + assert event.category == svc.CATEGORY_BUSINESS + assert event.source_ref == "douyu_task:701:gold" + + def test_gold_spent_skips_non_positive(self): + """余额没减少(或拿不到)就不发 —— 不要记一笔没发生的消耗。""" + for bad in (None, 0, -5): + assert ( + biz.record_gold_spent( + self.session, self.account, task_id=702, amount=bad + ) + is False + ) + self.session.commit() + assert self.events(svc.EVENT_GOLD_SPENT) == [] + + def test_points_expire_defaults_to_unconfirmed(self): + """到期金额默认是**预计**的 —— 我们并没有亲眼看到扣减。""" + biz.record_points_expire( + self.session, + self.account, + amount=500, + expires_at=NOW, + ) + self.session.commit() + event = self.events(svc.EVENT_POINTS_EXPIRE)[0] + assert event.amount == -500 + assert event.detail["confirmed"] is False + assert "预计" in event.title + + def test_points_expire_can_be_confirmed(self): + biz.record_points_expire(self.session, self.account, amount=500, confirmed=True) + self.session.commit() + event = self.events(svc.EVENT_POINTS_EXPIRE)[0] + assert event.detail["confirmed"] is True + assert "预计" not in event.title + + def test_two_events_from_one_task_do_not_collide(self): + """同一个任务既要「积分到账」又要「消耗鱼翅」—— + + 共用 `task_ref` 会撞唯一键、后写的那条被**静默丢弃**。副事件带后缀避免它。 + """ + biz.record_points_credit( + self.session, self.account, task_id=703, gain=10, source_label="精英令" + ) + biz.record_gold_spent( + self.session, + self.account, + task_id=703, + amount=100, + source_label="赠送精英令", + ) + self.session.commit() + assert len(self.events(svc.EVENT_POINTS_CREDIT)) == 1 + assert len(self.events(svc.EVENT_GOLD_SPENT)) == 1, ( + "副事件被主事件的幂等键挤掉了" + ) + + +class TestPointsExpireJob(_Base): + """到期补记作业:扫「已到期」的账号,写一条事件并清标记。""" + + def _job(self): + from web.backend.models import AutomationJob + + return AutomationJob( + key="points_expire_check", + title="积分到期补记", + interval_seconds=86400, + batch_size=200, + concurrency=1, + ) + + def _run(self): + from web.backend.services.automation.jobs import points_expire + + out = points_expire.run_points_expire_check(self.session, self._job()) + self.session.commit() + return out + + def test_expired_points_are_recorded_once(self): + from datetime import timedelta + + self.account.points = 500 + self.account.points_expire_at = NOW - timedelta(days=1) + self.session.commit() + + out = self._run() + assert out["written"] == 1 + events = self.events(svc.EVENT_POINTS_EXPIRE) + assert len(events) == 1 + assert events[0].amount == -500 + assert events[0].detail["confirmed"] is False + # 标记被清掉 → 再跑一次不会重复记 + self.session.refresh(self.account) + assert self.account.points_expire_at is None + assert self._run()["written"] == 0 + + def test_not_yet_expired_is_left_alone(self): + from datetime import timedelta + + self.account.points = 500 + self.account.points_expire_at = NOW + timedelta(days=3) + self.session.commit() + + assert self._run()["written"] == 0 + self.session.refresh(self.account) + assert self.account.points_expire_at is not None, "没到期的标记不该被清" + + def test_zero_points_clears_marker_without_event(self): + """积分已经为 0 就没有东西可到期 —— 写一条 -0 只是噪声。""" + from datetime import timedelta + + self.account.points = 0 + self.account.points_expire_at = NOW - timedelta(days=1) + self.session.commit() + + out = self._run() + assert out["written"] == 0 + assert out["skipped_zero"] == 1 + assert self.events(svc.EVENT_POINTS_EXPIRE) == [] + self.session.refresh(self.account) + assert self.account.points_expire_at is None + + +class TestExpireAtParsing: + """斗鱼给的到期时间形态不定(时间戳或字符串),两种都要兜住。""" + + @pytest.mark.parametrize( + "value", + [ + 1791115200, # 秒级时间戳 + "1791115200", # 字符串时间戳 + 1791115200000, # 毫秒时间戳 + "2026-10-04 12:00:00", # 常见字符串 + "2026-10-04T12:00:00", + ], + ) + def test_parses_known_shapes(self, value): + from web.backend.services.douyu_runner_donate import _parse_expire_at + + parsed = _parse_expire_at(value) + assert parsed is not None + assert parsed.tzinfo is not None, "必须是 aware UTC,否则入库会偏" + + @pytest.mark.parametrize("value", [None, "", "不是什么时间", True, {}]) + def test_unknown_shapes_return_none(self, value): + from web.backend.services.douyu_runner_donate import _parse_expire_at + + assert _parse_expire_at(value) is None, "认不出来就返回 None,不要猜一个时刻" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 90cd49b..21575b8 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261004_0045" +HEAD_REVISION = "20261004_0046" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261004_0046_points_expire_at.py b/web/backend/migrations/versions/20261004_0046_points_expire_at.py new file mode 100644 index 0000000..a000ad3 --- /dev/null +++ b/web/backend/migrations/versions/20261004_0046_points_expire_at.py @@ -0,0 +1,39 @@ +"""补记「积分到期」:新增 `accounts.points_expire_at` + +积分到期是账号资产的真实减少,但**斗鱼不会通知** —— 只在积分查询里给一个 +`willExpireTime`(而且只有送礼后的那次查询才有)。不把这个时刻记下来, +这个减少在时间线上就是永久空白。 + +所以:送礼流程顺手把它写进 `accounts.points_expire_at`, +再由作业 `points_expire_check` 在到期后补写一条 `points_expire` 事件。 + +列为可空:绝大多数账号从没查过到期时间。 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261004_0046" +down_revision: str | None = "20261004_0045" +branch_labels: str | None = None +depends_on: str | None = None + +COLUMN = "points_expire_at" + + +def upgrade() -> None: + op.add_column( + "accounts", + sa.Column( + COLUMN, + sa.DateTime(), + nullable=True, + comment="积分到期时刻(来源:积分查询的 willExpireTime)", + ), + ) + + +def downgrade() -> None: + op.drop_column("accounts", COLUMN) diff --git a/web/backend/models.py b/web/backend/models.py index 0874b53..6b3107e 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -104,6 +104,12 @@ class Account(Base): role_bind_status: Mapped[str] = mapped_column( String(16), default="unknown", server_default="unknown" ) + # 积分**到期时刻**(斗鱼在积分查询里给 `willExpireTime`,只有送礼后的那次查询才有)。 + # 积分到期是账号资产的真实减少,但斗鱼不会「通知」你 —— 所以这里记下到期时刻, + # 由作业在到期后补记一条 `points_expire` 事件(否则这个减少在时间线上是空白)。 + points_expire_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) # 电竞手册是否已开通(与 elite_opened 对称)。 # 以前这个状态**只寄生在 `esports_bind_status` 里**(取值 `esports_opened`)—— # 那是「一列多义」的典型:同一个列既表示开通、又表示角色绑定、又表示查询痕迹。 diff --git a/web/backend/services/account_events_service.py b/web/backend/services/account_events_service.py index 350677b..b2c7d08 100644 --- a/web/backend/services/account_events_service.py +++ b/web/backend/services/account_events_service.py @@ -50,6 +50,8 @@ EVENT_BIND_OK = "bind_ok" # 绑定/换绑 EVENT_RECHARGE = "recharge" # 充值到账 EVENT_EXCHANGE = "exchange" # 兑换(含消耗积分) EVENT_POINTS_CREDIT = "points_credit" # 积分到账(含到期时间) +EVENT_POINTS_EXPIRE = "points_expire" # 积分到期(资产减少) +EVENT_GOLD_SPENT = "gold_spent" # 鱼翅消耗(送礼等) EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通 EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通 EVENT_ASSIGNED = "assigned" # 分配客服 diff --git a/web/backend/services/automation/__init__.py b/web/backend/services/automation/__init__.py index 31bd25c..92a983a 100644 --- a/web/backend/services/automation/__init__.py +++ b/web/backend/services/automation/__init__.py @@ -16,6 +16,8 @@ from .jobs.douyu_tasks_retention import ( from .jobs.douyu_tasks_retention import ( run_douyu_tasks_retention, ) +from .jobs.points_expire import JOB_KEY as POINTS_EXPIRE_KEY +from .jobs.points_expire import run_points_expire_check from .scheduler import ( JobSpec, automation_enabled, @@ -34,6 +36,7 @@ __all__ = [ "CK_HEALTH_KEY", "CK_RELOGIN_KEY", "DOUYU_TASKS_RETENTION_KEY", + "POINTS_EXPIRE_KEY", "JobSpec", "automation_enabled", "ensure_jobs", @@ -46,6 +49,7 @@ __all__ = [ "run_douyu_tasks_retention", "run_due_jobs", "run_job_now", + "run_points_expire_check", "scheduler_status", "start_scheduler", "stop_scheduler", @@ -54,6 +58,17 @@ __all__ = [ def register_default_jobs() -> None: """登记内置作业(幂等)。""" + register_job( + JobSpec( + key=POINTS_EXPIRE_KEY, + title="积分到期补记", + # 每天一次足够:到期时刻的精度是「天」级别,晚几小时记也不影响 + interval_seconds=24 * 3600, + batch_size=200, + concurrency=1, + run=run_points_expire_check, + ) + ) register_job( JobSpec( key=DOUYU_TASKS_RETENTION_KEY, diff --git a/web/backend/services/automation/jobs/points_expire.py b/web/backend/services/automation/jobs/points_expire.py new file mode 100644 index 0000000..567ac7e --- /dev/null +++ b/web/backend/services/automation/jobs/points_expire.py @@ -0,0 +1,110 @@ +"""作业:把已经到期的积分记进账号时间线。 + +## 为什么需要单独一个作业 + +积分到期是账号资产的**真实减少**,但斗鱼**不会通知**你 —— 它只在积分查询里 +给一个 `willExpireTime`。所以: + +1. 送礼流程顺手把这个时刻写进 `accounts.points_expire_at`(学习即记录); +2. 本作业扫「到期时刻已过、且积分还没归零」的账号,补写一条 `points_expire` + 事件,然后清掉该字段(不重复记)。 + +没有它,积分到期在时间线上就是永久空白 —— 进项有账、这项没账。 + +## 金额的诚实边界(重要) + +`willExpireTime` 的语义是「**当前**这些积分在此时到期」,但到期那一刻**到底扣了 +多少分,我们并没有亲眼看到**(斗鱼不给回调,重查要按账号发网络请求)。 + +所以本作业写出的 `amount` 是**当时账号上的积分余额**(也就是预计要归零的数量), +并且 `detail.confirmed = False`、标题带「(预计)」—— +**标成预计,而不是假装它是确认过的扣减**。 + +要变成已确认,只能由「到期后重查、真的看到余额下降」的调用方补一条 +`confirmed=True` 的事件。这里刻意不做网络请求:本作业要能在纯 DB 上跑完, +不必持有 CK,也不给斗鱼侧增加调用量。 +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from loguru import logger +from sqlalchemy.orm import Session + +from ....models import Account, AutomationJob +from ...business_events import record_points_expire + +JOB_KEY = "points_expire_check" + +#: 一次最多处理多少个账号(避免一轮里写太多事件) +DEFAULT_BATCH = 200 + + +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _load_config(job: AutomationJob) -> dict[str, Any]: + config = job.config + if isinstance(config, str): + try: + config = json.loads(config) + except ValueError: + config = {} + return dict(config) if isinstance(config, dict) else {} + + +def run_points_expire_check(db: Session, job: AutomationJob) -> dict[str, Any]: + batch = _positive_int(job.batch_size, DEFAULT_BATCH) + now = datetime.now(UTC) + + rows = ( + db.query(Account) + .filter( + Account.points_expire_at.isnot(None), + Account.points_expire_at <= now, + ) + .order_by(Account.points_expire_at.asc(), Account.id.asc()) + .limit(batch) + .all() + ) + + written = 0 + skipped_zero = 0 + for account in rows: + points = account.points + if not points or int(points) <= 0: + # 没有积分可到期:不写事件(写一条 -0 只是噪声),但要把字段清掉 + skipped_zero += 1 + account.points_expire_at = None + continue + record_points_expire( + db, + account, + amount=int(points), + occurred_at=account.points_expire_at, + expires_at=account.points_expire_at, + # 没亲眼看到扣减 —— 如实标成预计 + confirmed=False, + ) + account.points_expire_at = None + written += 1 + + db.commit() + message = ( + f"积分到期补记 {written} 条(另有 {skipped_zero} 个账号积分已为 0,只清标记)" + ) + logger.info(message) + return { + "scanned": len(rows), + "written": written, + "skipped_zero": skipped_zero, + "message": message, + } diff --git a/web/backend/services/business_events.py b/web/backend/services/business_events.py index 0200ca0..25b151a 100644 --- a/web/backend/services/business_events.py +++ b/web/backend/services/business_events.py @@ -40,20 +40,26 @@ from .account_events_service import ( CATEGORY_BUSINESS, EVENT_BIND_OK, EVENT_EXCHANGE, + EVENT_GOLD_SPENT, EVENT_POINTS_CREDIT, + EVENT_POINTS_EXPIRE, EVENT_RECHARGE, emit, ) -def task_ref(task_id: int) -> str: +def task_ref(task_id: int, kind: str = "") -> str: """业务事件的幂等键:一条任务行对应一条事实。 与旧收集器(`f"douyu_task:{row['id']}"`)**完全一致**,所以直发与收集器 同时观测到同一笔业务时只会写一条 —— 这让「先直发、观察一段、再删收集器」 成为安全的迁移路径。 """ - return f"douyu_task:{int(task_id)}" + base = f"douyu_task:{int(task_id)}" + # 同一个任务可能产生**多条**事件(例如送礼:既「积分到账」又「消耗鱼翅」)。 + # 它们共用 task_ref 会撞唯一键、后写的那条被静默丢弃 —— 所以副事件带一个后缀。 + # 不带后缀的形态与旧收集器一致,主事件继续沿用它。 + return f"{base}:{kind}" if kind else base def record_recharge( @@ -155,6 +161,82 @@ def record_exchange( ) +def record_gold_spent( + db: Session, + account: Account, + *, + task_id: int, + amount: int | None, + occurred_at: datetime | None = None, + reason: str = "", + source_label: str = "", + balance_after: int | None = None, + confirmed: bool = True, +) -> bool: + """鱼翅消耗(送礼等)。 + + `amount` 传**正数**,这里写成负号(与 `exchange` 一样,出项用负数)。 + 金额取**实测的余额差**(送礼前后各刷一次鱼翅),不是按礼物单价推算 —— + 单价会变、活动会打折,只有观测到的差是真的。 + + 余额差拿不到(或没有减少)时**不发事件**:宁可少一条,也不要记一笔没发生的消耗。 + """ + if not amount or int(amount) <= 0: + return False + spent = int(amount) + title = f"{source_label}消耗鱼翅 {spent}" if source_label else f"消耗鱼翅 {spent}" + if not confirmed: + title += "(未确认扣减)" + return emit( + db, + account_id=account.id, + event_type=EVENT_GOLD_SPENT, + title=title, + category=CATEGORY_BUSINESS, + occurred_at=occurred_at, + amount=-spent, + balance_after=balance_after, + detail={"reason": reason, "source": source_label, "confirmed": confirmed}, + source="douyu_task", + subject_type="gold", + # 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键 + source_ref=task_ref(task_id, "gold"), + ) + + +def record_points_expire( + db: Session, + account: Account, + *, + amount: int | None, + occurred_at: datetime | None = None, + expires_at: Any = None, + confirmed: bool = False, +) -> bool: + """积分到期(资产减少)。 + + ⚠️ **默认 `confirmed=False`**:斗鱼只在积分查询里给一个 `willExpireTime` + (到期时刻),到期**实际扣了多少分**我们并没有亲眼看到。 + 所以默认只记「预计到期」,并在 detail 里标清楚;要记成已确认, + 必须由重新查询后真正观测到余额下降的调用方显式传 `confirmed=True`。 + """ + title = f"积分到期 -{amount}" if amount else "积分到期" + if not confirmed: + title += "(预计)" + return emit( + db, + account_id=account.id, + event_type=EVENT_POINTS_EXPIRE, + title=title, + category=CATEGORY_BUSINESS, + occurred_at=occurred_at, + amount=-int(amount) if amount else None, + detail={"confirmed": confirmed, "expires_at": expires_at}, + source="points_expire", + subject_type="points", + ) + + def record_points_credit( db: Session, account: Account, diff --git a/web/backend/services/douyu_runner_donate.py b/web/backend/services/douyu_runner_donate.py index 893c5b9..d11bfe5 100644 --- a/web/backend/services/douyu_runner_donate.py +++ b/web/backend/services/douyu_runner_donate.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient from ..models import Account, DouyuTask -from .business_events import record_points_credit +from .business_events import record_gold_spent, record_points_credit DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 @@ -139,6 +139,13 @@ class DonateMixin: 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, @@ -195,6 +202,15 @@ class DonateMixin: 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( @@ -254,6 +270,15 @@ class DonateMixin: 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"]), @@ -289,6 +314,12 @@ class DonateMixin: 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: @@ -303,10 +334,21 @@ class DonateMixin: task_id=task.id, gain=max(0, gift_count) * 10, points_after=account.points, - expire_at=_points_expire_at(result), + 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) @@ -338,3 +380,48 @@ def _esports_gift_points(result: dict, baseline_points: int | None) -> int | Non 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 diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py index a3bb07e..ecc2873 100644 --- a/web/backend/services/douyu_runner_manual.py +++ b/web/backend/services/douyu_runner_manual.py @@ -375,15 +375,20 @@ class ManualMixin: account.updated_at = datetime.now(UTC) # elite_opened 是**覆盖式**布尔值:开通之后再操作也看不出「什么时候开的」。 # 在这里记一条事件,补上开通时间。 + # 开通是**资产减少**(花积分)。只记余额、不记花掉多少的话, + # 账号的积分流水就对不上账 —— 进项有金额、出项没有。 + spent = _points_spent(baseline_points, account.points) emit( db, account_id=account.id, event_type=EVENT_ELITE_OPENED, - title="精英宝典开通", + title=f"精英宝典开通 -{spent}" if spent else "精英宝典开通", occurred_at=account.updated_at, + amount=-spent if spent else None, detail={ "points": account.points, "expire_time": account.elite_expire_time, + "spent": spent, }, source="manual", source_ref=( @@ -478,13 +483,16 @@ class ManualMixin: # 与精英宝典同理:状态字段是覆盖式的,这里补上「什么时候开的」。 # 注意只在**真正开通**这一支发事件;上面「基线查到已开通」那一支是检测, # 每次都发会刷屏。 + # 同理:开通电竞手册也要记清花了多少积分 + spent = _points_spent(baseline_manual_score, account.esports_points) emit( db, account_id=account.id, event_type=EVENT_ESPORTS_OPENED, - title="电竞手册开通", + title=f"电竞手册开通 -{spent}" if spent else "电竞手册开通", occurred_at=account.updated_at, - detail={"points": account.esports_points}, + amount=-spent if spent else None, + detail={"points": account.esports_points, "spent": spent}, source="manual", source_ref=( f"esports_opened:{account.id}:{int(account.updated_at.timestamp())}" @@ -548,3 +556,16 @@ class ManualMixin: f"积分: {points if points is not None else '-'}", result, ) + + +def _points_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