审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** —— 充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记, 于是「一共充了多少」算得出来,「花哪儿了」算不出来。 按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。 ## 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`,标记被清、重跑不再重复 ## 现在的资产流水(斗鱼侧) | 方向 | 覆盖 | | --- | --- | | 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ | | 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** | | 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
"""作业:把已经到期的积分记进账号时间线。
|
|
|
|
## 为什么需要单独一个作业
|
|
|
|
积分到期是账号资产的**真实减少**,但斗鱼**不会通知**你 —— 它只在积分查询里
|
|
给一个 `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,
|
|
}
|