Files
live-hub-py/tests/test_account_events.py
T
yml2213 94eace50c1 fix(events): S2.1 修掉 emit() 的 naive 时区偏移,并打通台账列与审计关联
外部审计对 S1+S2 提出 3 条,全部复核成立,本提交一并修掉。第 1 条是真缺陷,
且已经写进数据了。

## 1. ⚠️ emit() 把 naive datetime 当成「本地时间」(数据正确性)

`emit()` 原来写 `(occurred_at or now).astimezone(UTC)`。对 **naive** 值,Python 会
把它当作**服务器本地时间**再换算 —— 本机 UTC+8,事件时间整整早 8 小时。

naive 的来源很具体:收集器用**裸 SQL** 读 `douyu_tasks`,绕过 `UTCDateTime`
装饰器,拿到的就是 naive。而这条路正是「充值/兑换/积分/开通」台账。

仓库早有既定约定(写在 `database.UTCDateTime` 里):**naive → 视为已是 UTC,原样用**。
`scheduler._as_utc` / `yyb_service._as_utc` / `audit.py` 都遵守了,**只有 emit() 漏了**。
现抽成 `account_events_service.to_naive_utc()` 统一收口,并补齐文档。

**数据库铁证**(修复前,本机开发库):
```
事件 occurred_at        源 douyu_tasks.finished_at    相差
2026-09-30 20:23:36  →  2026-10-01 04:23:36          8 小时
```
已用 `UPDATE ... JOIN douyu_tasks` 把那 3 条按源时间对齐(相差 0 秒),
修复 SQL 记在账号事件设计文档 §11.2 第 5 条,供曾开启过收集器的环境使用。
该作业默认 `enabled=false`、需人工开启,故生产大概率无受影响数据。

## 2. 回归用例:这个 bug 在 UTC 机器上**看不见**

`astimezone(UTC)` 在 TZ=UTC 下恰好是恒等变换 —— 旧实现与正确实现结果相同。
也就是说单写一条普通用例,在 CI/容器里会**假绿**,只在开发机现形。

所以用例显式把进程时区切到 UTC+8(`_force_non_utc_timezone`)。已验证:
**在 TZ=UTC 下临时还原旧实现,该用例照样红**。另补一条 aware 路径的用例,
防止「为了修 naive 把换算弄坏」。

顺带补了收集器的端到端用例:**事件 occurred_at 必须等于源任务 finished_at**。
旧用例只断言 written/watermark 数量、从不看时间,这正是 bug 一路绿灯的原因。

## 3. emit() 支持 S1 建的台账列(此前是「空壳」)

迁移 0042 给 `account_events` 加了 `category` / `subject_type` / `subject_key` /
`actor_type` / `actor_id` 与复合索引 `ix_account_events_ledger`,但 `emit()` 一直没
暴露它们 —— 列建好了没有写入方。现补齐参数与 INSERT,并加用例锁住「能写」。
S3 的 `status_changed`(`category="lifecycle"`)与 S5 的台账查询都要靠它。

## 4. 清 CK 的审计补 account_id

`DELETE /cookies/{id}` 与 `/cookies/batch` 的审计只写了 `target`(自由文本,
账号改名/重名就关联不上)。S1 已给 `audit_logs` 加了 `account_id`,这里补上写入
(批量时仅在只涉及一个账号时填),并加用例断言审计能反查到账号。
这是 S6「审计进账号时间线」的前置。

## 验证

- 新增/修改 5 条用例,先红后绿;**已验证在 TZ=UTC 下旧实现照样被这些用例抓到**
- `TZ=Asia/Shanghai`:**373 passed / 6 skipped**(原 368 + 5 条新用例)
- `TZ=UTC`:369 passed / 4 failed —— 那 4 条是 `test_huya_web_cookie_fields.py`
  的 qimei 用例,**在 main 上同样失败**(qimei 按 SDK 语义编码本地时间,
  与本次改动无关,也未修)
- ruff check / format 全绿;前端 `vite build` 通过(本次未动前端)
2026-10-04 14:30:02 +08:00

747 lines
26 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. **水位线只进不退**:解析失败要跳过而不是卡住,否则一行坏数据会让事件流永久停摆。
"""
import os
import time
from collections.abc import Iterator
from contextlib import contextmanager
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
@contextmanager
def _force_non_utc_timezone(name: str = "Asia/Shanghai") -> Iterator[None]:
"""临时把进程时区切到非 UTC,让「naive 被当成本地时间」这类 bug 必然暴露。
⚠️ 不加这一段,时区相关的回归在 **UTC 机器上(CI / 生产容器)会假绿**:
旧实现 `naive.astimezone(UTC)` 在 TZ=UTC 下恰好等于正确结果,
只有开发机(UTC+8)才现形 —— 而「只在某些机器上失败」的测试等于没有测试。
"""
previous = os.environ.get("TZ")
os.environ["TZ"] = name
time.tzset()
try:
yield
finally:
if previous is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = previous
time.tzset()
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_naive_occurred_at_is_treated_as_utc(self):
"""⚠️ 回归:naive datetime 必须按「已经是 UTC」处理,不能当本地时间。
仓库的既定约定写在 `database.UTCDateTime` 里:
**写:aware → 转 UTC 去 tzinfo;naive → 视为已是 UTC,原样存**。
`scheduler._as_utc` / `yyb_service._as_utc` / `audit.py` 都遵守了这条。
但 `emit()` 曾经直接 `.astimezone(UTC)` —— 对 naive 值,Python 会把它
当作**服务器本地时间**再换算。本机是 UTC+8 时,事件时间会被整整扣掉
8 小时(实测库里 `account_events.occurred_at` 比源 `douyu_tasks.finished_at`
早 8 小时)。而 `occurred_at` 是「业务发生时间」,错了整条时间线都是错的。
naive 值的来源很具体:收集器用**裸 SQL** 读 `douyu_tasks`,绕过了
`UTCDateTime` 装饰器,拿到的就是 naive。
用例显式切到 UTC+8 执行:在 UTC 机器上旧实现与正确实现结果相同,
不切时区就会假绿。
"""
with _force_non_utc_timezone():
# 故意造 naive 值:本用例就是要验证 naive 的处理
naive_probe = datetime(2026, 10, 3, 11, 46, 7) # noqa: DTZ001
assert naive_probe.astimezone(UTC).replace(tzinfo=None) != naive_probe, (
"时区未生效,本用例失去意义"
)
db = self._fresh()
try:
# 模拟从 DATETIME 列读出来的裸值(语义是 UTC,但没有 tzinfo)
naive_utc = datetime(2026, 10, 3, 11, 46, 7) # noqa: DTZ001
assert naive_utc.tzinfo is None
ok = svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_RECHARGE,
title="充值 98",
amount=98,
occurred_at=naive_utc,
source="task",
source_ref="naive-utc-regression",
)
assert ok
db.commit()
finally:
db.close()
db = self._fresh()
try:
event = (
db.query(AccountEvent)
.filter(AccountEvent.source_ref == "naive-utc-regression")
.one()
)
# 存进去的应当是同一个「墙上时间」,不能被时区换算挪动
assert event.occurred_at == datetime(2026, 10, 3, 11, 46, 7, tzinfo=UTC)
finally:
db.close()
def test_aware_occurred_at_is_converted_to_utc(self):
"""aware 值仍要正常换算(别为了修 naive 把这条路弄坏)。"""
from datetime import timedelta, timezone
db = self._fresh()
try:
ok = svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_RECHARGE,
title="充值 1",
occurred_at=datetime(
2026, 10, 3, 19, 46, 7, tzinfo=timezone(timedelta(hours=8))
),
source="task",
source_ref="aware-utc-regression",
)
assert ok
db.commit()
finally:
db.close()
db = self._fresh()
try:
event = (
db.query(AccountEvent)
.filter(AccountEvent.source_ref == "aware-utc-regression")
.one()
)
assert event.occurred_at == datetime(2026, 10, 3, 11, 46, 7, tzinfo=UTC)
finally:
db.close()
def test_stores_category_subject_and_actor(self):
"""台账检索列(category / subject_* / actor_*)要真的能写进去。
S1 的迁移 0042 建了这 5 列与复合索引 `ix_account_events_ledger`,但 `emit()`
一直没有暴露它们 —— 列建好了却没有写入方(和 `status` 一样的「空壳」)。
这条用例锁住「能写」,S3/S5 才能靠索引做「该账号该商品最近一次兑换」这类查询。
"""
db = self._fresh()
try:
ok = svc.emit(
db,
account_id=self.account_id,
event_type=svc.EVENT_EXCHANGE,
title="兑换 兔兔白日梦",
category=svc.CATEGORY_BUSINESS,
subject_type="commodity",
subject_key="20260120QYOOB_bag10",
actor_type="system",
actor_id=7,
source_ref="ledger-columns-regression",
)
assert ok
db.commit()
finally:
db.close()
db = self._fresh()
try:
event = (
db.query(AccountEvent)
.filter(AccountEvent.source_ref == "ledger-columns-regression")
.one()
)
assert event.category == "business"
assert event.subject_type == "commodity"
assert event.subject_key == "20260120QYOOB_bag10"
assert event.actor_type == "system"
assert event.actor_id == 7
finally:
db.close()
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
def test_collected_occurred_at_matches_source_task_time(self):
"""⚠️ 端到端回归:收集器写的事件时间必须等于源任务的业务时间。
这条用例存在的理由:收集器用**裸 SQL** 读 `douyu_tasks`,拿到的是 naive
datetime(绕过 `UTCDateTime`)。旧实现把它当本地时间换算,导致事件时间
整整早 8 小时(本机 UTC+8,库里实测过)——而「什么时候兑换/充值了」
正是这张表存在的意义。
旧用例只断言 written / watermark 的数量,从不看时间,所以这个 bug
一路绿灯溜了过去。
"""
from web.backend.models import DouyuTask
task_id = self._add_task(
"create_gold_qr",
{"buy_num": 98, "order_id": "o-time", "supplier_order": {}},
)
# 给源任务一个明确的业务完成时间(UTC 语义)
source = self.session.query(DouyuTask).filter(DouyuTask.id == task_id).one()
source.finished_at = datetime(2026, 10, 3, 11, 46, 7) # noqa: DTZ001
self.session.commit()
job = self._make_job(watermark=task_id - 1)
# 显式切到 UTC+8:这个 bug 在 UTC 机器上不可见,不切时区会假绿。
with _force_non_utc_timezone():
db = self._fresh()
try:
result = collect_job.run_account_events_collect(db, job)
finally:
db.close()
assert result["detail"]["written"] == 1
db = self._fresh()
try:
event = (
db.query(AccountEvent)
.filter(AccountEvent.source_ref == f"douyu_task:{task_id}")
.one()
)
assert event.occurred_at == datetime(2026, 10, 3, 11, 46, 7, tzinfo=UTC)
finally:
db.close()
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()