feat(events): 补齐资产【出项】—— 开通金额、鱼翅消耗、积分到期(C 最全)
审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** —— 充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记, 于是「一共充了多少」算得出来,「花哪儿了」算不出来。 按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。 ## 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`,标记被清、重跑不再重复 ## 现在的资产流水(斗鱼侧) | 方向 | 覆盖 | | --- | --- | | 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ | | 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** | | 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
This commit is contained in:
@@ -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,不要猜一个时刻"
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user