Files
live-hub-py/web/backend/services/account_events_service.py
T
yml2213 1a8710834a feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环
`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。

## 状态机(7 → 6 个状态)

`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`

**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。

## 唯一推导入口(防「双份真相」)

新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
  推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
  否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
  作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
  会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。

**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。

## 消费端:补上一个真缺口 + 删掉两处临时派生

- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
  选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
  「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
  `status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。

## 接口与前端

- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
  接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关

## 两个自查出来的问题(都已修)

1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
   `source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
   —— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
   改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
   `relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
   已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
   临时改回旧 CASE 该用例立刻红)。

## 验证

- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
  人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
  自动化排除 disabled/archived、列表状态筛选、接口

未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
2026-10-04 14:48:22 +08:00

261 lines
11 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.
"""账号事件流:写入与查询。
## 定位
`accounts` 存**当前状态**(积分、余额、开通标记),这张表存**发生过什么**。
账号详情页 = 状态卡(读 `accounts`)+ 时间线(读本表)。
## 两条写入路径
1. **直接发射**(`emit`):CK 失效/登录成功/重登、精英宝典与电竞手册开通 —— 这些
只在内存里出现过,不记就永远没有;
2. **增量收集**(`automation/jobs/account_events_collect.py`):充值/兑换/积分到账/绑定 ——
这些已经是 `douyu_tasks` 的行,由收集器按水位线往后提取,不动 runner 代码。
本模块只提供 `_task_to_event` 映射给它用。
## 两条硬约束
- **发射是旁路**:`emit` 任何失败都不能影响登录/兑换主流程(内部吞异常 + 记日志,
并用 SAVEPOINT 隔离,避免污染调用方的事务);
- **幂等**:`source_ref` 唯一键兜住「收集器重跑」「事件重复发射」。没有它,重跑一次
时间线就多一份重复记录。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from sqlalchemy import text
from sqlalchemy.orm import Session
# ── 事件类型 ──────────────────────────────────────────────
# 命名保持「名词_状态」风格,便于前端归类与筛选。
EVENT_IMPORTED = "imported" # 账号导入(读 accounts.created_at,一般不落表)
EVENT_LOGIN_OK = "login_ok" # 登录成功(含新 CK)
EVENT_CK_INVALID = "ck_invalid" # CK 判定失效
EVENT_RELOGIN_OK = "relogin_ok" # 自动重登成功
EVENT_RELOGIN_FAILED = "relogin_failed" # 自动重登失败(转人工/退避)
EVENT_BIND_OK = "bind_ok" # 绑定/换绑
EVENT_RECHARGE = "recharge" # 充值到账
EVENT_EXCHANGE = "exchange" # 兑换(含消耗积分)
EVENT_POINTS_CREDIT = "points_credit" # 积分到账(含到期时间)
EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通
EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通
EVENT_ASSIGNED = "assigned" # 分配客服
EVENT_TAG_CHANGED = "tag_changed" # 标签变更
EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…)
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
# 充值为正、兑换为负,其余不计入金额统计。
INCOME_TYPES = (EVENT_RECHARGE,)
OUTCOME_TYPES = (EVENT_EXCHANGE,)
# 粗分类:决定时间线怎么分组、以及哪些事件算「业务台账」。
CATEGORY_LIFECYCLE = "lifecycle" # 生命周期状态变迁
CATEGORY_AUTH = "auth" # 登录与 CK
CATEGORY_BUSINESS = "business" # 充值 / 兑换 / 积分 / 开通
CATEGORY_OPS = "ops" # 人工操作(分配 / 标签 / 停用)
def to_naive_utc(value: datetime) -> datetime:
"""把时间归一成 **naive UTC**(本服务所有时间列的落库形态)。
约定与 `database.UTCDateTime` 一致,也是全仓通行写法:
- **naive → 视为「已经是 UTC」,原样使用**;
- aware → 换算到 UTC 后去掉 tzinfo。
⚠️ 绝对不能直接写 `value.astimezone(UTC)`:对 naive 值,Python 会把它当作
**服务器本地时间**再换算。本机 UTC+8 时事件时间会被整整扣掉 8 小时,
而 naive 值的来源很常见 —— 用**裸 SQL** 读 DATETIME 列(如收集器读
`douyu_tasks.finished_at`)拿到的就是 naive,因为它绕过了 `UTCDateTime`
装饰器。仓库里 `scheduler._as_utc` / `yyb_service._as_utc` / `audit.py`
都遵守了这条约定,本函数与它们同口径。
"""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
def _now() -> datetime:
return datetime.now(UTC)
def emit(
db: Session,
*,
account_id: int,
event_type: str,
title: str,
occurred_at: datetime | None = None,
amount: int | None = None,
balance_after: int | None = None,
detail: dict[str, Any] | None = None,
source: str = "manual",
source_ref: str | None = None,
category: str = "",
subject_type: str = "",
subject_key: str = "",
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""记录一条账号事件。返回是否真的写入(幂等命中/异常时为 False)。
**绝不抛异常**:事件是旁路,不能因为记不上一条时间线就让登录或兑换失败。
用 SAVEPOINT 包住,失败只回滚这一条,不影响调用方已经做过的事。
`occurred_at` 是**业务发生时间**,可以是 aware,也可以是 naive
(naive 按约定视为已是 UTC,见 `to_naive_utc`)。
`category` / `subject_*` / `actor_*` 支撑台账检索:
例如「该账号该商品最近一次兑换」走
`(account_id, event_type, subject_key, occurred_at)` 复合索引。
"""
if not account_id or not event_type:
return False
sql = text(
"""
INSERT INTO account_events (
account_id, event_type, category, occurred_at, title,
amount, balance_after, subject_type, subject_key,
detail, source, actor_type, actor_id, source_ref, created_at
) VALUES (
:account_id, :event_type, :category, :occurred_at, :title,
:amount, :balance_after, :subject_type, :subject_key,
:detail, :source, :actor_type, :actor_id, :source_ref, :created_at
)
"""
)
params = {
"account_id": account_id,
"event_type": event_type,
"category": (category or "")[:16],
"occurred_at": to_naive_utc(occurred_at or _now()),
"title": (title or "")[:256],
"amount": amount,
"balance_after": balance_after,
"subject_type": (subject_type or "")[:16],
"subject_key": (subject_key or "")[:96],
"detail": json.dumps(detail, ensure_ascii=False, default=str)
if detail
else None,
"source": source[:24],
"actor_type": (actor_type or "")[:16],
"actor_id": actor_id,
"source_ref": (source_ref or None),
"created_at": to_naive_utc(_now()),
}
try:
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
with db.begin_nested():
db.execute(sql, params)
return True
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
return False
# ── douyu_tasks 增量收集 ──────────────────────────────────
# 只收集「业务事件」,例行轮询(query_*)一律不进时间线 —— 它们在 douyu_tasks 里
# 占绝大多数(query_limited_goods 就 6.7 万行),收进去时间线没法看。
COLLECTED_TASK_TYPES = (
"exchange_goods",
"create_gold_qr",
"donate_elite_gift",
"confirm_bind",
"confirm_esports_bind",
)
def _task_to_event(task_type: str, result: Any) -> dict[str, Any] | None:
"""把一条 douyu_tasks 行转成事件参数;解析不出返回 None(跳过并告警)。
⚠️ 字段路径是**抽样实测**得到的(见设计文档 §4.2),不是猜的。
解析不出时必须跳过而不是抛出去 —— 一行坏数据不能卡住整条水位线。
"""
if isinstance(result, str):
try:
result = json.loads(result)
except ValueError:
return None
if not isinstance(result, dict):
return None
if task_type == "exchange_goods":
name = result.get("commodity_name") or "兑换"
score = result.get("score")
points_after = (result.get("points_query") or {}).get("points")
return {
"event_type": EVENT_EXCHANGE,
"title": f"兑换 {name}",
"amount": -int(score) if isinstance(score, (int, float)) else None,
"balance_after": points_after if isinstance(points_after, int) else None,
"detail": {
"commodity_name": name,
"commodity_id": result.get("commodity_id"),
"order_id": result.get("order_id"),
"exchange_id": result.get("exchange_id"),
"game_name": result.get("game_name"),
"game_channel": result.get("game_channel"),
"score": score,
},
}
if task_type == "create_gold_qr":
amount = result.get("buy_num") or result.get("pay_amount")
supplier = result.get("supplier_order") or {}
try:
amount_int = int(float(amount)) if amount is not None else None
except (TypeError, ValueError):
amount_int = None
return {
"event_type": EVENT_RECHARGE,
"title": f"充值 {amount_int if amount_int is not None else ''}".strip(),
"amount": amount_int,
"balance_after": None,
"detail": {
"order_id": result.get("order_id"),
"supplier_order_id": supplier.get("order_id"),
"supplier_finished_at": supplier.get("finish_time"),
"channel": result.get("recharge_channel"),
"douyu_uid": result.get("douyu_uid"),
},
}
if task_type == "donate_elite_gift":
gain = result.get("gift_points_expected_gain")
points = result.get("points")
will_expire = ((result.get("points_query") or {}).get("raw") or {}).get("data")
expire_at = (
will_expire.get("willExpireTime") if isinstance(will_expire, dict) else None
)
return {
"event_type": EVENT_POINTS_CREDIT,
"title": f"积分到账 +{gain}" if gain is not None else "积分到账",
"amount": int(gain) if isinstance(gain, (int, float)) else None,
"balance_after": int(points) if isinstance(points, (int, float)) else None,
"detail": {
"baseline": result.get("gift_points_baseline"),
"target": result.get("gift_points_target"),
"points_expire_at": expire_at,
"gold_balance": result.get("gold_balance"),
},
}
if task_type in ("confirm_bind", "confirm_esports_bind"):
return {
"event_type": EVENT_BIND_OK,
"title": "绑定角色" if task_type == "confirm_bind" else "电竞手册绑定",
"amount": None,
"balance_after": None,
"detail": {"raw_keys": sorted(result.keys())[:20]},
}
return None