`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 账号页重构。
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""账号事件流:写入与查询。
|
||
|
||
## 定位
|
||
|
||
`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
|