Files
live-hub-py/tests/test_account_events.py
T
yml2213 834bfdd1d0 feat(account-events): 账号详情页与账号事件历史全链路支持
1. 新增账号事件数据表 AccountEvent 与迁移版本 20261002_0040_account_events,支持审计 Cookie 变更、自动重登、健康检查及任务执行事件;
2. 后端新增 account_events_service 并扩展 /api/accounts 路由,支持事件查询、过滤与上报;
3. 前端新增 AccountDetailPage 账号详情页组件,支持账号元信息展示、事件历史瀑布流与快捷重登操作;
4. 修复 CK 重登与自动化定时任务中事件关联逻辑,补充 test_account_events 单元测试;
5. 新增生产服务器高负载排查分析报告及相关设计文档。
2026-10-03 12:58:58 +08:00

549 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号事件流回归:幂等发射、旁路隔离、任务映射、收集器水位线。
三条必须守住:
1. **发射是旁路**:`emit` 任何失败都不能影响主流程(登录/兑换照常);
2. **幂等**:同一个 `source_ref` 重复发射只落一条 —— 收集器重跑的可靠性全押在这上面;
3. **水位线只进不退**:解析失败要跳过而不是卡住,否则一行坏数据会让事件流永久停摆。
"""
from datetime import UTC, datetime
from sqlalchemy.orm import sessionmaker
import web.backend.database as database_module
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, AutomationJob, DouyuTask, User
from web.backend.services import account_events_service as svc
from web.backend.services.automation.jobs import account_events_collect as collect_job
class _Base:
def setup_method(self):
self.engine = get_test_engine()
database_module.SessionLocal.configure(bind=self.engine)
self.session = make_test_session(self.engine)
self.session.query(AccountEvent).delete()
self.session.query(DouyuTask).delete()
self.session.query(AutomationJob).filter(
AutomationJob.key == collect_job.JOB_KEY
).delete()
self.session.commit()
user = User(username="evt-admin", password_hash="h", role="super_admin")
self.session.add(user)
self.session.flush()
self.user_id = user.id
self.account = Account(
username="事件测试账号",
password="p",
email="evt@example.com",
email_password="ep",
)
self.session.add(self.account)
self.session.commit()
self.account_id = self.account.id
def teardown_method(self):
self.session.close()
def _fresh(self):
return sessionmaker(bind=self.engine)()
class TestEmit(_Base):
def test_writes_event(self):
db = self._fresh()
try:
ok = svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_EXCHANGE,
title="兑换 套装-兔兔白日梦",
amount=-1280,
balance_after=0,
detail={"commodity_name": "套装-兔兔白日梦"},
source="manual",
)
db.commit()
finally:
db.close()
assert ok is True
db = self._fresh()
try:
row = db.query(AccountEvent).one()
assert row.account_id == self.account_id
assert row.event_type == svc.EVENT_EXCHANGE
assert row.amount == -1280
assert row.balance_after == 0
assert row.detail["commodity_name"] == "套装-兔兔白日梦"
finally:
db.close()
def test_same_source_ref_is_idempotent(self):
"""同一 source_ref 只能落一条 —— 收集器重跑靠它兜住。"""
db = self._fresh()
try:
for _ in range(3):
svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_RECHARGE,
title="充值 98",
amount=98,
source="task",
source_ref="douyu_task:1001",
)
db.commit()
finally:
db.close()
db = self._fresh()
try:
assert db.query(AccountEvent).count() == 1
finally:
db.close()
def test_multiple_null_source_refs_coexist(self):
"""没有幂等键的事件(手工记录)不能互相冲突 —— 这正是 source_ref 要可空的原因。"""
db = self._fresh()
try:
for _ in range(3):
svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_ASSIGNED,
title="分配客服",
source="manual",
)
db.commit()
finally:
db.close()
db = self._fresh()
try:
assert db.query(AccountEvent).count() == 3
finally:
db.close()
def test_failure_is_swallowed(self):
"""旁路原则:account_id 不存在(外键失败)也不能抛出去。"""
db = self._fresh()
try:
ok = svc.emit(
db,
account_id=99999999,
event_type=svc.EVENT_LOGIN_OK,
title="登录成功",
source="login",
)
db.commit()
finally:
db.close()
assert ok is False
def test_event_does_not_break_caller_transaction(self):
"""emit 失败后,调用方在同一事务里已做的写入仍然能提交。
注意必须用**同一个 session**:换 session 就测不到「SAVEPOINT 隔离」这件事
(改动会落在另一个事务里,断言必然失败)。
"""
db = self._fresh()
try:
account = db.query(Account).filter(Account.id == self.account_id).first()
account.remark = "改动要保住"
db.flush()
# 这条会因外键失败
svc.emit(
db,
account_id=99999999,
event_type=svc.EVENT_LOGIN_OK,
title="x",
)
db.commit()
finally:
db.close()
db = self._fresh()
try:
row = db.query(Account).filter(Account.id == self.account_id).first()
assert row.remark == "改动要保住"
finally:
db.close()
def test_empty_account_or_type_is_noop(self):
db = self._fresh()
try:
assert svc.emit(db, account_id=0, event_type="x", title="y") is False
assert (
svc.emit(db, account_id=self.account_id, event_type="", title="y")
is False
)
finally:
db.close()
class TestTaskMapping:
def test_exchange_maps_amount_negative(self):
payload = svc._task_to_event(
"exchange_goods",
{
"commodity_name": "套装-狂战萌兔",
"commodity_id": "20260120QYOOB_bag5",
"score": 480,
"points_query": {"points": 0},
"order_id": "147874",
"game_name": "窝补誓渣男",
"game_channel": "微信 / 安卓(android)",
},
)
assert payload["event_type"] == svc.EVENT_EXCHANGE
assert payload["amount"] == -480, "消耗要记成负数,否则累计统计会反向"
assert payload["balance_after"] == 0
assert payload["detail"]["game_name"] == "窝补誓渣男"
def test_recharge_maps_amount(self):
payload = svc._task_to_event(
"create_gold_qr",
{
"buy_num": 98,
"order_id": "DYGF5C24C5E4EA84T238035",
"supplier_order": {
"order_id": "200000229425",
"finish_time": "2026-10-03 11:46:07",
},
"recharge_channel": "supplier_api",
"douyu_uid": "678503799",
},
)
assert payload["event_type"] == svc.EVENT_RECHARGE
assert payload["amount"] == 98
assert payload["detail"]["supplier_order_id"] == "200000229425"
def test_points_credit_keeps_expire_time(self):
payload = svc._task_to_event(
"donate_elite_gift",
{
"gift_points_expected_gain": 980,
"points": 1280,
"gift_points_baseline": 300,
"gift_points_target": 1280,
"points_query": {"raw": {"data": {"willExpireTime": 1798819199}}},
},
)
assert payload["event_type"] == svc.EVENT_POINTS_CREDIT
assert payload["amount"] == 980
assert payload["balance_after"] == 1280
assert payload["detail"]["points_expire_at"] == 1798819199
def test_unparsable_result_returns_none(self):
assert svc._task_to_event("exchange_goods", "{not json") is None
assert svc._task_to_event("exchange_goods", None) is None
assert svc._task_to_event("unknown_type", {"a": 1}) is None
class TestCollector(_Base):
def _make_job(self, watermark=None):
job = AutomationJob(
key=collect_job.JOB_KEY,
title="账号事件收集",
enabled=True,
interval_seconds=600,
batch_size=100,
concurrency=1,
config={"watermark": watermark} if watermark is not None else None,
updated_at=datetime.now(UTC),
)
self.session.add(job)
self.session.commit()
return job
def _add_task(self, task_type, result, status="success", account_id=None):
task = DouyuTask(
batch_id="evt-batch",
account_id=account_id or self.account_id,
task_type=task_type,
status=status,
result=result,
created_by=self.user_id,
)
self.session.add(task)
self.session.commit()
return task.id
def test_first_run_sets_watermark_without_backfill(self):
"""首次运行只定水位线,不回溯历史(2026-10-03 已定不回填)。"""
self._add_task(
"exchange_goods",
{"commodity_name": "旧兑换", "score": 100, "points_query": {"points": 0}},
)
job = self._make_job()
db = self._fresh()
try:
result = collect_job.run_account_events_collect(db, job)
finally:
db.close()
assert result["detail"]["first_run"] is True
assert result["scanned"] == 0
db = self._fresh()
try:
assert db.query(AccountEvent).count() == 0, "首次运行不应回填历史事件"
finally:
db.close()
def test_collects_only_new_rows_and_advances_watermark(self):
first = self._add_task(
"exchange_goods",
{"commodity_name": "老兑换", "score": 100, "points_query": {"points": 5}},
)
job = self._make_job(watermark=first)
new_id = self._add_task(
"create_gold_qr",
{"buy_num": 68, "order_id": "DYGF-x", "supplier_order": {}},
)
db = self._fresh()
try:
result = collect_job.run_account_events_collect(db, job)
finally:
db.close()
assert result["detail"]["written"] == 1
assert result["detail"]["watermark"] == new_id
db = self._fresh()
try:
rows = db.query(AccountEvent).all()
assert len(rows) == 1
assert rows[0].event_type == svc.EVENT_RECHARGE
assert rows[0].source_ref == f"douyu_task:{new_id}"
finally:
db.close()
def test_rerun_does_not_duplicate(self):
"""重复跑不能造重复事件(source_ref 兜底)。"""
job = self._make_job(watermark=0)
self._add_task(
"create_gold_qr",
{"buy_num": 98, "order_id": "o1", "supplier_order": {}},
)
db = self._fresh()
try:
collect_job.run_account_events_collect(db, job)
finally:
db.close()
# 把水位线倒回去,模拟「重复处理同一批」
job2 = self._fresh()
try:
row = (
job2.query(AutomationJob)
.filter(AutomationJob.key == collect_job.JOB_KEY)
.first()
)
row.config = {"watermark": 0}
job2.commit()
finally:
job2.close()
db = self._fresh()
try:
job3 = (
db.query(AutomationJob)
.filter(AutomationJob.key == collect_job.JOB_KEY)
.first()
)
collect_job.run_account_events_collect(db, job3)
finally:
db.close()
db = self._fresh()
try:
assert db.query(AccountEvent).count() == 1
finally:
db.close()
def test_failed_task_produces_no_event(self):
"""失败的兑换不该记成「消耗了积分」。"""
job = self._make_job(watermark=0)
self._add_task(
"exchange_goods",
{"commodity_name": "失败兑换", "score": 100},
status="failed",
)
db = self._fresh()
try:
result = collect_job.run_account_events_collect(db, job)
finally:
db.close()
assert result["detail"]["written"] == 0
assert result["skipped"] == 1
def test_unparsable_row_does_not_stall_watermark(self):
"""坏数据要跳过并推进水位线,不能让事件流永久停摆。"""
job = self._make_job(watermark=0)
bad_id = self._add_task("exchange_goods", "{not json")
good_id = self._add_task(
"create_gold_qr", {"buy_num": 1, "order_id": "o2", "supplier_order": {}}
)
db = self._fresh()
try:
result = collect_job.run_account_events_collect(db, job)
finally:
db.close()
assert result["detail"]["written"] == 1
assert result["detail"]["watermark"] == good_id
assert good_id > bad_id
class TestAccountApi(_Base):
"""账号详情接口:概览统计 + 时间线分页 + 删除账号时清理事件。"""
def _admin(self):
return self.session.query(User).filter(User.username == "evt-admin").first()
def _seed(self):
"""造三条不同类型的事件,供统计与筛选断言。"""
db = self._fresh()
try:
svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_RECHARGE,
title="充值 98",
amount=98,
occurred_at=datetime(2026, 10, 1, 3, 0, tzinfo=UTC),
source="task",
source_ref="douyu_task:9001",
)
svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_EXCHANGE,
title="兑换 兔兔白日梦",
amount=-1280,
balance_after=0,
occurred_at=datetime(2026, 10, 2, 3, 0, tzinfo=UTC),
source="task",
source_ref="douyu_task:9002",
)
svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_ELITE_OPENED,
title="精英宝典开通",
occurred_at=datetime(2026, 10, 3, 3, 0, tzinfo=UTC),
source="manual",
source_ref="elite_opened:1:1",
)
db.commit()
finally:
db.close()
# ⚠️ MySQL 默认 REPEATABLE READ:self.session 在 setup 阶段就开了事务,
# 快照早于上面那批写入,不结束事务就查不到刚写的事件(P1b 踩过同一个坑)。
self.session.commit()
def test_overview_aggregates_stats(self):
from web.backend.routers import accounts as api
self._seed()
data = api.account_overview(
account_id=self.account_id, db=self.session, current=self._admin()
)
assert data["username"] == "事件测试账号"
assert data["created_at"] is not None, "导入时间必须返回"
stats = data["stats"]
assert stats["recharge_total"] == 98
assert stats["exchange_count"] == 1
assert stats["points_spent"] == 1280, "消耗要按正数展示给用户"
assert stats["event_count"] == 3
# 开通时间由事件表提供(不回填历史,所以只有上线后开通的才有)
assert data["opened_at"]["elite"] is not None
def test_events_paginated_desc(self):
from web.backend.routers import accounts as api
self._seed()
page = api.account_events(
account_id=self.account_id,
page=1,
page_size=2,
db=self.session,
current=self._admin(),
)
assert page["total"] == 3
assert len(page["items"]) == 2
# 倒序:最新的精英宝典开通在最前
assert page["items"][0]["event_type"] == svc.EVENT_ELITE_OPENED
def test_events_filter_by_type(self):
from web.backend.routers import accounts as api
self._seed()
page = api.account_events(
account_id=self.account_id,
event_type=svc.EVENT_EXCHANGE,
db=self.session,
current=self._admin(),
)
assert page["total"] == 1
assert page["items"][0]["title"] == "兑换 兔兔白日梦"
def test_events_isolated_between_accounts(self):
"""事件必须按账号隔离 —— 串号会让客服看到别人的记录。"""
from web.backend.routers import accounts as api
self._seed()
other = Account(
username="另一个账号",
password="p",
email="o@example.com",
email_password="ep",
)
self.session.add(other)
self.session.commit()
db = self._fresh()
try:
svc.emit(
db,
account_id=other.id,
event_type=svc.EVENT_RECHARGE,
title="别人的充值",
amount=1,
source="task",
source_ref="douyu_task:9003",
)
db.commit()
finally:
db.close()
page = api.account_events(
account_id=self.account_id, db=self.session, current=self._admin()
)
assert all("别人" not in item["title"] for item in page["items"])
def test_delete_account_cleans_events(self):
"""事件表有指向 accounts 的外键:删账号必须连带清事件,否则删不掉。"""
from web.backend.routers import accounts as api
self._seed()
api.delete_account(
account_id=self.account_id, db=self.session, current=self._admin()
)
db = self._fresh()
try:
left = (
db.query(AccountEvent)
.filter(AccountEvent.account_id == self.account_id)
.count()
)
assert left == 0
assert (
db.query(Account).filter(Account.id == self.account_id).first() is None
)
finally:
db.close()