1. 新增账号事件数据表 AccountEvent 与迁移版本 20261002_0040_account_events,支持审计 Cookie 变更、自动重登、健康检查及任务执行事件; 2. 后端新增 account_events_service 并扩展 /api/accounts 路由,支持事件查询、过滤与上报; 3. 前端新增 AccountDetailPage 账号详情页组件,支持账号元信息展示、事件历史瀑布流与快捷重登操作; 4. 修复 CK 重登与自动化定时任务中事件关联逻辑,补充 test_account_events 单元测试; 5. 新增生产服务器高负载排查分析报告及相关设计文档。
549 lines
18 KiB
Python
549 lines
18 KiB
Python
"""账号事件流回归:幂等发射、旁路隔离、任务映射、收集器水位线。
|
||
|
||
三条必须守住:
|
||
|
||
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()
|