feat(db): add video pages and up follower snapshot schema
This commit is contained in:
1 parent
707ed7d7d7
commit
4942d1d161
10 files changed
+521
-13
No files matched your search
+78
-1
@@ -10,15 +10,23 @@ sys.stdout.reconfigure(encoding="utf-8")
|
||||
from reaction_board.analytics import ( # noqa: E402
|
||||
DEFAULT_HEAT_WEIGHTS,
|
||||
METRICS,
|
||||
age_days,
|
||||
compute_growth,
|
||||
content_hours,
|
||||
eb_rate,
|
||||
effective_shrink_minutes,
|
||||
heat_scores,
|
||||
interactions_per_hour,
|
||||
interaction_rate,
|
||||
is_loopback,
|
||||
is_mature,
|
||||
median_of,
|
||||
normalize_cover,
|
||||
normalized_weights,
|
||||
percentile,
|
||||
robust_center_scale,
|
||||
score_percentiles,
|
||||
views_per_hour,
|
||||
)
|
||||
|
||||
|
||||
@@ -157,6 +165,72 @@ def test_normalize_cover_and_hosts():
|
||||
assert not is_loopback("192.168.1.2")
|
||||
|
||||
|
||||
def test_content_efficiency_helpers():
|
||||
assert content_hours(3600) == 1.0
|
||||
assert content_hours(7200) == 2.0
|
||||
assert content_hours(0) is None
|
||||
assert content_hours(-1) is None
|
||||
assert content_hours(None) is None
|
||||
assert content_hours(True) is None
|
||||
assert content_hours("bad") is None
|
||||
|
||||
assert views_per_hour(1000, 3600) == 1000.0
|
||||
assert views_per_hour(1000, 7200) == 500.0
|
||||
assert views_per_hour(None, 3600) == 0.0
|
||||
assert views_per_hour(1000, None) is None
|
||||
assert views_per_hour(1000, 0) is None
|
||||
|
||||
metrics = {"like": 30, "coin": 10, "favorite": 20, "view": 999}
|
||||
assert interactions_per_hour(metrics, 3600) == 60.0
|
||||
assert interactions_per_hour(metrics, 7200) == 30.0
|
||||
assert interactions_per_hour(None, 3600) is None
|
||||
assert interactions_per_hour(metrics, None) is None
|
||||
# 单项缺失按 0 处理,不抛异常
|
||||
assert interactions_per_hour({"like": None, "coin": "bad"}, 3600) == 0.0
|
||||
|
||||
|
||||
def test_age_and_maturity():
|
||||
reference = 1_800_000_000
|
||||
assert age_days(reference - 86400 * 10, reference) == 10
|
||||
assert age_days(reference, reference) == 0
|
||||
# 未来时间不产生负年龄
|
||||
assert age_days(reference + 86400, reference) == 0
|
||||
assert age_days(None, reference) is None
|
||||
assert age_days(0, reference) is None
|
||||
assert age_days("bad", reference) is None
|
||||
|
||||
assert is_mature(reference - 86400 * 14, reference, 14) is True
|
||||
assert is_mature(reference - 86400 * 13, reference, 14) is False
|
||||
assert is_mature(None, reference, 14) is False
|
||||
assert is_mature(reference - 86400 * 14, reference, 0) is True
|
||||
|
||||
|
||||
def test_shrinkage_helpers():
|
||||
assert median_of([]) is None
|
||||
assert median_of([None, "bad", True]) is None
|
||||
assert median_of([1, 3, 2]) == 2.0
|
||||
|
||||
# 自适应 κ 取各 UP 总分钟的中位数
|
||||
assert effective_shrink_minutes([10, 30, 20]) == 20.0
|
||||
assert effective_shrink_minutes([0, -5, None]) == 120.0
|
||||
assert effective_shrink_minutes([]) == 120.0
|
||||
assert effective_shrink_minutes([], fallback=60) == 60.0
|
||||
|
||||
# κ 越大越靠近先验;exposure 为 0 时直接返回先验
|
||||
assert abs(eb_rate(0, 0, 0.5, 100) - 0.5) < 1e-12
|
||||
assert eb_rate(100, 100, 1.0, 0) == 1.0
|
||||
assert eb_rate(100, 0, 1.0, 0) is None
|
||||
# (100 + 100*0.5) / (100 + 100) = 0.75
|
||||
assert abs(eb_rate(100, 100, 0.5, 100) - 0.75) < 1e-12
|
||||
assert eb_rate(None, 100, 0.5, 100) is None
|
||||
try:
|
||||
eb_rate(1, 1, 0.5, -1)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("负 prior_strength 必须报错")
|
||||
|
||||
|
||||
def main_test():
|
||||
test_percentile_and_robust_scale()
|
||||
test_normalized_weights()
|
||||
@@ -166,7 +240,10 @@ def main_test():
|
||||
test_interaction_rate_thresholds()
|
||||
test_compute_growth()
|
||||
test_normalize_cover_and_hosts()
|
||||
print("OK: analytics 热度/增长/互动率/封面测试全部通过")
|
||||
test_content_efficiency_helpers()
|
||||
test_age_and_maturity()
|
||||
test_shrinkage_helpers()
|
||||
print("OK: analytics 热度/增长/互动率/时长效率/收缩测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -216,6 +216,25 @@ def test_infos_invalid_attr_and_empty():
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
def test_infos_parses_page_count():
|
||||
payload = {"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO, INVALID_INFO]}
|
||||
client, _ = make_client(QueueTransport([http_json(payload)]))
|
||||
result = client.fav_infos(
|
||||
3700023351, [VALID_INFO["id"], INVALID_INFO["id"]]
|
||||
)
|
||||
assert result.items[VALID_INFO["id"]].pages == 2
|
||||
assert result.items[INVALID_INFO["id"]].pages == 1
|
||||
|
||||
# page 字段缺失或非法时保持 None,不把 0 当分 P 数
|
||||
broken = json.loads(json.dumps(VALID_INFO))
|
||||
broken["id"] = 999
|
||||
broken["page"] = 0
|
||||
client, _ = make_client(QueueTransport([http_json({
|
||||
"code": 0, "message": "0", "ttl": 1, "data": [broken],
|
||||
})]))
|
||||
assert client.fav_infos(3700023351, [999]).items[999].pages is None
|
||||
|
||||
|
||||
def test_infos_unexpected_aid_is_protocol_error():
|
||||
transport = QueueTransport([http_json({
|
||||
"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO],
|
||||
|
||||
+44
-1
@@ -126,12 +126,55 @@ def test_invalid_values():
|
||||
raise AssertionError("非法环境变量必须拒绝")
|
||||
|
||||
|
||||
def test_analytics_defaults_and_validation():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, base_config())
|
||||
cfg = load_config(path, env={})
|
||||
assert cfg.analytics.min_duration_seconds == 600
|
||||
assert cfg.analytics.maturity_days == 14
|
||||
assert cfg.analytics.min_view_for_efficiency == 0
|
||||
assert cfg.analytics.shrink_minutes is None
|
||||
|
||||
data = base_config()
|
||||
data["analytics"] = {
|
||||
"min_duration_seconds": 300,
|
||||
"maturity_days": 7,
|
||||
"min_view_for_efficiency": 100,
|
||||
"shrink_minutes": 90,
|
||||
}
|
||||
write(path, data)
|
||||
cfg = load_config(path, env={})
|
||||
assert cfg.analytics.min_duration_seconds == 300
|
||||
assert cfg.analytics.maturity_days == 7
|
||||
assert cfg.analytics.min_view_for_efficiency == 100
|
||||
assert cfg.analytics.shrink_minutes == 90
|
||||
|
||||
for bad, hint in (
|
||||
({"min_duration_seconds": -1}, "min_duration_seconds"),
|
||||
({"maturity_days": -1}, "maturity_days"),
|
||||
({"min_view_for_efficiency": -1}, "min_view_for_efficiency"),
|
||||
({"shrink_minutes": 0}, "shrink_minutes"),
|
||||
({"shrink_minutes": "auto"}, "shrink_minutes"),
|
||||
):
|
||||
data = base_config()
|
||||
data["analytics"] = bad
|
||||
write(path, data)
|
||||
try:
|
||||
load_config(path, env={})
|
||||
except ConfigError as exc:
|
||||
assert hint in str(exc), (bad, str(exc))
|
||||
else:
|
||||
raise AssertionError(f"非法 analytics 必须拒绝: {bad}")
|
||||
|
||||
|
||||
def main():
|
||||
test_dual_accounts_and_env_override()
|
||||
test_flat_and_missing_account_rejected()
|
||||
test_validation_require_read_auth_and_hot_reload()
|
||||
test_invalid_values()
|
||||
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖与热加载测试全部通过")
|
||||
test_analytics_defaults_and_validation()
|
||||
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖、热加载与 analytics 测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,7 +21,7 @@ from reaction_board.migrations import ( # noqa: E402
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"video", "snapshot", "folder", "folder_resource", "up_blacklist",
|
||||
"up_stat", "candidate", "seen_aid", "notify", "fav_action", "kv",
|
||||
"up_stat", "up_profile", "candidate", "seen_aid", "notify", "fav_action", "kv",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ def test_fresh_and_idempotent():
|
||||
conn = sqlite3.connect(Path(tmp) / "app.db")
|
||||
try:
|
||||
applied = run_migrations(conn)
|
||||
assert applied == [1, 2, 3]
|
||||
assert current_version(conn) == 3
|
||||
assert applied == [1, 2, 3, 4]
|
||||
assert current_version(conn) == 4
|
||||
assert EXPECTED_TABLES <= table_names(conn)
|
||||
assert "schema_version" in table_names(conn)
|
||||
columns = {
|
||||
@@ -49,10 +49,24 @@ def test_fresh_and_idempotent():
|
||||
row[1] for row in conn.execute("PRAGMA table_info(candidate)")
|
||||
}
|
||||
assert {"duration", "play", "like", "favorite", "danmaku", "review"} <= candidate_columns
|
||||
video_columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(video)")
|
||||
}
|
||||
assert {
|
||||
"pages", "duration", "up_follower", "up_follower_at",
|
||||
"up_follower_backfilled",
|
||||
} <= video_columns
|
||||
profile_columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(up_profile)")
|
||||
}
|
||||
assert {
|
||||
"mid", "follower_first", "following_first", "first_captured_at",
|
||||
"follower_latest", "last_refreshed",
|
||||
} <= profile_columns
|
||||
# 再跑一次不应重复应用
|
||||
assert run_migrations(conn) == []
|
||||
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
|
||||
assert row[0] == 3
|
||||
assert row[0] == 4
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -67,15 +81,15 @@ def test_backup_before_upgrade():
|
||||
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
|
||||
conn.commit()
|
||||
|
||||
v4 = Migration(4, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
||||
v5 = Migration(5, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
||||
applied = run_migrations(
|
||||
conn,
|
||||
backup_dir=backup_dir,
|
||||
now=1_800_000_000,
|
||||
migrations=MIGRATIONS + (v4,),
|
||||
migrations=MIGRATIONS + (v5,),
|
||||
)
|
||||
assert applied == [4]
|
||||
backups = list(backup_dir.glob("app-pre-v3-*.db"))
|
||||
assert applied == [5]
|
||||
backups = list(backup_dir.glob("app-pre-v4-*.db"))
|
||||
assert len(backups) == 1
|
||||
check = sqlite3.connect(backups[0])
|
||||
try:
|
||||
|
||||
@@ -18,7 +18,8 @@ F1 = FolderConfig(year=2025, media_id=4451053851, title="2025")
|
||||
F2 = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
||||
|
||||
|
||||
def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None, fav_time=None):
|
||||
def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None, fav_time=None,
|
||||
pages=2):
|
||||
return FavResource(
|
||||
aid=aid,
|
||||
bvid=bvid or f"BV{aid}",
|
||||
@@ -30,6 +31,7 @@ def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None, fav_time=Non
|
||||
cover="http://example/cover.jpg",
|
||||
attr=attr,
|
||||
fav_time=fav_time,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ def test_upsert_and_folder_resource():
|
||||
assert video["folder_id"] == F1.media_id
|
||||
assert video["source"] == f"fav:{F1.media_id}"
|
||||
assert video["first_seen"] == 1_800_000_000
|
||||
assert video["pages"] == 2
|
||||
assert repo.folder_active_aids(F1.media_id) == {1}
|
||||
|
||||
# 幂等:重复导入不新增行、不改 first_seen
|
||||
@@ -293,6 +296,57 @@ def test_pending_snapshot_aids():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_up_follower_snapshot_and_backfill():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1, up_mid=100), F1.media_id, was_added=True)
|
||||
repo.upsert_from_fav(resource(2, up_mid=100), F1.media_id, was_added=True)
|
||||
repo.upsert_from_fav(resource(3, up_mid=200), F1.media_id, was_added=True)
|
||||
missing = {row["up_mid"] for row in repo.up_mids_missing_follower()}
|
||||
assert missing == {100, 200}
|
||||
|
||||
now = 1_800_000_000
|
||||
assert repo.apply_up_follower(
|
||||
mid=100, name="UP", follower=1234, following=5, now=now
|
||||
) == 2
|
||||
first = repo.get_video(1)
|
||||
assert first["up_follower"] == 1234
|
||||
assert first["up_follower_at"] == now
|
||||
assert first["up_follower_backfilled"] == 0
|
||||
profile = repo.get_up_profile(100)
|
||||
assert profile["follower_first"] == 1234
|
||||
assert profile["follower_latest"] == 1234
|
||||
assert {
|
||||
row["up_mid"] for row in repo.up_mids_missing_follower()
|
||||
} == {200}
|
||||
|
||||
# 同一 UP 后来新增的视频记录当下的粉丝快照,不覆盖旧视频
|
||||
repo.upsert_from_fav(resource(4, up_mid=100), F1.media_id, was_added=True)
|
||||
repo.apply_up_follower(
|
||||
mid=100, name="UP", follower=2000, following=6, now=now + 60
|
||||
)
|
||||
assert repo.get_video(4)["up_follower"] == 2000
|
||||
assert repo.get_video(1)["up_follower"] == 1234
|
||||
profile = repo.get_up_profile(100)
|
||||
assert profile["follower_first"] == 1234
|
||||
assert profile["follower_latest"] == 2000
|
||||
|
||||
# 回填只补空值,并标记 backfilled
|
||||
assert repo.apply_up_follower(
|
||||
mid=200, name="UP2", follower=88, now=now + 120, backfilled=True
|
||||
) == 1
|
||||
assert repo.get_video(3)["up_follower"] == 88
|
||||
assert repo.get_video(3)["up_follower_backfilled"] == 1
|
||||
|
||||
coverage = repo.up_follower_coverage()
|
||||
assert coverage == {
|
||||
"total": 4, "covered": 4, "missing": 0, "ups_covered": 2,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_upsert_and_folder_resource()
|
||||
test_invalid_attr_and_missing()
|
||||
@@ -305,6 +359,7 @@ def main():
|
||||
test_primary_folder_is_order_independent()
|
||||
test_dropped_invalid_preserves_original_title()
|
||||
test_pending_snapshot_aids()
|
||||
test_up_follower_snapshot_and_backfill()
|
||||
print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user