"""S5:业务事实直发事件。 取代了原来的「收集器事后扫 `douyu_tasks.result` 反推事件」。本文件守住三件事: 1. **命名构造函数的字段口径**(金额符号、detail、subject); 2. **幂等**:`source_ref=task_ref(task_id)` —— 让直发与仍在跑的收集器能互相去重, 也让「runner 轮询」与「供应商回调」不会对同一笔充值重复记账; 3. **发射顺序**:事件必须在 `_mark_task` **之前**发。`_mark_task` 内部 commit, 而 `emit` 只 RELEASE SAVEPOINT、不提交外层事务,runner 的 `finally` 只 close (未提交即回滚)—— 发在后面会被静默丢弃,且 `emit` 仍返回 True。 """ from datetime import UTC, datetime 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 from web.backend.services import business_events as biz NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC) class _Base: def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.session.query(AccountEvent).delete() self.user = User(username="biz-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="biz-acc", password="p", email="b@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def events(self, event_type: str) -> list[AccountEvent]: return ( self.session.query(AccountEvent) .filter( AccountEvent.account_id == self.account.id, AccountEvent.event_type == event_type, ) .order_by(AccountEvent.id.asc()) .all() ) class TestConstructors(_Base): def test_recharge_records_amount_and_channel(self): assert ( biz.record_recharge( self.session, self.account, task_id=101, amount=100, channel="wechat_qr", douyu_uid="u1", ) is True ) self.session.commit() events = self.events(svc.EVENT_RECHARGE) assert len(events) == 1 event = events[0] assert event.amount == 100 assert event.category == svc.CATEGORY_BUSINESS assert event.source_ref == "douyu_task:101" assert event.detail["channel"] == "wechat_qr" assert event.detail["confirmed"] is True def test_recharge_without_amount_still_records(self): """金额拿不到时宁可记一条金额为空的事件,也不要凭空编一个数字。""" biz.record_recharge(self.session, self.account, task_id=102, amount=None) self.session.commit() event = self.events(svc.EVENT_RECHARGE)[0] assert event.amount is None def test_recharge_marks_supplier_confirmation_honestly(self): """供应商终态成功 ≠ 斗鱼余额已变,必须如实标注。""" biz.record_recharge( self.session, self.account, task_id=103, amount=50, channel="supplier_api", confirmed=False, ) self.session.commit() event = self.events(svc.EVENT_RECHARGE)[0] assert event.detail["confirmed"] is False assert "供应商回执" in event.title def test_exchange_amount_is_negative_and_subject_is_commodity(self): biz.record_exchange( self.session, self.account, task_id=201, name="兔兔白日梦", score=1200, commodity_id="sku-9", points_after=800, scope="elite", ) self.session.commit() event = self.events(svc.EVENT_EXCHANGE)[0] assert event.amount == -1200 assert event.balance_after == 800 assert event.subject_type == "commodity" assert event.subject_key == "sku-9" assert event.detail["scope"] == "elite" def test_exchange_without_score_keeps_amount_null(self): biz.record_exchange( self.session, self.account, task_id=202, name="x", score=None ) self.session.commit() assert self.events(svc.EVENT_EXCHANGE)[0].amount is None def test_points_credit_marks_unconfirmed(self): """未确认到账也记,但必须标出来 —— 不能把「预期」写成「已到账」。""" biz.record_points_credit( self.session, self.account, task_id=301, gain=980, points_after=100, confirmed=False, ) self.session.commit() event = self.events(svc.EVENT_POINTS_CREDIT)[0] assert event.amount == 980 assert event.detail["confirmed"] is False assert "未确认" in event.title def test_points_credit_confirmed(self): biz.record_points_credit( self.session, self.account, task_id=302, gain=10, confirmed=True ) self.session.commit() event = self.events(svc.EVENT_POINTS_CREDIT)[0] assert event.detail["confirmed"] is True assert "未确认" not in event.title def test_bind_records_scope_and_rebind(self): biz.record_bind( self.session, self.account, task_id=401, scope="esports", role_name="小明", rebind=True, ) self.session.commit() event = self.events(svc.EVENT_BIND_OK)[0] assert event.subject_type == "esports_role" assert event.subject_key == "小明" assert event.detail["rebind"] is True assert "换绑" in event.title def test_bind_weak_is_flagged(self): biz.record_bind( self.session, self.account, task_id=402, scope="elite", role_name="小华", weak=True, ) self.session.commit() event = self.events(svc.EVENT_BIND_OK)[0] assert event.detail["confirmed"] is False assert "未回查确认" in event.title class TestIdempotency(_Base): """同一个 task 只能对应一条事实 —— 这是直发与收集器/回调共存的基石。""" def test_same_task_id_does_not_duplicate(self): biz.record_recharge(self.session, self.account, task_id=500, amount=10) self.session.commit() # 模拟「另一个路径也观测到同一笔」(收集器重跑 / 供应商回调) biz.record_recharge(self.session, self.account, task_id=500, amount=10) self.session.commit() assert len(self.events(svc.EVENT_RECHARGE)) == 1 def test_different_task_id_is_a_new_fact(self): biz.record_recharge(self.session, self.account, task_id=501, amount=10) self.session.commit() biz.record_recharge(self.session, self.account, task_id=502, amount=10) self.session.commit() assert len(self.events(svc.EVENT_RECHARGE)) == 2 def test_task_ref_matches_old_collector_key(self): """必须与旧收集器的 key 完全一致,否则迁移窗口内会重复记账。""" assert biz.task_ref(123) == "douyu_task:123" class TestEmitSurvivesWorkerSessionClose(_Base): """发射顺序的**行为验证**:事件要能挺过 runner 的 `finally: worker_db.close()`。 runner 的每个任务在 `_execute_one` 的 finally 里只 close 不 commit。 `_mark_task` 内部有 commit,所以「紧贴 _mark_task 之前 emit」会被那次 commit 一起提交;若把 emit 放在 _mark_task 之后,它就落在一个没人提交的事务里, close 时被回滚 —— 而且 `emit` 照样返回 True,没有任何日志。 """ def test_event_committed_together_with_mark_task(self): # 模拟 runner:emit 后由 _mark_task 的 commit 收尾 biz.record_recharge(self.session, self.account, task_id=600, amount=7) self.session.commit() # 等价于 _mark_task 内部的 commit self.session.close() # 等价于 runner 的 finally fresh = make_test_session(self.engine) try: rows = ( fresh.query(AccountEvent) .filter(AccountEvent.source_ref == "douyu_task:600") .all() ) assert len(rows) == 1, "事件没有挺过 session close —— 发射顺序错了" finally: fresh.close() def test_emit_without_commit_is_lost_on_close(self): """反证:不 commit 就 close,事件确实会丢(这就是顺序规则存在的理由)。""" biz.record_recharge(self.session, self.account, task_id=601, amount=7) self.session.close() # 故意不 commit fresh = make_test_session(self.engine) try: rows = ( fresh.query(AccountEvent) .filter(AccountEvent.source_ref == "douyu_task:601") .all() ) assert rows == [], "若不 commit 也能落库,说明事务边界与预期不符" finally: fresh.close() class TestCollectorIsGone: """收集器必须被彻底删掉,不留半条命。""" def test_module_removed(self): import importlib try: importlib.import_module( "web.backend.services.automation.jobs.account_events_collect" ) except ModuleNotFoundError: return raise AssertionError("收集器模块还在") def test_no_collector_symbols_left(self): assert not hasattr(svc, "COLLECTED_TASK_TYPES") assert not hasattr(svc, "_task_to_event") def test_job_key_not_registered(self): """⚠️ `registered_jobs()` 返回的是 **dict**(key → JobSpec)。 写成 `{spec.key for spec in registered_jobs()}` 会遍历出字符串键、 `spec.key` 直接报错;而如果它恰好是空字典,推导式又会得到空集让断言**假绿**。 所以这里显式 register 后再按键判断。 """ from web.backend.services.automation import ( register_default_jobs, registered_jobs, ) register_default_jobs() keys = set(registered_jobs()) assert "account_events_collect" not in keys # 反向确认这个断言不是假绿:新作业确实在里面 assert "douyu_tasks_retention" in keys class TestEmitOrderingInRunners: """源码扫描:runner 里的 `record_*` 必须在 `_mark_task(..., "success")` **之前**。 这条规则很隐蔽(违反了不会报错、只是事件静默消失),所以用用例钉住。 做法:对每个 `record_*` 调用,找它之后出现的第一个 `_mark_task(`,该调用里 必须带 `"success"`;如果事件被写在 mark 之后,那么它后面第一个 `_mark_task` 要么是另一个分支的、要么根本不存在 —— 两种情况都会让用例变红。 """ RUNNER_FILES = ( "web/backend/services/douyu_runner_goods.py", "web/backend/services/douyu_runner_gold.py", "web/backend/services/douyu_runner_bind.py", "web/backend/services/douyu_runner_donate.py", "web/backend/services/douyu_runner_xpd.py", ) def test_record_calls_precede_success_mark(self): import re from pathlib import Path root = Path(__file__).resolve().parents[1] record_re = re.compile(r"\brecord_(recharge|exchange|points_credit|bind)\(") offenders: list[str] = [] checked = 0 for rel in self.RUNNER_FILES: source = (root / rel).read_text(encoding="utf-8") for match in record_re.finditer(source): checked += 1 tail = source[match.end() :] mark = tail.find("_mark_task(") line_no = source[: match.start()].count("\n") + 1 if mark == -1: offenders.append(f"{rel}:{line_no} 之后没有 _mark_task") continue # 取这次 _mark_task 调用的前若干字符,判断是不是 success 分支 snippet = tail[mark : mark + 400] if '"success"' not in snippet: offenders.append( f"{rel}:{line_no} 之后的第一个 _mark_task 不是 success" ) assert checked >= 10, f"只扫到 {checked} 个 record_ 调用,扫描规则可能失效了" assert not offenders, ( "record_* 必须在 _mark_task(success) **之前**调用" "(否则事件会被 runner 的 close 静默回滚):\n" + "\n".join(offenders) ) class TestTaskReadyForApi(_Base): def test_douyu_task_still_works(self): """兜底:删掉收集器不应影响 douyu_tasks 本身的读写。""" task = DouyuTask( batch_id="b1", account_id=self.account.id, task_type="exchange_goods", status="success", result={"commodity_name": "x"}, created_by=self.user.id, ) self.session.add(task) self.session.commit() assert task.id is not None