1734 lines
78 KiB
Python
1734 lines
78 KiB
Python
"""统一直播统计 SQLite 存储层。
|
|
|
|
所有写入经由单个 asyncio.Queue 消费协程串行执行。业务侧 record_* 方法均为
|
|
best-effort:队列未启动、已关闭、数据不合法或数据库暂时不可用时不会向主业务抛出异常。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ctypes
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
try:
|
|
from .core.runtime_paths import APP_ROOT
|
|
except ImportError: # 允许作为独立模块加载
|
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
BEIJING_TZ = timezone(timedelta(hours=8), name="Asia/Shanghai")
|
|
DEFAULT_DATABASE_PATH = APP_ROOT / "data" / "statistics.sqlite3"
|
|
_SCHEMA_VERSION = 2
|
|
_STOP = object()
|
|
_FLUSH = object()
|
|
|
|
_LIFECYCLE_KEYS = {
|
|
"login_sessions": ("login_session_id", "started_at_utc", "business_date"),
|
|
"group_runs": ("group_run_id", "started_at_utc", "business_date"),
|
|
"song_requests": ("request_id", "requested_at_utc", "business_date"),
|
|
"playback_sessions": ("playback_id", "started_at_utc", "business_date"),
|
|
"broadcast_requests": ("request_id", "requested_at_utc", "business_date"),
|
|
"tts_requests": ("request_id", "requested_at_utc", "business_date"),
|
|
"bilibili_connections": ("connection_id", "connected_at_utc", "business_date"),
|
|
}
|
|
|
|
_SENSITIVE_PARTS = (
|
|
"cookie",
|
|
"token",
|
|
"authorization",
|
|
"api_key",
|
|
"apikey",
|
|
"access_key",
|
|
"secret",
|
|
"password",
|
|
"passwd",
|
|
"music_u",
|
|
"session_key",
|
|
"csrf",
|
|
"buvid",
|
|
)
|
|
_AUDIO_URL_PARTS = ("audio_url", "temp_url", "temporary_url", "voice_url", "tts_url")
|
|
|
|
|
|
def utc_now() -> str:
|
|
"""返回可排序的 UTC ISO-8601 时间。"""
|
|
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
|
|
|
|
def business_date(value: datetime | str | None = None) -> str:
|
|
"""按北京时间计算业务日;无时区输入按 UTC 处理。"""
|
|
if isinstance(value, str):
|
|
try:
|
|
current = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
current = datetime.now(UTC)
|
|
else:
|
|
current = value or datetime.now(UTC)
|
|
if current.tzinfo is None:
|
|
current = current.replace(tzinfo=UTC)
|
|
return (current.astimezone(BEIJING_TZ) - timedelta(hours=4)).date().isoformat()
|
|
|
|
|
|
def _safe_url(value: str) -> str:
|
|
"""仅保留 URL 的来源与路径,不保存查询串、片段和用户信息。"""
|
|
try:
|
|
parsed = urlsplit(value)
|
|
except ValueError:
|
|
return "[REDACTED_URL]"
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
return "[REDACTED_URL]"
|
|
hostname = parsed.hostname or ""
|
|
port = f":{parsed.port}" if parsed.port else ""
|
|
return urlunsplit((parsed.scheme, f"{hostname}{port}", parsed.path, "", ""))
|
|
|
|
|
|
def redact_payload(value: Any, key: str = "") -> Any:
|
|
"""递归复制并脱敏可序列化 payload。"""
|
|
lowered = key.casefold()
|
|
if any(part in lowered for part in _SENSITIVE_PARTS):
|
|
return "[REDACTED]"
|
|
if value is None or isinstance(value, (bool, int, float)):
|
|
return value
|
|
if isinstance(value, str):
|
|
if any(part in lowered for part in _AUDIO_URL_PARTS):
|
|
return "[REDACTED_AUDIO_URL]"
|
|
if lowered.endswith("url") and ("?" in value or "#" in value):
|
|
return _safe_url(value)
|
|
return value
|
|
if isinstance(value, bytes):
|
|
return f"[BYTES:{len(value)}]"
|
|
if isinstance(value, Mapping):
|
|
return {str(item_key): redact_payload(item_value, str(item_key)) for item_key, item_value in value.items()}
|
|
if isinstance(value, Sequence):
|
|
return [redact_payload(item, key) for item in value]
|
|
return str(value)
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(redact_payload(value), ensure_ascii=False, separators=(",", ":"), default=str)
|
|
|
|
|
|
def _utc_iso(value: datetime | str) -> str:
|
|
if isinstance(value, str):
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
else:
|
|
parsed = value
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=UTC)
|
|
return parsed.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
|
|
|
|
def _clean_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
|
|
clean = redact_payload(dict(fields))
|
|
assert isinstance(clean, dict)
|
|
for name, value in tuple(clean.items()):
|
|
if name.endswith("_at_utc") and value is not None:
|
|
clean[name] = _utc_iso(value)
|
|
elif isinstance(value, (dict, list, tuple)):
|
|
clean[name] = _json(value)
|
|
elif isinstance(value, bool):
|
|
clean[name] = int(value)
|
|
return clean
|
|
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at_utc TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS process_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL UNIQUE,
|
|
started_at_utc TEXT NOT NULL,
|
|
ended_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
pid INTEGER,
|
|
host TEXT,
|
|
app_version TEXT,
|
|
exit_reason TEXT,
|
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
platform TEXT NOT NULL,
|
|
platform_user_id TEXT NOT NULL,
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
user_level INTEGER,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
first_seen_at_utc TEXT NOT NULL,
|
|
last_seen_at_utc TEXT NOT NULL,
|
|
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
|
points INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(platform, platform_user_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS signin_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
signed_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
points_awarded INTEGER NOT NULL DEFAULT 0,
|
|
streak_days INTEGER,
|
|
status TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS point_transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
transaction_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
amount INTEGER NOT NULL,
|
|
balance_after INTEGER,
|
|
reason TEXT,
|
|
reference_type TEXT,
|
|
reference_id TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS queue_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT UNIQUE,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
queue_name TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
item_id TEXT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
position INTEGER,
|
|
queue_size INTEGER,
|
|
wait_ms INTEGER,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS login_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
login_session_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
started_at_utc TEXT NOT NULL,
|
|
ended_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
status TEXT,
|
|
client_kind TEXT,
|
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS group_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
group_run_id TEXT NOT NULL UNIQUE,
|
|
group_name TEXT,
|
|
started_at_utc TEXT NOT NULL,
|
|
ended_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
status TEXT,
|
|
requested_count INTEGER,
|
|
completed_count INTEGER,
|
|
failed_count INTEGER,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS song_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
request_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
requested_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
song_id TEXT,
|
|
song_name TEXT,
|
|
artist TEXT,
|
|
source TEXT,
|
|
status TEXT,
|
|
points_cost INTEGER NOT NULL DEFAULT 0,
|
|
queue_position INTEGER,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS playback_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
playback_id TEXT NOT NULL UNIQUE,
|
|
request_id TEXT REFERENCES song_requests(request_id),
|
|
song_id TEXT,
|
|
song_name TEXT,
|
|
started_at_utc TEXT NOT NULL,
|
|
ended_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
status TEXT,
|
|
duration_ms INTEGER,
|
|
played_ms INTEGER,
|
|
stop_reason TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS gift_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
gift_id TEXT,
|
|
gift_name TEXT,
|
|
quantity INTEGER NOT NULL DEFAULT 1,
|
|
unit_value REAL NOT NULL DEFAULT 0,
|
|
total_value REAL NOT NULL DEFAULT 0,
|
|
currency TEXT,
|
|
combo_id TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS gift_aggregates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
business_date TEXT NOT NULL,
|
|
platform TEXT NOT NULL DEFAULT '',
|
|
platform_user_id TEXT NOT NULL DEFAULT '',
|
|
gift_id TEXT NOT NULL DEFAULT '',
|
|
gift_name TEXT,
|
|
quantity INTEGER NOT NULL DEFAULT 0,
|
|
total_value REAL NOT NULL DEFAULT 0,
|
|
updated_at_utc TEXT NOT NULL,
|
|
UNIQUE(business_date, platform, platform_user_id, gift_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS broadcast_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
request_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
requested_at_utc TEXT NOT NULL,
|
|
completed_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
channel TEXT,
|
|
content_length INTEGER,
|
|
status TEXT,
|
|
result_code TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tts_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
request_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
requested_at_utc TEXT NOT NULL,
|
|
completed_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
voice TEXT,
|
|
text_length INTEGER,
|
|
status TEXT,
|
|
duration_ms INTEGER,
|
|
result_code TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS bilibili_connections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
connection_id TEXT NOT NULL UNIQUE,
|
|
room_id TEXT,
|
|
connected_at_utc TEXT NOT NULL,
|
|
disconnected_at_utc TEXT,
|
|
business_date TEXT NOT NULL,
|
|
status TEXT,
|
|
reconnect_count INTEGER NOT NULL DEFAULT 0,
|
|
disconnect_reason TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS danmu_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT NOT NULL DEFAULT 'bilibili',
|
|
platform_user_id TEXT,
|
|
room_id TEXT,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
message_type TEXT NOT NULL DEFAULT 'danmu',
|
|
content_length INTEGER,
|
|
command TEXT,
|
|
handled INTEGER NOT NULL DEFAULT 0,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS service_state_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT UNIQUE,
|
|
service_name TEXT NOT NULL,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
old_state TEXT,
|
|
new_state TEXT NOT NULL,
|
|
reason TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS admin_audit_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT NOT NULL UNIQUE,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
actor TEXT,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
success INTEGER NOT NULL DEFAULT 1,
|
|
remote_address_hash TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS import_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_key TEXT NOT NULL UNIQUE,
|
|
source_type TEXT NOT NULL,
|
|
source_fingerprint TEXT,
|
|
first_imported_at_utc TEXT NOT NULL,
|
|
last_imported_at_utc TEXT NOT NULL,
|
|
status TEXT,
|
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS import_checkpoints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL REFERENCES import_sources(id) ON DELETE CASCADE,
|
|
checkpoint_key TEXT NOT NULL,
|
|
checkpoint_value TEXT,
|
|
updated_at_utc TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
UNIQUE(source_id, checkpoint_key)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT NOT NULL UNIQUE,
|
|
event_type TEXT NOT NULL,
|
|
category TEXT,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
business_date TEXT NOT NULL,
|
|
process_session_id TEXT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
platform TEXT,
|
|
platform_user_id TEXT,
|
|
correlation_id TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_events_date_type ON events(business_date, event_type);
|
|
CREATE INDEX IF NOT EXISTS idx_events_user_time ON events(platform, platform_user_id, occurred_at_utc);
|
|
CREATE INDEX IF NOT EXISTS idx_signin_date ON signin_events(business_date);
|
|
CREATE INDEX IF NOT EXISTS idx_points_date ON point_transactions(business_date);
|
|
CREATE INDEX IF NOT EXISTS idx_song_date_status ON song_requests(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_playback_date_status ON playback_sessions(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_gifts_date ON gift_events(business_date);
|
|
CREATE INDEX IF NOT EXISTS idx_danmu_date ON danmu_events(business_date);
|
|
CREATE INDEX IF NOT EXISTS idx_tts_date_status ON tts_requests(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_queue_date_action ON queue_events(business_date, action);
|
|
CREATE INDEX IF NOT EXISTS idx_group_date_status ON group_runs(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_bilibili_date_status ON bilibili_connections(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_broadcast_date_status ON broadcast_requests(business_date, status);
|
|
CREATE INDEX IF NOT EXISTS idx_service_date_state ON service_state_events(business_date, new_state);
|
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_date_action ON admin_audit_events(business_date, action);
|
|
CREATE TABLE IF NOT EXISTS live_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
started_at_utc TEXT NOT NULL,
|
|
ended_at_utc TEXT,
|
|
status TEXT,
|
|
duration_ms INTEGER,
|
|
source TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
updated_at_utc TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS live_session_daily_durations (
|
|
session_id TEXT NOT NULL REFERENCES live_sessions(session_id) ON DELETE CASCADE,
|
|
business_date TEXT NOT NULL,
|
|
duration_ms INTEGER NOT NULL,
|
|
PRIMARY KEY(session_id, business_date)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_live_sessions_kind_time ON live_sessions(kind, started_at_utc);
|
|
CREATE INDEX IF NOT EXISTS idx_live_daily_date ON live_session_daily_durations(business_date);
|
|
"""
|
|
|
|
_DAILY_STATISTICS_VIEW = """
|
|
CREATE VIEW daily_live_statistics AS
|
|
WITH dates AS (
|
|
SELECT business_date FROM live_session_daily_durations
|
|
UNION SELECT business_date FROM events
|
|
UNION SELECT business_date FROM process_sessions
|
|
UNION SELECT business_date FROM signin_events
|
|
UNION SELECT business_date FROM point_transactions
|
|
UNION SELECT business_date FROM queue_events
|
|
UNION SELECT business_date FROM group_runs
|
|
UNION SELECT business_date FROM song_requests
|
|
UNION SELECT business_date FROM playback_sessions
|
|
UNION SELECT business_date FROM gift_events
|
|
UNION SELECT business_date FROM danmu_events
|
|
UNION SELECT business_date FROM tts_requests
|
|
UNION SELECT business_date FROM broadcast_requests
|
|
UNION SELECT business_date FROM bilibili_connections
|
|
UNION SELECT business_date FROM service_state_events
|
|
UNION SELECT business_date FROM admin_audit_events
|
|
)
|
|
SELECT d.business_date,
|
|
COALESCE((SELECT SUM(x.duration_ms) FROM live_session_daily_durations x JOIN live_sessions s ON s.session_id=x.session_id WHERE x.business_date=d.business_date AND s.kind IN ('live','stream','streaming')),0) AS live_duration_ms,
|
|
COALESCE((SELECT SUM(x.duration_ms) FROM live_session_daily_durations x JOIN live_sessions s ON s.session_id=x.session_id WHERE x.business_date=d.business_date AND s.kind='process'),0) AS process_duration_ms,
|
|
COALESCE((SELECT SUM(x.duration_ms) FROM live_session_daily_durations x JOIN live_sessions s ON s.session_id=x.session_id WHERE x.business_date=d.business_date AND s.kind='bilibili_connection'),0) AS bilibili_connection_duration_ms,
|
|
COALESCE((SELECT SUM(reconnect_count) FROM bilibili_connections x WHERE x.business_date=d.business_date),0) AS bilibili_reconnect_count,
|
|
(SELECT COUNT(*) FROM danmu_events x WHERE x.business_date=d.business_date AND x.message_type='danmu') AS danmu_count,
|
|
(SELECT COUNT(*) FROM danmu_events x WHERE x.business_date=d.business_date AND (x.command IS NOT NULL AND x.command<>'') ) AS command_count,
|
|
(SELECT COUNT(DISTINCT platform || ':' || COALESCE(platform_user_id,'')) FROM danmu_events x WHERE x.business_date=d.business_date AND COALESCE(platform_user_id,'')<>'') AS active_users,
|
|
(SELECT COUNT(*) FROM signin_events x WHERE x.business_date=d.business_date) AS signin_count,
|
|
(SELECT COUNT(DISTINCT platform || ':' || COALESCE(platform_user_id,'')) FROM signin_events x WHERE x.business_date=d.business_date AND COALESCE(platform_user_id,'')<>'') AS unique_signin_users,
|
|
COALESCE((SELECT SUM(amount) FROM point_transactions x WHERE x.business_date=d.business_date),0) AS point_net_change,
|
|
(SELECT COUNT(*) FROM queue_events x WHERE x.business_date=d.business_date AND x.action IN ('join','enqueue','entered')) AS queue_join_count,
|
|
(SELECT COUNT(DISTINCT COALESCE(CAST(user_id AS TEXT), json_extract(payload_json,'$.platform_user_id'), item_id)) FROM queue_events x WHERE x.business_date=d.business_date AND x.action IN ('join','enqueue','entered')) AS unique_queue_users,
|
|
(SELECT COUNT(*) FROM queue_events x WHERE x.business_date=d.business_date AND x.action IN ('leave','dequeue','removed')) AS leave_count,
|
|
(SELECT COUNT(*) FROM queue_events x WHERE x.business_date=d.business_date AND x.action IN ('timeout','expired')) AS timeout_count,
|
|
COALESCE((SELECT MAX(queue_size) FROM queue_events x WHERE x.business_date=d.business_date),0) AS max_queue_size,
|
|
(SELECT AVG(wait_ms) FROM queue_events x WHERE x.business_date=d.business_date AND x.action='promoted' AND wait_ms IS NOT NULL) AS avg_wait_ms,
|
|
(SELECT COUNT(*) FROM queue_events x WHERE x.business_date=d.business_date AND x.action='promoted' AND wait_ms IS NOT NULL) AS wait_sample_count,
|
|
(SELECT COUNT(*) FROM group_runs x WHERE x.business_date=d.business_date) AS group_run_count,
|
|
(SELECT COUNT(*) FROM group_runs x WHERE x.business_date=d.business_date AND x.status IN ('success','completed','finished')) AS group_run_success_count,
|
|
(SELECT COUNT(*) FROM group_runs x WHERE x.business_date=d.business_date AND x.status IN ('failed','error')) AS group_run_failed_count,
|
|
COALESCE((SELECT SUM(CASE WHEN ended_at_utc IS NOT NULL THEN MAX(0, CAST((julianday(ended_at_utc)-julianday(started_at_utc))*86400000 AS INTEGER)) ELSE 0 END) FROM group_runs x WHERE x.business_date=d.business_date),0) AS group_run_total_duration_ms,
|
|
(SELECT COUNT(*) FROM song_requests x WHERE x.business_date=d.business_date) AS song_request_count,
|
|
(SELECT COUNT(DISTINCT platform || ':' || COALESCE(platform_user_id,'')) FROM song_requests x WHERE x.business_date=d.business_date AND COALESCE(platform_user_id,'')<>'') AS unique_song_users,
|
|
(SELECT COUNT(*) FROM song_requests x WHERE x.business_date=d.business_date AND x.status IN ('played','completed','finished','success')) AS played_count,
|
|
(SELECT COUNT(*) FROM song_requests x WHERE x.business_date=d.business_date AND x.status IN ('skipped','skip')) AS skipped_count,
|
|
(SELECT COUNT(*) FROM song_requests x WHERE x.business_date=d.business_date AND x.status IN ('error','failed')) AS error_count,
|
|
(SELECT COUNT(*) FROM playback_sessions x WHERE x.business_date=d.business_date AND json_extract(x.payload_json,'$.source')='background') AS background_playback_count,
|
|
(SELECT COUNT(*) FROM gift_events x WHERE x.business_date=d.business_date) AS gift_event_count,
|
|
COALESCE((SELECT SUM(quantity) FROM gift_events x WHERE x.business_date=d.business_date),0) AS gift_quantity,
|
|
(SELECT COUNT(DISTINCT platform || ':' || COALESCE(platform_user_id,'')) FROM gift_events x WHERE x.business_date=d.business_date AND COALESCE(platform_user_id,'')<>'') AS unique_gifters,
|
|
COALESCE((SELECT SUM(total_value) FROM gift_events x WHERE x.business_date=d.business_date AND x.currency='CNY'),0) AS gift_total_value,
|
|
(SELECT COUNT(*) FROM tts_requests x WHERE x.business_date=d.business_date) AS tts_request_count,
|
|
(SELECT COUNT(*) FROM tts_requests x WHERE x.business_date=d.business_date AND x.status IN ('success','completed','finished')) AS tts_success_count,
|
|
(SELECT COUNT(*) FROM tts_requests x WHERE x.business_date=d.business_date AND x.status IN ('failure','failed','error')) AS tts_failure_count,
|
|
COALESCE((SELECT AVG(duration_ms) FROM tts_requests x WHERE x.business_date=d.business_date AND duration_ms IS NOT NULL),0) AS tts_avg_duration_ms,
|
|
(SELECT COUNT(*) FROM broadcast_requests x WHERE x.business_date=d.business_date) AS broadcast_request_count,
|
|
(SELECT COUNT(*) FROM broadcast_requests x WHERE x.business_date=d.business_date AND x.status IN ('success','completed','finished')) AS broadcast_success_count,
|
|
(SELECT COUNT(*) FROM broadcast_requests x WHERE x.business_date=d.business_date AND x.status IN ('failure','failed','error')) AS broadcast_failure_count,
|
|
(SELECT COUNT(*) FROM service_state_events x WHERE x.business_date=d.business_date AND x.new_state IN ('error','failed','failure')) AS service_error_count,
|
|
(SELECT COUNT(*) FROM service_state_events x WHERE x.business_date=d.business_date AND x.new_state IN ('restart','restarted','starting') ) AS service_restart_count,
|
|
(SELECT COUNT(*) FROM admin_audit_events x WHERE x.business_date=d.business_date) AS admin_action_count,
|
|
(SELECT COUNT(*) FROM events x WHERE x.business_date=d.business_date) AS generic_event_count
|
|
FROM dates d;
|
|
"""
|
|
|
|
|
|
class StatsStore:
|
|
"""基于 SQLite 的异步单写统计存储。"""
|
|
|
|
def __init__(
|
|
self,
|
|
database_path: str | Path = DEFAULT_DATABASE_PATH,
|
|
*,
|
|
queue_size: int = 10_000,
|
|
batch_size: int = 100,
|
|
commit_interval: float = 0.5,
|
|
) -> None:
|
|
self.database_path = Path(database_path)
|
|
self.queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=max(1, queue_size))
|
|
self.batch_size = max(1, batch_size)
|
|
self.commit_interval = max(0.05, commit_interval)
|
|
self._writer_task: asyncio.Task[None] | None = None
|
|
self._started = False
|
|
self._closing = False
|
|
self._connection: sqlite3.Connection | None = None
|
|
self._last_error: str | None = None
|
|
self._written = 0
|
|
self._dropped = 0
|
|
self._process_session_id: str | None = None
|
|
|
|
async def start(self) -> bool:
|
|
if self._started and self._writer_task and not self._writer_task.done():
|
|
return True
|
|
if self._closing:
|
|
return False
|
|
try:
|
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(self.database_path, timeout=5.0)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute("PRAGMA synchronous=NORMAL")
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
connection.execute("PRAGMA busy_timeout=5000")
|
|
self._migrate(connection)
|
|
gift_values_repaired = self._repair_gift_value_history(connection)
|
|
if gift_values_repaired:
|
|
LOGGER.warning("已修复统计库历史礼物金额: %s 条", gift_values_repaired)
|
|
repaired = self._repair_startup_reconciliation_history(connection)
|
|
if any(repaired.values()):
|
|
LOGGER.warning("已修复统计库历史异常生命周期: %s", repaired)
|
|
recovered = self._reconcile_stale_lifecycles(connection)
|
|
if any(recovered.values()):
|
|
LOGGER.warning("已收口统计库遗留生命周期: %s", recovered)
|
|
self._connection = connection
|
|
self._started = True
|
|
self._writer_task = asyncio.create_task(self._writer(), name="stats-store-writer")
|
|
return True
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
LOGGER.exception("无法启动统计存储")
|
|
try:
|
|
connection.close()
|
|
except (UnboundLocalError, sqlite3.Error):
|
|
pass
|
|
self._connection = None
|
|
self._started = False
|
|
return False
|
|
|
|
async def close(self) -> None:
|
|
if not self._started:
|
|
return
|
|
self._closing = True
|
|
try:
|
|
await self.flush()
|
|
await self.queue.put(_STOP)
|
|
if self._writer_task:
|
|
await self._writer_task
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
LOGGER.exception("关闭统计存储失败")
|
|
finally:
|
|
self._started = False
|
|
self._writer_task = None
|
|
if self._connection:
|
|
try:
|
|
self._connection.close()
|
|
except sqlite3.Error:
|
|
pass
|
|
self._connection = None
|
|
self._closing = False
|
|
|
|
async def flush(self) -> bool:
|
|
writer = self._writer_task
|
|
if not self._started or not writer or writer.done():
|
|
return False
|
|
loop = asyncio.get_running_loop()
|
|
completed = loop.create_future()
|
|
try:
|
|
await self.queue.put((_FLUSH, completed))
|
|
done, _ = await asyncio.wait((completed, writer), return_when=asyncio.FIRST_COMPLETED)
|
|
if completed in done:
|
|
return bool(completed.result())
|
|
self._last_error = "统计 writer 在 flush 完成前退出"
|
|
return False
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
async def execute(self, func, *args) -> Any:
|
|
"""在 writer 持有的唯一连接上执行同步函数并返回其结果。
|
|
|
|
供其它模块(如兑换码)复用同一 sqlite 连接、串行化写入,
|
|
消除多连接写同一文件导致的 "database is locked" 写争用。
|
|
"""
|
|
writer = self._writer_task
|
|
if not self._started or not writer or writer.done():
|
|
raise RuntimeError("统计存储未启动")
|
|
loop = asyncio.get_running_loop()
|
|
completed = loop.create_future()
|
|
try:
|
|
await self.queue.put(("__execute__", func, args, completed))
|
|
except Exception as exc:
|
|
completed.cancel()
|
|
raise RuntimeError("统计存储写入队列已满") from exc
|
|
return await completed
|
|
|
|
def emit_nowait(
|
|
self,
|
|
event_type: str,
|
|
payload: Mapping[str, Any] | None = None,
|
|
**fields: Any,
|
|
) -> bool:
|
|
"""无等待旁路写入;队列满或未启动时返回 False。"""
|
|
if not self._started or self._closing or not event_type:
|
|
self._dropped += 1
|
|
return False
|
|
try:
|
|
operation = self._event_operation(event_type, payload, fields)
|
|
self.queue.put_nowait(operation)
|
|
return True
|
|
except (asyncio.QueueFull, Exception) as exc:
|
|
self._dropped += 1
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
async def emit(
|
|
self,
|
|
event_type: str,
|
|
payload: Mapping[str, Any] | None = None,
|
|
**fields: Any,
|
|
) -> bool:
|
|
"""等待队列容量后旁路写入;失败不抛出。"""
|
|
if not self._started or self._closing or not event_type:
|
|
self._dropped += 1
|
|
return False
|
|
try:
|
|
await self.queue.put(self._event_operation(event_type, payload, fields))
|
|
return True
|
|
except Exception as exc:
|
|
self._dropped += 1
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
def _event_operation(
|
|
self,
|
|
event_type: str,
|
|
payload: Mapping[str, Any] | None,
|
|
fields: Mapping[str, Any],
|
|
) -> tuple[str, dict[str, Any]]:
|
|
now = _utc_iso(fields.get("occurred_at_utc") or utc_now())
|
|
event_id = str(fields.get("event_id") or self.make_event_id(event_type, now, fields, payload or {}))
|
|
return (
|
|
"events",
|
|
{
|
|
"event_id": event_id,
|
|
"event_type": event_type,
|
|
"category": fields.get("category"),
|
|
"occurred_at_utc": now,
|
|
"business_date": fields.get("business_date") or business_date(now),
|
|
"process_session_id": fields.get("process_session_id") or self._process_session_id,
|
|
"user_id": fields.get("user_id"),
|
|
"platform": fields.get("platform"),
|
|
"platform_user_id": fields.get("platform_user_id"),
|
|
"correlation_id": fields.get("correlation_id"),
|
|
"payload_json": _json(payload or {}),
|
|
},
|
|
)
|
|
|
|
def _enqueue_record(self, table: str, fields: Mapping[str, Any]) -> bool:
|
|
if not self._started or self._closing:
|
|
self._dropped += 1
|
|
return False
|
|
try:
|
|
self.queue.put_nowait((table, _clean_fields(fields)))
|
|
return True
|
|
except Exception as exc:
|
|
self._dropped += 1
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
async def _writer(self) -> None:
|
|
connection = self._connection
|
|
if connection is None:
|
|
return
|
|
pending = 0
|
|
while True:
|
|
try:
|
|
try:
|
|
item = await asyncio.wait_for(self.queue.get(), timeout=self.commit_interval)
|
|
except TimeoutError:
|
|
if pending:
|
|
connection.commit()
|
|
pending = 0
|
|
continue
|
|
if item is _STOP:
|
|
if pending:
|
|
connection.commit()
|
|
self.queue.task_done()
|
|
return
|
|
if isinstance(item, tuple) and item and item[0] is _FLUSH:
|
|
if pending:
|
|
connection.commit()
|
|
pending = 0
|
|
future = item[1]
|
|
if not future.done():
|
|
future.set_result(True)
|
|
self.queue.task_done()
|
|
continue
|
|
if isinstance(item, tuple) and len(item) == 4 and item[0] == "__execute__":
|
|
if pending:
|
|
connection.commit()
|
|
pending = 0
|
|
_, func, args, future = item
|
|
try:
|
|
result = func(connection, *args)
|
|
if not future.done():
|
|
future.set_result(result)
|
|
except Exception as exc:
|
|
try:
|
|
connection.rollback()
|
|
except Exception:
|
|
pass
|
|
if not future.done():
|
|
future.set_exception(exc)
|
|
self.queue.task_done()
|
|
continue
|
|
table, fields = item
|
|
if table == "__process_start__":
|
|
self._insert(connection, "process_sessions", fields)
|
|
self._upsert_live_session(connection, {
|
|
"session_id": fields["session_id"], "kind": "process",
|
|
"started_at_utc": fields["started_at_utc"], "status": "running",
|
|
"source": "process_sessions", "payload_json": fields.get("metadata_json", "{}"),
|
|
})
|
|
elif table == "__process_end__":
|
|
connection.execute(
|
|
"UPDATE process_sessions SET ended_at_utc=?, exit_reason=? WHERE session_id=?",
|
|
(fields["ended_at_utc"], fields.get("exit_reason"), fields["session_id"]),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT started_at_utc, metadata_json FROM process_sessions WHERE session_id=?",
|
|
(fields["session_id"],),
|
|
).fetchone()
|
|
if row:
|
|
self._upsert_live_session(connection, {
|
|
"session_id": fields["session_id"], "kind": "process",
|
|
"started_at_utc": row[0], "ended_at_utc": fields["ended_at_utc"],
|
|
"status": "ended", "source": "process_sessions", "payload_json": row[1],
|
|
})
|
|
elif table == "__bilibili_connection__":
|
|
self._upsert_lifecycle(connection, "bilibili_connections", fields)
|
|
if fields.get("disconnected_at_utc"):
|
|
row = connection.execute(
|
|
"SELECT connected_at_utc, disconnected_at_utc, status, payload_json "
|
|
"FROM bilibili_connections WHERE connection_id=?",
|
|
(fields["connection_id"],),
|
|
).fetchone()
|
|
if row and row[1]:
|
|
self._upsert_live_session(connection, {
|
|
"session_id": fields["connection_id"], "kind": "bilibili_connection",
|
|
"started_at_utc": row[0], "ended_at_utc": row[1],
|
|
"status": row[2], "source": "bilibili_connections",
|
|
"payload_json": row[3],
|
|
})
|
|
elif table == "__user_upsert__":
|
|
self._upsert_user(connection, fields)
|
|
elif table == "__import__":
|
|
self._run_import(connection, fields)
|
|
pending = 0
|
|
elif table == "__live_session_upsert__":
|
|
self._upsert_live_session(connection, fields)
|
|
else:
|
|
if table in _LIFECYCLE_KEYS:
|
|
inserted = self._upsert_lifecycle(connection, table, fields)
|
|
else:
|
|
inserted = self._insert(connection, table, fields)
|
|
if table == "gift_events" and inserted:
|
|
self._aggregate_gift(connection, fields)
|
|
pending += 1
|
|
self._written += 1
|
|
self.queue.task_done()
|
|
if pending >= self.batch_size:
|
|
connection.commit()
|
|
pending = 0
|
|
except Exception as exc:
|
|
connection.rollback()
|
|
pending = 0
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
LOGGER.exception("统计写入失败")
|
|
try:
|
|
self.queue.task_done()
|
|
except ValueError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _insert(connection: sqlite3.Connection, table: str, fields: Mapping[str, Any]) -> bool:
|
|
allowed = {
|
|
"process_sessions", "signin_events", "point_transactions", "queue_events",
|
|
"login_sessions", "group_runs", "song_requests", "playback_sessions",
|
|
"gift_events", "broadcast_requests", "tts_requests", "bilibili_connections",
|
|
"danmu_events", "service_state_events", "admin_audit_events", "events",
|
|
}
|
|
if table not in allowed:
|
|
raise ValueError(f"不允许写入表: {table}")
|
|
columns = tuple(fields)
|
|
sql = f"INSERT OR IGNORE INTO {table} ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})"
|
|
cursor = connection.execute(sql, tuple(fields[column] for column in columns))
|
|
return cursor.rowcount > 0
|
|
|
|
@staticmethod
|
|
def _upsert_lifecycle(connection: sqlite3.Connection, table: str, fields: Mapping[str, Any]) -> bool:
|
|
definition = _LIFECYCLE_KEYS.get(table)
|
|
if definition is None:
|
|
raise ValueError(f"不允许生命周期更新表: {table}")
|
|
key_column, start_column, business_column = definition
|
|
if key_column not in fields:
|
|
raise ValueError(f"{table} 缺少稳定 ID: {key_column}")
|
|
columns = tuple(fields)
|
|
placeholders = ",".join("?" for _ in columns)
|
|
immutable = {key_column, start_column, business_column}
|
|
updates = tuple(column for column in columns if column not in immutable)
|
|
if updates:
|
|
update_sql = ",".join(f"{column}=excluded.{column}" for column in updates)
|
|
conflict_sql = f"DO UPDATE SET {update_sql}"
|
|
else:
|
|
conflict_sql = "DO NOTHING"
|
|
sql = (
|
|
f"INSERT INTO {table} ({','.join(columns)}) VALUES ({placeholders}) "
|
|
f"ON CONFLICT({key_column}) {conflict_sql}"
|
|
)
|
|
cursor = connection.execute(sql, tuple(fields[column] for column in columns))
|
|
return cursor.rowcount > 0
|
|
|
|
@staticmethod
|
|
def _upsert_user(connection: sqlite3.Connection, fields: Mapping[str, Any]) -> None:
|
|
connection.execute(
|
|
"""INSERT INTO users (
|
|
platform, platform_user_id, display_name, avatar_url, user_level, is_admin,
|
|
first_seen_at_utc, last_seen_at_utc, snapshot_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(platform, platform_user_id) DO UPDATE SET
|
|
display_name=CASE WHEN excluded.last_seen_at_utc >= users.last_seen_at_utc
|
|
THEN COALESCE(excluded.display_name, users.display_name) ELSE users.display_name END,
|
|
avatar_url=CASE WHEN excluded.last_seen_at_utc >= users.last_seen_at_utc
|
|
THEN COALESCE(excluded.avatar_url, users.avatar_url) ELSE users.avatar_url END,
|
|
user_level=CASE WHEN excluded.last_seen_at_utc >= users.last_seen_at_utc
|
|
THEN COALESCE(excluded.user_level, users.user_level) ELSE users.user_level END,
|
|
is_admin=CASE WHEN excluded.last_seen_at_utc >= users.last_seen_at_utc
|
|
THEN excluded.is_admin ELSE users.is_admin END,
|
|
first_seen_at_utc=MIN(users.first_seen_at_utc, excluded.first_seen_at_utc),
|
|
last_seen_at_utc=MAX(users.last_seen_at_utc, excluded.last_seen_at_utc),
|
|
snapshot_json=CASE WHEN excluded.last_seen_at_utc >= users.last_seen_at_utc
|
|
THEN excluded.snapshot_json ELSE users.snapshot_json END""",
|
|
tuple(fields[name] for name in (
|
|
"platform", "platform_user_id", "display_name", "avatar_url", "user_level",
|
|
"is_admin", "first_seen_at_utc", "last_seen_at_utc", "snapshot_json",
|
|
)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _upsert_live_session(connection: sqlite3.Connection, fields: Mapping[str, Any]) -> None:
|
|
existing = connection.execute(
|
|
"SELECT kind, started_at_utc, ended_at_utc, status, duration_ms, source, payload_json FROM live_sessions WHERE session_id=?",
|
|
(fields["session_id"],),
|
|
).fetchone()
|
|
started = fields.get("started_at_utc") or (existing[1] if existing else None)
|
|
if not started:
|
|
raise ValueError("live session 缺少 started_at_utc")
|
|
ended = fields.get("ended_at_utc") if "ended_at_utc" in fields else (existing[2] if existing else None)
|
|
kind = fields.get("kind") or (existing[0] if existing else "live")
|
|
status = fields.get("status") if "status" in fields else (existing[3] if existing else None)
|
|
source = fields.get("source") if "source" in fields else (existing[5] if existing else None)
|
|
payload_json = fields.get("payload_json") if "payload_json" in fields else (existing[6] if existing else "{}")
|
|
duration_ms = fields.get("duration_ms")
|
|
if duration_ms is None and ended:
|
|
duration_ms = StatsStore._duration_ms(started, ended)
|
|
connection.execute(
|
|
"""INSERT INTO live_sessions (
|
|
session_id, kind, started_at_utc, ended_at_utc, status, duration_ms,
|
|
source, payload_json, updated_at_utc
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
kind=excluded.kind, started_at_utc=excluded.started_at_utc,
|
|
ended_at_utc=excluded.ended_at_utc, status=excluded.status,
|
|
duration_ms=excluded.duration_ms, source=excluded.source,
|
|
payload_json=excluded.payload_json, updated_at_utc=excluded.updated_at_utc""",
|
|
(fields["session_id"], kind, started, ended, status, duration_ms, source, payload_json, utc_now()),
|
|
)
|
|
connection.execute("DELETE FROM live_session_daily_durations WHERE session_id=?", (fields["session_id"],))
|
|
if ended:
|
|
for day, allocated_ms in StatsStore._split_session_duration(started, ended, duration_ms):
|
|
connection.execute(
|
|
"INSERT INTO live_session_daily_durations(session_id, business_date, duration_ms) VALUES (?, ?, ?)",
|
|
(fields["session_id"], day, allocated_ms),
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_utc(value: str) -> datetime:
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=UTC)
|
|
return parsed.astimezone(UTC)
|
|
|
|
@staticmethod
|
|
def _duration_ms(started_at_utc: str, ended_at_utc: str) -> int:
|
|
return max(0, round((StatsStore._parse_utc(ended_at_utc) - StatsStore._parse_utc(started_at_utc)).total_seconds() * 1000))
|
|
|
|
@staticmethod
|
|
def _split_session_duration(started_at_utc: str, ended_at_utc: str, duration_ms: Any = None) -> list[tuple[str, int]]:
|
|
started = StatsStore._parse_utc(started_at_utc)
|
|
ended = StatsStore._parse_utc(ended_at_utc)
|
|
if ended <= started:
|
|
return []
|
|
exact_total = (ended - started).total_seconds() * 1000
|
|
target_total = max(0, int(duration_ms)) if duration_ms is not None else round(exact_total)
|
|
segments: list[tuple[str, float]] = []
|
|
cursor = started
|
|
while cursor < ended:
|
|
local = cursor.astimezone(BEIJING_TZ)
|
|
boundary_date = local.date() if local.hour < 4 else local.date() + timedelta(days=1)
|
|
boundary = datetime.combine(boundary_date, datetime.min.time(), BEIJING_TZ).replace(hour=4).astimezone(UTC)
|
|
segment_end = min(ended, boundary)
|
|
segments.append((business_date(cursor), (segment_end - cursor).total_seconds() * 1000))
|
|
cursor = segment_end
|
|
allocated: list[tuple[str, int]] = []
|
|
consumed = 0
|
|
for index, (day, milliseconds) in enumerate(segments):
|
|
value = target_total - consumed if index == len(segments) - 1 else round(target_total * milliseconds / exact_total)
|
|
value = max(0, value)
|
|
allocated.append((day, value))
|
|
consumed += value
|
|
return allocated
|
|
|
|
@staticmethod
|
|
def _business_day_end(value: datetime) -> datetime:
|
|
local = value.astimezone(BEIJING_TZ)
|
|
boundary_date = local.date() if local.hour < 4 else local.date() + timedelta(days=1)
|
|
return datetime.combine(boundary_date, datetime.min.time(), BEIJING_TZ).replace(hour=4).astimezone(UTC)
|
|
|
|
@classmethod
|
|
def _merged_daily_interval_durations(
|
|
cls,
|
|
intervals: Sequence[tuple[Any, Any]],
|
|
) -> dict[str, int]:
|
|
daily_intervals: dict[str, list[tuple[datetime, datetime]]] = {}
|
|
for started_value, ended_value in intervals:
|
|
if not started_value or not ended_value:
|
|
continue
|
|
try:
|
|
started = cls._parse_utc(str(started_value))
|
|
ended = cls._parse_utc(str(ended_value))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if ended <= started:
|
|
continue
|
|
cursor = started
|
|
while cursor < ended:
|
|
segment_end = min(ended, cls._business_day_end(cursor))
|
|
daily_intervals.setdefault(business_date(cursor), []).append((cursor, segment_end))
|
|
cursor = segment_end
|
|
|
|
durations: dict[str, int] = {}
|
|
for day, segments in daily_intervals.items():
|
|
merged_ms = 0.0
|
|
current_start: datetime | None = None
|
|
current_end: datetime | None = None
|
|
for segment_start, segment_end in sorted(segments):
|
|
if current_start is None:
|
|
current_start, current_end = segment_start, segment_end
|
|
elif segment_start <= current_end:
|
|
current_end = max(current_end, segment_end)
|
|
else:
|
|
merged_ms += (current_end - current_start).total_seconds() * 1000
|
|
current_start, current_end = segment_start, segment_end
|
|
if current_start is not None and current_end is not None:
|
|
merged_ms += (current_end - current_start).total_seconds() * 1000
|
|
durations[day] = min(86_400_000, max(0, round(merged_ms)))
|
|
return durations
|
|
|
|
@staticmethod
|
|
def _aggregate_gift(connection: sqlite3.Connection, fields: Mapping[str, Any]) -> None:
|
|
connection.execute(
|
|
"""INSERT INTO gift_aggregates (
|
|
business_date, platform, platform_user_id, gift_id, gift_name,
|
|
quantity, total_value, updated_at_utc
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(business_date, platform, platform_user_id, gift_id) DO UPDATE SET
|
|
gift_name=COALESCE(excluded.gift_name, gift_aggregates.gift_name),
|
|
quantity=gift_aggregates.quantity + excluded.quantity,
|
|
total_value=gift_aggregates.total_value + excluded.total_value,
|
|
updated_at_utc=excluded.updated_at_utc""",
|
|
(
|
|
fields["business_date"], fields.get("platform") or "",
|
|
fields.get("platform_user_id") or "", fields.get("gift_id") or "",
|
|
fields.get("gift_name"), fields.get("quantity", 1),
|
|
fields.get("total_value", 0), fields.get("occurred_at_utc") or utc_now(),
|
|
),
|
|
)
|
|
|
|
def _run_import(self, connection: sqlite3.Connection, fields: Mapping[str, Any]) -> None:
|
|
future = fields["future"]
|
|
try:
|
|
existing = connection.execute(
|
|
"SELECT id, source_fingerprint, status FROM import_sources WHERE source_key=?",
|
|
(fields["source_key"],),
|
|
).fetchone()
|
|
if existing and existing[1] == fields.get("fingerprint") and existing[2] == "completed":
|
|
connection.commit()
|
|
if not future.done():
|
|
future.set_result(True)
|
|
return
|
|
now = utc_now()
|
|
connection.execute(
|
|
"""INSERT INTO import_sources (
|
|
source_key, source_type, source_fingerprint, first_imported_at_utc,
|
|
last_imported_at_utc, status, metadata_json
|
|
) VALUES (?, ?, ?, ?, ?, 'running', ?)
|
|
ON CONFLICT(source_key) DO UPDATE SET
|
|
source_type=excluded.source_type,
|
|
source_fingerprint=excluded.source_fingerprint,
|
|
last_imported_at_utc=excluded.last_imported_at_utc,
|
|
status='running', metadata_json=excluded.metadata_json""",
|
|
(fields["source_key"], fields["source_type"], fields.get("fingerprint"), now, now, fields["metadata_json"]),
|
|
)
|
|
source_id = connection.execute(
|
|
"SELECT id FROM import_sources WHERE source_key=?", (fields["source_key"],)
|
|
).fetchone()[0]
|
|
for table, record in fields["records"]:
|
|
if table == "users":
|
|
self._upsert_user(connection, record)
|
|
else:
|
|
if table in _LIFECYCLE_KEYS:
|
|
inserted = self._upsert_lifecycle(connection, table, record)
|
|
else:
|
|
inserted = self._insert(connection, table, record)
|
|
if table == "gift_events" and inserted:
|
|
self._aggregate_gift(connection, record)
|
|
connection.execute(
|
|
"""INSERT INTO import_checkpoints (
|
|
source_id, checkpoint_key, checkpoint_value, updated_at_utc, payload_json
|
|
) VALUES (?, ?, ?, ?, '{}')
|
|
ON CONFLICT(source_id, checkpoint_key) DO UPDATE SET
|
|
checkpoint_value=excluded.checkpoint_value, updated_at_utc=excluded.updated_at_utc""",
|
|
(source_id, fields["checkpoint_key"], fields.get("checkpoint_value"), now),
|
|
)
|
|
connection.execute(
|
|
"UPDATE import_sources SET status='completed', last_imported_at_utc=? WHERE id=?",
|
|
(now, source_id),
|
|
)
|
|
connection.commit()
|
|
if not future.done():
|
|
future.set_result(True)
|
|
except Exception as exc:
|
|
connection.rollback()
|
|
if not future.done():
|
|
future.set_result(False)
|
|
raise exc
|
|
|
|
@staticmethod
|
|
def _migrate(connection: sqlite3.Connection) -> None:
|
|
current_version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
if current_version > _SCHEMA_VERSION:
|
|
raise RuntimeError(f"数据库版本 {current_version} 高于程序支持版本 {_SCHEMA_VERSION}")
|
|
connection.executescript(_SCHEMA)
|
|
connection.execute("DROP VIEW IF EXISTS daily_live_statistics")
|
|
connection.executescript(_DAILY_STATISTICS_VIEW)
|
|
# 积分权威列迁移:旧库补充 points 列,并从历史 snapshot_json 一次性回填。
|
|
user_columns = {row[1] for row in connection.execute("PRAGMA table_info(users)")}
|
|
if "points" not in user_columns:
|
|
connection.execute("ALTER TABLE users ADD COLUMN points INTEGER NOT NULL DEFAULT 0")
|
|
connection.execute(
|
|
"UPDATE users SET points = CAST(COALESCE(json_extract(snapshot_json, '$.points'), 0) AS INTEGER)"
|
|
)
|
|
for version in range(max(1, current_version + 1), _SCHEMA_VERSION + 1):
|
|
connection.execute(
|
|
"INSERT OR IGNORE INTO schema_migrations(version, applied_at_utc) VALUES (?, ?)",
|
|
(version, utc_now()),
|
|
)
|
|
connection.execute(f"PRAGMA user_version={_SCHEMA_VERSION}")
|
|
connection.commit()
|
|
|
|
@staticmethod
|
|
def _pid_is_running(pid: Any) -> bool:
|
|
try:
|
|
value = int(pid)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if value <= 0:
|
|
return False
|
|
if os.name == "nt":
|
|
process = ctypes.windll.kernel32.OpenProcess(0x1000, False, value)
|
|
if not process:
|
|
return False
|
|
try:
|
|
exit_code = ctypes.c_ulong()
|
|
if not ctypes.windll.kernel32.GetExitCodeProcess(process, ctypes.byref(exit_code)):
|
|
return False
|
|
return exit_code.value == 259
|
|
finally:
|
|
ctypes.windll.kernel32.CloseHandle(process)
|
|
try:
|
|
os.kill(value, 0)
|
|
return True
|
|
except PermissionError:
|
|
return True
|
|
except (OSError, ProcessLookupError):
|
|
return False
|
|
|
|
@staticmethod
|
|
def _next_lifecycle_start(
|
|
connection: sqlite3.Connection,
|
|
table: str,
|
|
start_column: str,
|
|
row_id: int,
|
|
started_at_utc: str,
|
|
) -> str | None:
|
|
row = connection.execute(
|
|
f"SELECT {start_column} FROM {table} "
|
|
f"WHERE {start_column}>? OR ({start_column}=? AND id>?) "
|
|
f"ORDER BY {start_column}, id LIMIT 1",
|
|
(started_at_utc, started_at_utc, row_id),
|
|
).fetchone()
|
|
return str(row[0]) if row else None
|
|
|
|
@classmethod
|
|
def _reconciliation_end(
|
|
cls,
|
|
connection: sqlite3.Connection,
|
|
table: str,
|
|
start_column: str,
|
|
row_id: int,
|
|
started_at_utc: str,
|
|
now: str,
|
|
) -> str:
|
|
next_started = cls._next_lifecycle_start(
|
|
connection, table, start_column, row_id, started_at_utc
|
|
)
|
|
if next_started and cls._parse_utc(next_started) < cls._parse_utc(now):
|
|
return next_started
|
|
return now
|
|
|
|
@staticmethod
|
|
def _repair_gift_value_history(connection: sqlite3.Connection) -> int:
|
|
cursor = connection.execute(
|
|
"""UPDATE gift_events SET
|
|
total_value=MAX(0, CAST(json_extract(payload_json, '$.raw_total_coin') AS REAL)) / 1000.0,
|
|
unit_value=CASE WHEN quantity > 0 THEN
|
|
(MAX(0, CAST(json_extract(payload_json, '$.raw_total_coin') AS REAL)) / 1000.0) / quantity
|
|
ELSE 0 END,
|
|
payload_json=json_set(
|
|
payload_json, '$.value_rule', 'bilibili_gold_coin_1000_to_cny_1_v2'
|
|
)
|
|
WHERE currency='CNY'
|
|
AND LOWER(COALESCE(json_extract(payload_json, '$.coin_type'), ''))='gold'
|
|
AND COALESCE(json_extract(payload_json, '$.value_rule'), '')<>'bilibili_gold_coin_1000_to_cny_1_v2'
|
|
AND (
|
|
json_extract(payload_json, '$.value_rule')='gold_battery_10_to_cny_1_v1'
|
|
OR json_extract(payload_json, '$.value_migration')='bilibili_coin_to_cny_v1'
|
|
)
|
|
AND json_extract(payload_json, '$.raw_total_coin') IS NOT NULL"""
|
|
)
|
|
repaired = max(0, int(cursor.rowcount))
|
|
if repaired:
|
|
connection.execute("DELETE FROM gift_aggregates")
|
|
connection.execute(
|
|
"""INSERT INTO gift_aggregates (
|
|
business_date, platform, platform_user_id, gift_id, gift_name,
|
|
quantity, total_value, updated_at_utc
|
|
)
|
|
SELECT business_date, COALESCE(platform, ''), COALESCE(platform_user_id, ''),
|
|
COALESCE(gift_id, ''), MAX(gift_name), SUM(quantity), SUM(total_value),
|
|
MAX(occurred_at_utc)
|
|
FROM gift_events
|
|
GROUP BY business_date, COALESCE(platform, ''),
|
|
COALESCE(platform_user_id, ''), COALESCE(gift_id, '')"""
|
|
)
|
|
connection.commit()
|
|
return repaired
|
|
|
|
@classmethod
|
|
def _repair_startup_reconciliation_history(cls, connection: sqlite3.Connection) -> dict[str, int]:
|
|
repaired = {"process_sessions": 0, "bilibili_connections": 0, "group_runs": 0}
|
|
|
|
process_rows = connection.execute(
|
|
"SELECT id, session_id, started_at_utc, ended_at_utc, exit_reason, metadata_json "
|
|
"FROM process_sessions ORDER BY started_at_utc, id"
|
|
).fetchall()
|
|
for index, row in enumerate(process_rows[:-1]):
|
|
next_started = str(process_rows[index + 1][2])
|
|
if row[4] != "startup_reconciliation" or not row[3]:
|
|
continue
|
|
if cls._parse_utc(str(row[3])) <= cls._parse_utc(next_started):
|
|
continue
|
|
connection.execute(
|
|
"UPDATE process_sessions SET ended_at_utc=? WHERE id=?",
|
|
(next_started, row[0]),
|
|
)
|
|
cls._upsert_live_session(connection, {
|
|
"session_id": row[1], "kind": "process", "started_at_utc": row[2],
|
|
"ended_at_utc": next_started, "status": "interrupted",
|
|
"source": "process_sessions", "payload_json": row[5] or "{}",
|
|
})
|
|
repaired["process_sessions"] += 1
|
|
|
|
connection_rows = connection.execute(
|
|
"SELECT id, connection_id, connected_at_utc, disconnected_at_utc, disconnect_reason, payload_json "
|
|
"FROM bilibili_connections ORDER BY connected_at_utc, id"
|
|
).fetchall()
|
|
for index, row in enumerate(connection_rows[:-1]):
|
|
next_started = str(connection_rows[index + 1][2])
|
|
if row[4] != "startup_reconciliation" or not row[3]:
|
|
continue
|
|
if cls._parse_utc(str(row[3])) <= cls._parse_utc(next_started):
|
|
continue
|
|
connection.execute(
|
|
"UPDATE bilibili_connections SET disconnected_at_utc=? WHERE id=?",
|
|
(next_started, row[0]),
|
|
)
|
|
cls._upsert_live_session(connection, {
|
|
"session_id": row[1], "kind": "bilibili_connection", "started_at_utc": row[2],
|
|
"ended_at_utc": next_started, "status": "failed",
|
|
"source": "bilibili_connections", "payload_json": row[5] or "{}",
|
|
})
|
|
repaired["bilibili_connections"] += 1
|
|
|
|
group_rows = connection.execute(
|
|
"SELECT id, started_at_utc, ended_at_utc, payload_json "
|
|
"FROM group_runs ORDER BY started_at_utc, id"
|
|
).fetchall()
|
|
for index, row in enumerate(group_rows[:-1]):
|
|
if not row[2]:
|
|
continue
|
|
try:
|
|
payload = json.loads(row[3] or "{}")
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
next_started = str(group_rows[index + 1][1])
|
|
if payload.get("reason") != "startup_reconciliation":
|
|
continue
|
|
if cls._parse_utc(str(row[2])) <= cls._parse_utc(next_started):
|
|
continue
|
|
connection.execute(
|
|
"UPDATE group_runs SET ended_at_utc=? WHERE id=?",
|
|
(next_started, row[0]),
|
|
)
|
|
repaired["group_runs"] += 1
|
|
|
|
connection.commit()
|
|
return repaired
|
|
|
|
@classmethod
|
|
def _reconcile_stale_lifecycles(cls, connection: sqlite3.Connection) -> dict[str, int]:
|
|
"""启动时收口异常退出遗留的进程、B站连接和配置组会话。"""
|
|
recovered = {"process_sessions": 0, "bilibili_connections": 0, "group_runs": 0}
|
|
now = utc_now()
|
|
stale_process_ids: list[str] = []
|
|
for row in connection.execute(
|
|
"SELECT id, session_id, pid, started_at_utc, metadata_json "
|
|
"FROM process_sessions WHERE ended_at_utc IS NULL ORDER BY started_at_utc, id"
|
|
).fetchall():
|
|
if cls._pid_is_running(row[2]):
|
|
continue
|
|
ended_at = cls._reconciliation_end(
|
|
connection, "process_sessions", "started_at_utc", row[0], row[3], now
|
|
)
|
|
connection.execute(
|
|
"UPDATE process_sessions SET ended_at_utc=?, exit_reason=? WHERE session_id=? AND ended_at_utc IS NULL",
|
|
(ended_at, "startup_reconciliation", row[1]),
|
|
)
|
|
cls._upsert_live_session(connection, {
|
|
"session_id": row[1], "kind": "process", "started_at_utc": row[3],
|
|
"ended_at_utc": ended_at, "status": "interrupted", "source": "process_sessions",
|
|
"payload_json": row[4] or "{}",
|
|
})
|
|
stale_process_ids.append(str(row[1]))
|
|
recovered["process_sessions"] += 1
|
|
|
|
live_process_exists = any(
|
|
cls._pid_is_running(row[0])
|
|
for row in connection.execute(
|
|
"SELECT pid FROM process_sessions WHERE ended_at_utc IS NULL"
|
|
).fetchall()
|
|
)
|
|
if live_process_exists:
|
|
connection.commit()
|
|
return recovered
|
|
|
|
for row in connection.execute(
|
|
"SELECT id, connection_id, connected_at_utc, payload_json FROM bilibili_connections "
|
|
"WHERE disconnected_at_utc IS NULL ORDER BY connected_at_utc, id"
|
|
).fetchall():
|
|
ended_at = cls._reconciliation_end(
|
|
connection, "bilibili_connections", "connected_at_utc", row[0], row[2], now
|
|
)
|
|
connection.execute(
|
|
"UPDATE bilibili_connections SET disconnected_at_utc=?, status='failed', disconnect_reason=? "
|
|
"WHERE connection_id=? AND disconnected_at_utc IS NULL",
|
|
(ended_at, "startup_reconciliation", row[1]),
|
|
)
|
|
cls._upsert_live_session(connection, {
|
|
"session_id": row[1], "kind": "bilibili_connection", "started_at_utc": row[2],
|
|
"ended_at_utc": ended_at, "status": "failed", "source": "bilibili_connections",
|
|
"payload_json": row[3] or "{}",
|
|
})
|
|
recovered["bilibili_connections"] += 1
|
|
|
|
for row in connection.execute(
|
|
"SELECT id, group_run_id, started_at_utc, payload_json FROM group_runs "
|
|
"WHERE ended_at_utc IS NULL AND status='running' ORDER BY started_at_utc, id"
|
|
).fetchall():
|
|
try:
|
|
payload = json.loads(row[3] or "{}")
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
payload = {}
|
|
payload["reason"] = "startup_reconciliation"
|
|
if stale_process_ids:
|
|
payload["stale_process_session_ids"] = stale_process_ids
|
|
ended_at = cls._reconciliation_end(
|
|
connection, "group_runs", "started_at_utc", row[0], row[2], now
|
|
)
|
|
connection.execute(
|
|
"UPDATE group_runs SET ended_at_utc=?, status='interrupted', payload_json=? "
|
|
"WHERE group_run_id=? AND ended_at_utc IS NULL",
|
|
(ended_at, _json(payload), row[1]),
|
|
)
|
|
recovered["group_runs"] += 1
|
|
|
|
connection.commit()
|
|
return recovered
|
|
|
|
@staticmethod
|
|
def make_event_id(*parts: Any) -> str:
|
|
raw = _json(parts).encode("utf-8", "replace")
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
def record_process_start(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
pid: int | None = None,
|
|
host: str | None = None,
|
|
app_version: str | None = None,
|
|
metadata: Mapping[str, Any] | None = None,
|
|
started_at_utc: str | None = None,
|
|
) -> bool:
|
|
self._process_session_id = session_id
|
|
started = started_at_utc or utc_now()
|
|
return self._enqueue_record("__process_start__", {
|
|
"session_id": session_id, "started_at_utc": started, "business_date": business_date(started),
|
|
"pid": pid, "host": host, "app_version": app_version, "metadata_json": _json(metadata or {}),
|
|
})
|
|
|
|
def record_process_end(self, session_id: str | None = None, *, exit_reason: str | None = None) -> bool:
|
|
target = session_id or self._process_session_id
|
|
if not target or not self._started or self._closing:
|
|
return False
|
|
try:
|
|
self.queue.put_nowait(("__process_end__", {"session_id": target, "ended_at_utc": utc_now(), "exit_reason": exit_reason}))
|
|
if target == self._process_session_id:
|
|
self._process_session_id = None
|
|
return True
|
|
except Exception:
|
|
self._dropped += 1
|
|
return False
|
|
|
|
def upsert_live_session(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
kind: str | None = None,
|
|
started_at_utc: datetime | str | None = None,
|
|
ended_at_utc: datetime | str | None = None,
|
|
status: str | None = None,
|
|
duration_ms: int | None = None,
|
|
source: str | None = None,
|
|
payload: Mapping[str, Any] | None = None,
|
|
) -> bool:
|
|
if not session_id:
|
|
return False
|
|
fields: dict[str, Any] = {"session_id": session_id}
|
|
if kind is not None:
|
|
fields["kind"] = kind
|
|
if started_at_utc is not None:
|
|
fields["started_at_utc"] = started_at_utc
|
|
if ended_at_utc is not None:
|
|
fields["ended_at_utc"] = ended_at_utc
|
|
if status is not None:
|
|
fields["status"] = status
|
|
if duration_ms is not None:
|
|
fields["duration_ms"] = max(0, int(duration_ms))
|
|
if source is not None:
|
|
fields["source"] = source
|
|
if payload is not None:
|
|
fields["payload_json"] = _json(payload)
|
|
if not self._started or self._closing:
|
|
self._dropped += 1
|
|
return False
|
|
try:
|
|
self.queue.put_nowait(("__live_session_upsert__", _clean_fields(fields)))
|
|
return True
|
|
except Exception as exc:
|
|
self._dropped += 1
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
def begin_live_session(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
kind: str = "live",
|
|
started_at_utc: datetime | str | None = None,
|
|
status: str = "running",
|
|
source: str | None = None,
|
|
payload: Mapping[str, Any] | None = None,
|
|
) -> bool:
|
|
return self.upsert_live_session(
|
|
session_id, kind=kind, started_at_utc=started_at_utc or utc_now(),
|
|
status=status, source=source, payload=payload,
|
|
)
|
|
|
|
def end_live_session(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
ended_at_utc: datetime | str | None = None,
|
|
status: str = "ended",
|
|
duration_ms: int | None = None,
|
|
payload: Mapping[str, Any] | None = None,
|
|
) -> bool:
|
|
return self.upsert_live_session(
|
|
session_id, ended_at_utc=ended_at_utc or utc_now(), status=status,
|
|
duration_ms=duration_ms, payload=payload,
|
|
)
|
|
|
|
def upsert_user_snapshot(
|
|
self,
|
|
platform: str,
|
|
platform_user_id: str,
|
|
*,
|
|
display_name: str | None = None,
|
|
avatar_url: str | None = None,
|
|
user_level: int | None = None,
|
|
is_admin: bool = False,
|
|
snapshot: Mapping[str, Any] | None = None,
|
|
seen_at_utc: str | None = None,
|
|
) -> bool:
|
|
if not self._started or self._closing:
|
|
return False
|
|
seen = seen_at_utc or utc_now()
|
|
fields = _clean_fields({
|
|
"platform": platform, "platform_user_id": platform_user_id, "display_name": display_name,
|
|
"avatar_url": avatar_url, "user_level": user_level, "is_admin": is_admin,
|
|
"first_seen_at_utc": seen, "last_seen_at_utc": seen, "snapshot_json": _json(snapshot or {}),
|
|
})
|
|
try:
|
|
self.queue.put_nowait(("__user_upsert__", fields))
|
|
return True
|
|
except Exception:
|
|
self._dropped += 1
|
|
return False
|
|
|
|
def load_user_points(self, platform: str) -> dict[str, int]:
|
|
"""同步读取某平台的用户积分(权威余额)。仅可在事件循环线程调用。"""
|
|
connection = self._connection
|
|
if not self._started or connection is None:
|
|
return {}
|
|
rows = connection.execute(
|
|
"SELECT platform_user_id, points FROM users WHERE platform=?",
|
|
(platform,),
|
|
).fetchall()
|
|
return {str(row[0]): int(row[1]) for row in rows}
|
|
|
|
async def set_user_points(self, platform: str, platform_user_id: str, points: int) -> bool:
|
|
"""权威写入用户积分:串行执行并等待落盘后才返回。"""
|
|
if not self._started or self._closing:
|
|
return False
|
|
try:
|
|
await self.execute(self._set_user_points_impl, platform, platform_user_id, points)
|
|
return True
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
@staticmethod
|
|
def _set_user_points_impl(connection, platform, platform_user_id, points):
|
|
now = utc_now()
|
|
connection.execute(
|
|
"""INSERT INTO users (
|
|
platform, platform_user_id, display_name, first_seen_at_utc, last_seen_at_utc, points
|
|
) VALUES (?, ?, NULL, ?, ?, ?)
|
|
ON CONFLICT(platform, platform_user_id) DO UPDATE SET
|
|
points=excluded.points,
|
|
last_seen_at_utc=MAX(users.last_seen_at_utc, excluded.last_seen_at_utc)""",
|
|
(platform, platform_user_id, now, now, int(points)),
|
|
)
|
|
connection.commit()
|
|
|
|
def record_signin(self, event_id: str, *, platform: str, platform_user_id: str, points_awarded: int = 0, **fields: Any) -> bool:
|
|
return self._record_timed("signin_events", "signed_at_utc", event_id=event_id, platform=platform, platform_user_id=platform_user_id, points_awarded=points_awarded, **fields)
|
|
|
|
def record_point_transaction(self, transaction_id: str, amount: int, **fields: Any) -> bool:
|
|
return self._record_timed("point_transactions", "occurred_at_utc", transaction_id=transaction_id, amount=amount, **fields)
|
|
|
|
def record_queue_event(self, queue_name: str, action: str, **fields: Any) -> bool:
|
|
fields.setdefault("event_id", self.make_event_id(queue_name, action, utc_now(), fields))
|
|
return self._record_timed("queue_events", "occurred_at_utc", queue_name=queue_name, action=action, **fields)
|
|
|
|
def record_login_session(self, login_session_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("login_sessions", "started_at_utc", login_session_id=login_session_id, **fields)
|
|
|
|
def record_group_run(self, group_run_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("group_runs", "started_at_utc", group_run_id=group_run_id, **fields)
|
|
|
|
def record_song_request(self, request_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("song_requests", "requested_at_utc", request_id=request_id, **fields)
|
|
|
|
def record_playback_session(self, playback_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("playback_sessions", "started_at_utc", playback_id=playback_id, **fields)
|
|
|
|
def record_gift(self, event_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("gift_events", "occurred_at_utc", event_id=event_id, **fields)
|
|
|
|
def record_broadcast_request(self, request_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("broadcast_requests", "requested_at_utc", request_id=request_id, **fields)
|
|
|
|
def record_tts_request(self, request_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("tts_requests", "requested_at_utc", request_id=request_id, **fields)
|
|
|
|
def record_bilibili_connection(self, connection_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("__bilibili_connection__", "connected_at_utc", connection_id=connection_id, **fields)
|
|
|
|
def record_danmu(self, event_id: str, **fields: Any) -> bool:
|
|
return self._record_timed("danmu_events", "occurred_at_utc", event_id=event_id, **fields)
|
|
|
|
def record_service_state(self, service_name: str, new_state: str, **fields: Any) -> bool:
|
|
fields.setdefault("event_id", self.make_event_id(service_name, new_state, utc_now(), fields))
|
|
return self._record_timed("service_state_events", "occurred_at_utc", service_name=service_name, new_state=new_state, **fields)
|
|
|
|
def record_admin_audit(self, event_id: str, action: str, **fields: Any) -> bool:
|
|
if fields.get("remote_address"):
|
|
fields["remote_address_hash"] = hashlib.sha256(str(fields.pop("remote_address")).encode()).hexdigest()
|
|
return self._record_timed("admin_audit_events", "occurred_at_utc", event_id=event_id, action=action, **fields)
|
|
|
|
def _record_timed(self, table: str, time_column: str, **fields: Any) -> bool:
|
|
timestamp = _utc_iso(fields.get(time_column) or utc_now())
|
|
fields[time_column] = timestamp
|
|
fields.setdefault("business_date", business_date(timestamp))
|
|
payload = fields.pop("payload", None)
|
|
if payload is not None:
|
|
fields["payload_json"] = _json(payload)
|
|
return self._enqueue_record(table, fields)
|
|
|
|
async def import_once(
|
|
self,
|
|
source_key: str,
|
|
source_type: str,
|
|
records: Sequence[tuple[str, Mapping[str, Any]]],
|
|
*,
|
|
fingerprint: str | None = None,
|
|
checkpoint_key: str = "default",
|
|
checkpoint_value: str | None = None,
|
|
metadata: Mapping[str, Any] | None = None,
|
|
) -> bool:
|
|
"""幂等导入:相同 source_key+fingerprint 成功导入后不重复执行。"""
|
|
if not self._started or self._closing:
|
|
return False
|
|
loop = asyncio.get_running_loop()
|
|
completed = loop.create_future()
|
|
try:
|
|
await self.queue.put(("__import__", {
|
|
"source_key": source_key, "source_type": source_type, "fingerprint": fingerprint,
|
|
"records": [(table, _clean_fields(fields)) for table, fields in records],
|
|
"checkpoint_key": checkpoint_key, "checkpoint_value": checkpoint_value,
|
|
"metadata_json": _json(metadata or {}), "future": completed,
|
|
}))
|
|
return bool(await completed)
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return False
|
|
|
|
async def query_daily_statistics(self, start_date: str | None = None, end_date: str | None = None) -> list[dict[str, Any]]:
|
|
"""查询每日统计视图;查询失败返回空列表。"""
|
|
try:
|
|
return await self.query_daily_statistics_strict(start_date, end_date)
|
|
except Exception as exc:
|
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
|
return []
|
|
|
|
async def query_daily_statistics_strict(
|
|
self,
|
|
start_date: str | None = None,
|
|
end_date: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""查询每日统计视图;查询失败时向调用方抛出异常。"""
|
|
await self.flush()
|
|
rows = await asyncio.to_thread(self._query_daily_sync, start_date, end_date)
|
|
self._last_error = None
|
|
return rows
|
|
|
|
def runtime_snapshot(self) -> dict[str, Any]:
|
|
"""返回不执行磁盘完整性检查的轻量运行状态。"""
|
|
return {
|
|
"started": self._started,
|
|
"closing": self._closing,
|
|
"writer_alive": bool(self._writer_task and not self._writer_task.done()),
|
|
"queue_size": self.queue.qsize(),
|
|
"queue_capacity": self.queue.maxsize,
|
|
"last_error": self._last_error,
|
|
}
|
|
|
|
def _query_daily_sync(self, start_date: str | None, end_date: str | None) -> list[dict[str, Any]]:
|
|
clauses: list[str] = []
|
|
params: list[str] = []
|
|
if start_date:
|
|
clauses.append("business_date >= ?")
|
|
params.append(start_date)
|
|
if end_date:
|
|
clauses.append("business_date <= ?")
|
|
params.append(end_date)
|
|
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
uri = f"file:{self.database_path.as_posix()}?mode=ro"
|
|
connection = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
try:
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA busy_timeout=5000")
|
|
cursor = connection.execute(f"SELECT * FROM daily_live_statistics{where} ORDER BY business_date", params)
|
|
rows = [dict(row) for row in cursor]
|
|
columns = [item[0] for item in cursor.description or ()]
|
|
|
|
live_rows = connection.execute(
|
|
"SELECT kind, started_at_utc, ended_at_utc FROM live_sessions WHERE ended_at_utc IS NOT NULL"
|
|
).fetchall()
|
|
duration_maps = {
|
|
"live_duration_ms": self._merged_daily_interval_durations([
|
|
(row[1], row[2]) for row in live_rows if row[0] in {"live", "stream", "streaming"}
|
|
]),
|
|
"process_duration_ms": self._merged_daily_interval_durations([
|
|
(row[1], row[2]) for row in live_rows if row[0] == "process"
|
|
]),
|
|
"bilibili_connection_duration_ms": self._merged_daily_interval_durations([
|
|
(row[1], row[2]) for row in live_rows if row[0] == "bilibili_connection"
|
|
]),
|
|
"group_run_total_duration_ms": self._merged_daily_interval_durations([
|
|
(row[0], row[1]) for row in connection.execute(
|
|
"SELECT started_at_utc, ended_at_utc FROM group_runs WHERE ended_at_utc IS NOT NULL"
|
|
).fetchall()
|
|
]),
|
|
}
|
|
|
|
by_day = {str(row["business_date"]): row for row in rows}
|
|
duration_days = set().union(*(values.keys() for values in duration_maps.values()))
|
|
for day in duration_days:
|
|
if (start_date and day < start_date) or (end_date and day > end_date):
|
|
continue
|
|
if day not in by_day:
|
|
by_day[day] = {column: 0 for column in columns}
|
|
by_day[day]["business_date"] = day
|
|
for day, row in by_day.items():
|
|
for column, values in duration_maps.items():
|
|
row[column] = values.get(day, 0)
|
|
return [by_day[day] for day in sorted(by_day)]
|
|
finally:
|
|
connection.close()
|
|
|
|
async def health_snapshot(self) -> dict[str, Any]:
|
|
snapshot: dict[str, Any] = {
|
|
"started": self._started,
|
|
"closing": self._closing,
|
|
"writer_alive": bool(self._writer_task and not self._writer_task.done()),
|
|
"queue_size": self.queue.qsize(),
|
|
"queue_capacity": self.queue.maxsize,
|
|
"written": self._written,
|
|
"dropped": self._dropped,
|
|
"last_error": self._last_error,
|
|
"database_path": str(self.database_path),
|
|
"process_session_id": self._process_session_id,
|
|
}
|
|
if self._started:
|
|
try:
|
|
await self.flush()
|
|
snapshot["database"] = await asyncio.to_thread(self._health_sync)
|
|
except Exception as exc:
|
|
snapshot["database"] = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
else:
|
|
snapshot["database"] = {"ok": False, "error": "not_started"}
|
|
return snapshot
|
|
|
|
def _health_sync(self) -> dict[str, Any]:
|
|
uri = f"file:{self.database_path.as_posix()}?mode=ro"
|
|
connection = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
try:
|
|
result = connection.execute("PRAGMA quick_check").fetchone()
|
|
return {
|
|
"ok": bool(result and result[0] == "ok"),
|
|
"quick_check": result[0] if result else None,
|
|
"size_bytes": self.database_path.stat().st_size if self.database_path.exists() else 0,
|
|
"schema_version": connection.execute("PRAGMA user_version").fetchone()[0],
|
|
}
|
|
finally:
|
|
connection.close()
|