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 = (
|
||||
|
||||
@@ -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)
|
||||
@@ -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`)——
|
||||
# 那是「一列多义」的典型:同一个列既表示开通、又表示角色绑定、又表示查询痕迹。
|
||||
|
||||
@@ -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" # 分配客服
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user