目标插件:
plugins/feed_algo(新建,v0.1 设计稿) 编写日期:2026-10-29 状态:方案稿,未动一行代码,等架构与站长双签 依据:docs/插件开发规范.mdv1.7(2736 行,逐节核对)、FLINTHUB_项目理解与速查.md、app/SplitDB/Schema.php实际 DDL
零、结论与红线对齐
一句话:前台是一条 (user_id, pos) 主键连续读,取 10 行;所有会让分数变化的东西都在 CLI 侧算完。
| 红线 | 本方案怎么满足 | 落点 |
|---|---|---|
| 禁止实时全表计算 | 衰减、共现、兴趣传播、重复惩罚、质量分全部离线;前台不做一次乘除 | §3、§4 |
| 单次查询 ≤10ms | 前台 1 条语句、2 张表、1 段主键连续读 + 10 次主键查找;实测 0.034ms(5000 用户 / 30 万行) | §4.4 |
禁止 SELECT * | 全部语句显式列清单 | §4.3 |
| JOIN ≤3 表 | 前台最多 2 张表;离线批处理单语句 ≤3 张 | §4.3 |
| 不查别人业务表 | 只读写 fa_*(插件独立库);核心数据经 Model 离线读一次、落快照 | §2.9 |
| 自包含 | 独立库 plugins/feed_algo/data/feed_algo.sqlite,零外部依赖 | §2 |
新增的、必须写进验收的三条(都是我这次勘查发现的硬约束,不写会踩):
- 核心
tags/thread_tags(business 库)与topic_index(main_index 库)跨库不可 JOIN → 任何「帖 + 标签」的关联都必须在插件库里落一份fa_thread_tag,否则前台必然跨库。 thread_tags的id是INTEGER PRIMARY KEY(自增),ORDER BY id能还原最后一次保存时的标签顺序 → 主 Tag 判定不需要新字段、不需要问用户,直接用第 1 位(默认每帖恒 1 个主槽)。⚠️ 它不是防刷手段:Tag::saveForThread()是先DELETE再重插,作者编辑一次即可重排(详见 §2.4)。- 首页
home/index_default.php只有一个内容钩子announcements_display(第 17 行,公告位),没有列表锚点。要往首页塞 feed,只有controller_view_before换$data['latestThreads']一条路(零核心改动)。
一、为什么不采用「朴素做法」
先把会被否掉的写法摆出来,避免后面重复讨论。
1.1 朴素做法一:前台实时算分
-- ❌ 直接作废
SELECT t.*, SUM(ut.score * POW(0.85, (julianday('now')-julianday(ut.updated_at))/7))
FROM thread_tags tt JOIN tags t ... JOIN fa_user_tag ut ...
ORDER BY 2 DESC LIMIT 10
三个死因:① POW 逐行求值 → 无法用索引排序,必须把全站候选拉进临时 B 表;② julianday('now') 是非确定函数,SQLite 每行都要重算;③ 跨库,物理上就 JOIN 不了。
1.2 朴素做法二:每 7 天 UPDATE ... SET score = score * 0.85 全表
fa_user_tag 一旦到 5k 用户 × 300 tag = 150 万行,单条 UPDATE 就是 150 万次写 + 150 万次 WAL 页刷盘,2 核 VPS 上要跑几分钟,期间持有写锁 → 全站写操作(发帖/回帖/点赞)全部 database is locked。
本方案的解法见 §3.2:衰减是等比变换,等比不改变排序 → 用「纪元号」把 O(行数) 的每周全表写,压成 O(1) 的一行 UPDATE + O(活跃行) 的摊销。
1.3 朴素做法三:共现矩阵全量重算
标签数 B 假设 500 个 → 矩阵 25 万格,每次全量 = 扫全部 thread_tags 再两两组合。论坛帖量小时能跑,但帖量一涨就是 O(N·K²),且每周只为了修几条边就重扫全站。
解法见 §3.3:增量边表 + 只更新受影响的边,全量重算降级为「每周一次的兜底校验」,且带明确的行数阈值开关。
二、数据表设计(DDL)
独立库:plugins/feed_algo/data/feed_algo.sqlite,经 \app\Helpers\Plugin::db('feed_algo') 获取。全部 SQLite 语法,全部 IF NOT EXISTS,全部整数定点(不用 REAL 做排序键,避免浮点比较误差导致分页漂移)。
统一约定:所有 *_c 后缀字段 = 定点整数,单位 1/100(即 score_c = 1234 表示 12.34)。所有时间 = Unix 秒(INTEGER)。
2.1 ① 用户 Tag 兴趣表 fa_user_tag
CREATE TABLE IF NOT EXISTS fa_user_tag (
user_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
score_c INTEGER NOT NULL DEFAULT 0, -- 兴趣分 ×100(含间接注入)
direct_c INTEGER NOT NULL DEFAULT 0, -- 直接行为贡献 ×100(剥离注入,防重复累加)
epoch INTEGER NOT NULL DEFAULT 0, -- 本行材质化时的全局衰减纪元
hit_cnt INTEGER NOT NULL DEFAULT 0, -- 累计有效行为数
read_cnt INTEGER NOT NULL DEFAULT 0, -- 其中「有效阅读」数(≥3s 停留)
last_hit_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, tag_id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_fa_user_tag_top ON fa_user_tag (user_id, score_c DESC);
CREATE INDEX IF NOT EXISTS idx_fa_user_tag_epoch ON fa_user_tag (epoch, score_c);
为什么要有 direct_c:兴趣分是 直接行为 + 20% 关联注入 两段之和。若重算时直接在 score_c 上再注入一次,B tag 会指数膨胀(A→B→C 链条上每次重算都叠一层)。做法固定为:
score_c = decay(direct_c, epoch) + inject(neighbors) -- 每次重算都从 direct_c 重建
2.2 ② Tag 关联图谱表 fa_tag_edge
CREATE TABLE IF NOT EXISTS fa_tag_edge (
a_tag INTEGER NOT NULL, -- 恒有 a_tag < b_tag(无向边去重)
b_tag INTEGER NOT NULL,
co_cnt INTEGER NOT NULL DEFAULT 0, -- 共现帖数 c_ab
ca_cnt INTEGER NOT NULL DEFAULT 0, -- a 的全站帖数(冗余,免二次聚合)
cb_cnt INTEGER NOT NULL DEFAULT 0,
w_c INTEGER NOT NULL DEFAULT 0, -- 边权重 ×100(min(3,√lift)×conf),见 §3.3
lift_c INTEGER NOT NULL DEFAULT 0, -- 原始 lift ×1000(诊断 / 切换 metric 用)
updated_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (a_tag, b_tag)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_fa_tag_edge_b ON fa_tag_edge (b_tag, w_c DESC);
-- 增量边:新帖/改标签时只写这里,日任务合并
CREATE TABLE IF NOT EXISTS fa_tag_edge_delta (
a_tag INTEGER NOT NULL,
b_tag INTEGER NOT NULL,
d_co INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (a_tag, b_tag)
) WITHOUT ROWID;
为什么 ca_cnt/cb_cnt 冗余:PMI 需要 $c_a$、$c_b$。若不冗余,每次重算 lift 都要回核心库数一遍每个 tag 的帖数(500 个 tag = 500 次 COUNT)。冗余后日任务只做一次聚合。
2.3 ③ 用户已读 / 负反馈表 fa_user_seen
CREATE TABLE IF NOT EXISTS fa_user_seen (
user_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
series_id INTEGER NOT NULL DEFAULT 0, -- 同系列指纹
first_seen_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL DEFAULT 0,
seen_cnt INTEGER NOT NULL DEFAULT 0, -- 曝光次数(含未点击)
click_cnt INTEGER NOT NULL DEFAULT 0,
dwell_ms INTEGER NOT NULL DEFAULT 0, -- 累计有效停留
penalty_c INTEGER NOT NULL DEFAULT 100,-- 100=无惩罚 / 20=×0.2 / 90=×0.9
feedback INTEGER NOT NULL DEFAULT 0, -- 0无 1秒退 2显式不感兴趣 3屏蔽作者
updated_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, thread_id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_fa_user_seen_series ON fa_user_seen (user_id, series_id);
CREATE INDEX IF NOT EXISTS idx_fa_user_seen_gc ON fa_user_seen (last_seen_at);
feedback = 3 的落法:存 user_id + thread_id 不够(要屏蔽作者),所以另开一行 thread_id = 0 的哨兵记录承载作者维度:
-- 同一张表复用,thread_id=0 表示「作者维度」负反馈
-- series_id 字段此时存 author_id(注释写清楚,不留坑)
这是有意的省表:多一张
fa_user_block会多一条每页必查的路径。但必须在Plugin.php里用常量SEEN_AUTHOR_SENTINEL = 0收口,禁止裸写0。
2.4 ④ 主/辅 Tag 关联表 fa_thread_tag
CREATE TABLE IF NOT EXISTS fa_thread_tag (
thread_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
role INTEGER NOT NULL DEFAULT 0, -- 0=主 Tag(参与打分)1=辅 Tag(仅召回)
weight_c INTEGER NOT NULL DEFAULT 100, -- 该 tag 在此帖中的权重 ×1
ord INTEGER NOT NULL DEFAULT 0, -- 作者填写顺序(来自 thread_tags.id 的排名)
create_time INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (thread_id, tag_id)
) WITHOUT ROWID;
-- 召回用:按 tag 找最近的主 Tag 帖
CREATE INDEX IF NOT EXISTS idx_fa_thread_tag_main ON fa_thread_tag (tag_id, role, create_time DESC);
-- 反查用:某帖的 tag 构成(离线算共现、算分)
CREATE INDEX IF NOT EXISTS idx_fa_thread_tag_thread ON fa_thread_tag (thread_id, role, ord);
主/辅判定规则(离线,三级优先):
| 优先级 | 规则 | 来源 |
|---|---|---|
| 1 | 后台「主 Tag 白名单」命中的 tag → 主(可额外提升 1 个) | 插件 settings(feed_algo_main_tags) |
| 2 | 作者填写顺序第 1 位 → 主 | SELECT tag_id FROM thread_tags WHERE thread_id=? ORDER BY id ASC LIMIT 1 |
| 3 | 其余全部 → 辅 | 同上,第 2 位之后 |
默认 N=1,不是 2。 这是被真实数据打回来的:
| 每帖标签数 | 帖数 | 占比 |
|---|---|---|
| 1 个 | 146 | 51.8% |
| 2 个 | 68 | 24.1% |
| 3 个 | 44 | 15.6% |
| 4 个 | 24 | 8.5% |
(实测 data/meta/business.sqlite:510 条关联 / 282 个有标签帖 / 101 个标签)
若按 N=2:
- 主 Tag 覆盖 82.0% 的全部关联(418/510)→ 「辅 Tag 仅检索」这条规则几乎不存在;
- 4 标签帖拿 2 个主槽、1 标签帖拿 1 个主槽 → 挂更多标签直接换来更多召回额度,正是要堵的口子。
按 N=1:主 Tag 覆盖 55.3%(282/510),且每帖主槽恒为 1 个,与它挂 1 个还是 4 个标签无关。这才是「主/辅」该有的硬边界。
必须说清的一件事:顺序不等于作者意图,也不能防「作者自己刷」。
thread_tags.id是INTEGER PRIMARY KEY(app/SplitDB/Schema.php:561),Tag::saveForThread()按输入顺序逐条INSERT OR IGNORE,所以id顺序 = 最后一次保存时表单里的顺序;- 但
saveForThread()开头就是DELETE FROM thread_tags WHERE thread_id=?再重插 → 作者编辑一次就能重排; - 所以「第 1 位」只是廉价的确定性平局判据(避免随机取一个 tag 当主),不是防刷手段。
真正防「作者刷自己」的是 §5.2 的单日单 Tag 截断与 §5.3 的 $\sigma(u)$,不是主/辅划分。主/辅挡的是别人:脚本给目标帖堆标签、或给别人的帖刷赞时,最多只能影响到 1 个主槽。
⚠️ 核心
Tag::saveForThread()还有一道array_slice($names, 0, 10)的上限 —— 单帖最多 10 个标签。实测最多只用到 4 个,所以「堆标签」在当前站点规模下还不是主要威胁,但规则要先立对。
2.5 ⑤ 候选池 fa_feed_pool(前台唯一入口)
CREATE TABLE IF NOT EXISTS fa_feed_pool (
user_id INTEGER NOT NULL,
pos INTEGER NOT NULL, -- ★ 已排好的展示位次(0-based,主键第二列)
thread_id INTEGER NOT NULL,
ord_c INTEGER NOT NULL DEFAULT 0, -- 合成得分 ×100(诊断 + 重排依据)
aff_c INTEGER NOT NULL DEFAULT 0, -- 兴趣分(诊断用)
hot_c INTEGER NOT NULL DEFAULT 0, -- 热度分(诊断用)
qual_c INTEGER NOT NULL DEFAULT 0, -- 质量分(诊断用)
rep_c INTEGER NOT NULL DEFAULT 100, -- 防重复系数 ×100
main_tag INTEGER NOT NULL DEFAULT 0, -- 命中哪个主 tag
src INTEGER NOT NULL DEFAULT 0, -- 0 兴趣召回 / 1 关联扩展 / 2 高热兜底位
build_id INTEGER NOT NULL DEFAULT 0, -- 批次号(便于整批失效)
expires_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, pos)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_fa_feed_pool_exp ON fa_feed_pool (expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS uk_fa_feed_pool_t ON fa_feed_pool (user_id, thread_id);
关键设计:pos 是落库的位次整数,不是查询里算的。
WITHOUT ROWID 表的 PRIMARY KEY (user_id, pos) 就是 B 树聚簇索引本身。于是「取某用户第 $p$ 页的 10 条」= user_id 定值 + pos 连续区间,一次连续叶扫描,天然有序、零排序步骤。
这一版是实测改出来的,不是设计出来的(过程见 §4.3 的"两次推翻")。原设计用 UNION ALL 拼「9 条自然 + 1 条兜底」,EXPLAIN QUERY PLAN 实测会出 USE TEMP B-TREE FOR ORDER BY —— 因为 UNION ALL 之后外层的 ORDER BY 作用在派生表上,已经没有可用索引了。
池容量上限:每用户 60 行(对应 6 页)。5k 用户 × 60 = 30 万行,行长约 74B → 约 22MB。
2.6 ⑥ 兜底位在离线就"坐进位次"
「每 10 条插 1 条全局高热但未交互」的实现分两步:
离线(build):先把该用户的自然推荐按 ord_c 降序排好,然后每隔 9 条插入 1 条兜底候选,整体重排 pos = 0,1,2,…。于是 pos ∈ {9,19,29,39,49} 恒为 src=2。
前台:WHERE user_id=? AND pos >= :start AND pos < :end,不判断谁是兜底、不查 fa_user_seen、不做 NOT EXISTS。
边界用半开区间
[start, end),不用BETWEEN(闭区间)。BETWEEN 0 AND 10是 11 行,第 10 行会与下一页第 0 行重叠 —— 这种差一错误在 feed 里表现为「翻页时同一条重复出现」,且不报错。
这样"每 10 条插 1 条"这条业务规则在离线就被压成了位次,前台完全不需要知道它的存在。这是整个设计里性价比最高的一个决定 —— 一条规则、零前台成本。
代价(必须写清):兜底位固定在第 10、20、30… 位。如果自然推荐只剩 3 条,第 10 位仍是兜底,中间 4~9 位空着。此时由 §4.6 的 L1 分支把空位压缩掉(PHP 里过滤空行并按实际条数渲染),不做 SQL 层"填满"。
2.7 ⑦ 展示快照 fa_thread_snapshot
CREATE TABLE IF NOT EXISTS fa_thread_snapshot (
thread_id INTEGER PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
author_id INTEGER NOT NULL DEFAULT 0,
author_name TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
category_id INTEGER NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
series_id INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0, -- 0 可用 / 1 已删或不可见
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_fa_thread_snapshot_author ON fa_thread_snapshot (author_id, create_time);
为什么冗余展示字段:前台若调 Thread::getByIds() + decorateList(),那一次要打 main_index 一次 + business 两次(用户、分类),跨 3 个库文件、3 次连接复用、还带 attachExcerpts 的读文件开销。首页 10ms 预算直接爆掉。
冗余代价:标题最多旧 6 小时(池子 TTL)。可接受,因为 feed 本来就是「推荐」而非「最新」;且 status 由 cron 每轮回填,删帖不会露出。
2.8 ⑧ 行为流水 fa_behavior + 状态表 fa_state + 脏队列 fa_dirty_user
CREATE TABLE IF NOT EXISTS fa_behavior (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
action INTEGER NOT NULL, -- 1点击 2有效停留 3赞 4评论 5秒退
weight_c INTEGER NOT NULL, -- 30/50/100/150/-40
dwell_ms INTEGER NOT NULL DEFAULT 0,
fp TEXT NOT NULL DEFAULT '', -- 幂等指纹(16 hex)
day INTEGER NOT NULL DEFAULT 0, -- YYYYMMDD 整数
create_time INTEGER NOT NULL,
applied INTEGER NOT NULL DEFAULT 0 -- 0待聚合 1已聚合 2影子(不参与)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_fa_behavior_fp ON fa_behavior (user_id, thread_id, action, fp);
CREATE INDEX IF NOT EXISTS idx_fa_behavior_pending ON fa_behavior (applied, id);
CREATE INDEX IF NOT EXISTS idx_fa_behavior_user_day ON fa_behavior (user_id, day);
CREATE TABLE IF NOT EXISTS fa_state (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
);
-- k: epoch / epoch_at / build_cursor / ingest_id / edge_day / schema_ver / last_build_at
CREATE TABLE IF NOT EXISTS fa_dirty_user (
user_id INTEGER PRIMARY KEY,
reason INTEGER NOT NULL DEFAULT 0, -- 1 新行为 2 新帖 3 图谱变更
enqueued_at INTEGER NOT NULL DEFAULT 0
) WITHOUT ROWID;
fp 幂等指纹:substr(sha256(user_id|thread_id|action|ua_hash|ip_/16|salt), 0, 16)。加 salt 是为了外部无法构造。唯一索引直接把「同一用户同一帖同一动作的重复埋点」在写库那一刻消掉——这比在后面聚合时去重省一个数量级。
2.9 表清单汇总与容量估算
| # | 表 | 用途 | 5k 用户估算 |
|---|---|---|---|
| ① | fa_user_tag | 用户 Tag 兴趣 | 平均 40 万行 / 20MB;封顶 150 万行 / 75MB |
| ② | fa_tag_edge | 关联图谱 | ~5 万行 / 3MB($c_{ab}\ge2$ 且 $\text{lift}\ge1.5$ 剪枝后) |
| ② | fa_tag_edge_delta | 增量边 | <2000 行 / 100KB |
| ③ | fa_user_seen | 已读/负反馈 | ~50 万行 / 30MB(保留 180 天) |
| ④ | fa_thread_tag | 主/辅 Tag | 帖数 × 1.81 ≈ 4 万行 / 2MB |
| ⑤ | fa_feed_pool | 候选池 | 30 万行 / 22MB |
| ⑦ | fa_thread_snapshot | 展示快照 | 2 万行 / 5MB |
| ⑧ | fa_behavior | 行为流水 | ~100 万行 / 45MB(保留 14 天) |
「每用户封顶 300 tag」是容量上限,不是预期值。实测当前站点的标签基数只有 101 个(§3.3),所以单用户实际能命中的 tag 数受全站标签总数约束,远不到 300。按 5k 用户 × 平均 80 tag ≈ 40 万行 / 20MB 作为常态,150 万行是"标签基数涨到 300 且每个用户都全命中"的理论最坏值。
合计约 150MB,单文件 SQLite,WAL 模式(DBFactory 已统一设 busy_timeout=5000 / synchronous=NORMAL / cache_size=-20000)。插件不得覆盖这些 PRAGMA(规范红线 7)。
三、离线计算流程(Cron)
3.1 调度总表
全部走宝塔计划任务 · CLI 模式。理由:核心 cron_trigger.php 的消费者只认 rebuild_search/stats,插件任务塞进去会被 error_log 后标记 done(规范 §11.4-26 明写)。所以插件自带 CLI 入口:
php /www/wwwroot/站点/plugins/feed_algo/cli/feed_cron.php --stage=all
| Stage | 频率 | 单次预算 | 做什么 |
|---|---|---|---|
ingest | 每 5 分钟 | ≤1.5s | spool 文件 → fa_behavior 批量入库 |
build | 每 5 分钟 | ≤6s | 重建「脏用户」候选池,轮转 200 用户/轮 |
edge | 每日 03:20 | ≤8s | 合并增量边 + 重算边权重 $w$(§3.3)+ 剪枝 |
materialize | 每小时 :07 | ≤4s | 刷新 fa_thread_snapshot(新帖/改标题) |
decay | 每周日 04:00 | ≤20s | 纪元 +1,休眠行分批补齐,低分裁剪 |
gc | 每日 04:40 | ≤3s | 清过期池、清 14 天前流水、清 180 天前 seen |
并发与重入:flock(LOCK_EX|LOCK_NB) 锁 data/lock/feed_cron.lock,拿不到就打印「上一轮仍在跑」并 exit(0)(退出码 0,避免宝塔把正常的跳过记成失败告警)。
时间预算:每个 stage 内部带 deadline,到点保存游标后主动退出,下轮从游标继续。绝不出现「跑一半被 max_execution_time 杀掉、游标没存、下一轮从头再来」的死循环。
3.2 时间衰减:把「每周全表 ×0.85」变成「一行 UPDATE」
这是整个方案里最需要解释的一步。
数学事实:设衰减因子 $\gamma = 0.85$,每 7 天一个纪元。行 $i$ 在纪元 $e_i$ 时被写入值 $s_i$,到纪元 $e$ 的真实值:
$$I_i(e) = s_i \cdot \gamma^{,e - e_i}$$
关键不变量:对同一个用户的所有行,若 $e_i$ 相同,则乘以同一个常数 $\gamma^{e-e_i}$ 不改变任何两行的相对大小 → ORDER BY score_c DESC 的结果与未衰减时完全一致。
所以:
衰减不改变排序,只改变绝对量级。 而排序只需要相对序。
于是每周只做三件事:
| 步骤 | 操作 | 成本 |
|---|---|---|
| ① | fa_state.epoch += 1(1 行 UPDATE) | O(1) |
| ② | 休眠行补齐:score_c = round(score_c * 0.85^Δ)、epoch = e | O(休眠行),分批 |
| ③ | 低分裁剪:score_c < 20 的行直接 DELETE | O(被裁行) |
为什么还必须做 ②:e_i 不一致时相对序会错——活跃行刚被折到当前纪元,休眠行还停在旧纪元、值是「没衰减过的」,会虚高。所以 ② 是正确性要求,不是优化。
② 的批量化写法(每批 2000 行,单批约 20ms 纯 CPU):
-- 取一批(走 idx_fa_user_tag_epoch,首列就是 epoch)
SELECT user_id, tag_id, score_c, epoch
FROM fa_user_tag
WHERE epoch < :e
ORDER BY epoch ASC
LIMIT 2000;
-- 逐行在 PHP 里算 pow(0.85, e - epoch) 后写回(不用 SQL 的 pow,避免逐行函数调用)
算一下最坏情况(§2.9 的封顶值 150 万行):150 万 / 2000 = 750 批 × 20ms = 15 秒/周。常态(平均 40 万行)是 200 批 ≈ 4 秒/周,加上裁剪(低分行不进 ②)实际通常 2~3 秒。
对比朴素做法:同为 150 万行,单条 UPDATE 在 2 核 VPS 上要 3~8 分钟并全程持有写锁 → 期间全站发帖/回帖/点赞全部 database is locked。从分钟级锁表降到 15 秒分批、每批 20ms 的毫秒级锁。
为什么不用 epoch 做查询期补偿(比如前台 ORDER BY score_c * factor):那就变成「前台算衰减」了,直接违反第一条红线,而且会让 ORDER BY 用不上索引。这一条要写进代码注释,防以后有人「优化」。
3.3 Tag 共现图谱:增量为主,全量为辅
问题:全量重算 = 扫所有 thread_tags、每帖取 tag 集合、两两组合、累加。帖量 N、每帖 tag 数 K → O(N·K²)。
做法:把「共现」拆成「增量收集」与「批量归并」两段。
① 增量收集(免扫描):新帖发布/编辑标签时,只往 fa_tag_edge_delta 写这一个帖贡献的边:
INSERT INTO fa_tag_edge_delta (a_tag, b_tag, d_co) VALUES (:a, :b, 1)
ON CONFLICT(a_tag, b_tag) DO UPDATE SET d_co = d_co + 1;
边数上限由两件事决定:核心 Tag::saveForThread() 的 array_slice($names, 0, 10) 把单帖标签卡在 10 个,所以最坏 $C(10,2)=45$ 条边;实测当前站点每帖最多 4 个标签(§2.4 表),实际是 $C(4,2)=6$ 条。成本与全站规模无关,只与单帖标签数有关。
② 每日归并(03:20):一条 UPSERT 把 delta 灌进主表,再只重算被碰到的那几条边的权重:
-- 归并(单语句,2 张表)
INSERT INTO fa_tag_edge (a_tag, b_tag, co_cnt, ca_cnt, cb_cnt, w_c, updated_at)
SELECT d.a_tag, d.b_tag, d.d_co, 0, 0, 0, :now
FROM fa_tag_edge_delta d
WHERE d.d_co > 0
ON CONFLICT(a_tag, b_tag) DO UPDATE SET
co_cnt = co_cnt + excluded.co_cnt,
updated_at = excluded.updated_at;
边权重不用 PMI,用「有界提升度 × 置信度」。 这一条是被真实数据打回来的,下面把过程写清楚,因为它推翻了一个看起来更"学术"的公式。
先看当前库的真实规模(实测 data/meta/business.sqlite):
| 指标 | 实测值 |
|---|---|
thread_tags 总行数 | 510 |
| 标签数 | 101 |
| 有标签的帖数 $N$ | 282 |
| 共现对(唯一) | 221 |
| $\text{co}\ge2$ 的边 | 49 |
| $\text{co}\ge3$ 的边 | 22 |
| 最大共现 | 13 |
| 单标签帖占比 | 51.8% |
PMI 在这个规模下失效。按 $\text{PMI}=\log_2 \frac{c_{ab}N}{c_ac_b}$ 实算,排名最高的 9 条边全是 $c_{ab}=1,\ c_a=1,\ c_b=1$ 的孤对,PMI 一律虚高到 8.14:
46-48 cab=1 ca=1 cb=1 pmi=8.14
50-51 cab=1 ca=1 cb=1 pmi=8.14
57-58 cab=1 ca=1 cb=1 pmi=8.14
...
原因很直白:只出现一次的标签,其"期望共现"是 $1\times1/282\approx0.0035$,任何一次共现都是 282 倍超额。小样本下 PMI 的上界由样本量决定,不由关联强度决定。
同理,$\text{lift}=\frac{c_{ab}}{c_ac_b/N}$ 会直接给出 282.00,$\text{conf}=c_{ab}/\min(c_a,c_b)$ 会给出 1.00 —— 三个经典指标在孤对上一齐失真。
最终采用的三段式:
$$\text{lift}(a,b)=\frac{c_{ab},N}{c_a,c_b},\qquad \text{conf}(a,b)=\frac{c_{ab}}{\min(c_a,c_b)}$$
$$w(a,b)=\min!\big(3.0,\ \sqrt{\text{lift}}\big)\cdot \text{conf}$$
- 保留条件:$c_{ab}\ge 2$ 且 $\text{lift}\ge 1.5$。实测保留 48 / 221 条边,覆盖率 40/101 个标签(39.6%),平均度 2.4。
min(3.0, √lift):√lift把 282 这种极端值压到 16.8,再截断到 3.0,防单条边独占注入权重。若直接用lift归一化,$19\text{-}20$(lift=141)会吃掉几乎 100% 的注入额度,用户的整个关联兴趣只由一条边决定。- 乘
conf:conf衡量"a 出现时 b 也出现的概率",是关联的方向性强弱,有界于 $[0,1]$。
实测区分度:48 条边的 $w$ 落在 0.28 ~ 3.00,有 31 个不同档位(65% 区分度)。若把 conf 直接当权重,48 条边里有大量并列 1.00,区分度仅 1 档 —— 等于所有边等权,传播就退化成"平均撒胡椒面"。
⚠️ 这套参数是当前数据规模的解,不是通用解。 规模涨到 $c_{ab}$ 中位数 $\ge 5$ 时,PMI 会重新变得可靠。所以后台留一个开关
feed_edge_metric(lift_conf/pmi),默认lift_conf,并在切换时打印当前 $c_{ab}$ 中位数供判断。不要静默换公式。
③ 全量重算(兜底,非日常):仅当 fa_state.edge_day 距今 > 30 天,或后台手动点「重建图谱」时执行。带硬开关:
若 fa_tag_edge 行数 > 200000 → 拒绝全量,写 error_log 并回退到「只归并增量」
理由:全量重算在 2 核机器上是唯一可能失控的操作,必须有一道「不许跑」的闸,而不是「小心点跑」。
3.4 兴趣传播(A Tag 高分 → 关联 B Tag 注入 20%)
只对「脏用户」重算,一次重算的顺序:
1. 取该用户 Top-M(默认 M=20)主 Tag,按 score_c DESC
2. 对每个 a ∈ TopM:查 fa_tag_edge 取 w_c 最大的 top-10 邻居(走 idx_fa_tag_edge_b)
3. 归一化:w_b = w(a,b) / Σ w(a,·) ← 用 §3.3 的 w,不是原始 lift
4. 注入:score_c[b] += round(0.2 * decayed(direct_c[a]) * w_b)
5. 同时 score_c[b] 的基底从 direct_c[b] 重建(不是累加)
单用户成本:20 × 10 = 200 次查边 + 600 次算术 ≈ 0.4ms。200 用户/轮 = 80ms。完全在预算内。
为什么第 3 步必须用 w 而不是 lift:§3.3 实测过,lift 在 c_ab=1 的孤对上能到 282,而 w 被截断在 3.0。若用原始 lift 归一化,一条孤对边就会吃掉该 tag 的绝大部分注入额度 —— 用户的整个关联兴趣被一条"只共同出现过一次"的边决定。截断的代价是区分度变低(48 条边落在 0.28~3.00,31 个档位),收益是不会被单条噪声边绑架。
「不递归」是硬约束:注入只做一层。若允许 A→B→C,则任意两个 tag 在 3 跳内连通,弱相关标签会互相抬轿,推荐质量崩坏(且计算量指数爆炸)。这一条要写进代码注释。
3.5 候选召回与打分(离线,落 ord_c 与 pos)
三个来源,合并去重后打分:
| src | 来源 | 取法 |
|---|---|---|
| 0 | 兴趣召回 | Top-M 主 Tag → fa_thread_tag(role=0) → 最近 30 天帖,每 tag 取 30 条 |
| 1 | 关联扩展 | Top-M 的邻居 tag → 同上,每 tag 取 10 条(这里允许辅 tag 命中,但打分只用主 tag) |
| 2 | 兜底位 | fa_thread_snapshot 按热度取 30 条 → 剔掉该用户 fa_user_seen 里有的 → 留 10 条 |
打分公式(全部离线,结果乘 100 取整落 ord_c):
$$\hat a = \frac{\sum_{b \in M(t)} I_u(b)}{\max_{t'} \sum_{b \in M(t')} I_u(b)} \quad\text{(用户内归一化,0..1)}$$
$$\hat h = \frac{\ln(1+v) + 2\ln(1+r) + 3\ln(1,l)}{1 + (\Delta t / 48\text{h})^{1.5}} \quad\text{(HN 式热度,}\Delta t\text{ = 发帖至今)}$$
$$\hat q = \mathrm{clamp}!\left(\frac{r}{v}\cdot k,\ 0,\ 1\right) \quad\text{(互动率,}k\text{ 为标定常数)}$$
$$S(u,t) = 100 \cdot \big[,0.62,\hat a + 0.23,\hat h + 0.10,\hat q + 0.05,\text{fresh},\big] \cdot \rho(u,t) \cdot \sigma(u)$$
第二步:把分数折成展示位次 pos。
这一步是「每 10 条插 1 条兜底」唯一落地的地方:
1. 自然候选(src∈{0,1})按 ord_c 降序,取前 50
2. 兜底候选(src=2)按 ord_c 降序,取前 10
3. 交错:遍历自然序列,每填满 9 个就插 1 个兜底
4. 依次赋 pos = 0,1,2,…, 最多 60 个
交错后 pos ∈ {9,19,29,39,49} 恒为 src=2。第 10、20、30… 位(1-based)必然是兜底,且在离线和前台都不需要再判断一次。
为什么交错要放在写库前,而不是查询里:查询里做「每 10 条插 1 条」只能靠 UNION ALL + 窗口函数 + 外层排序,实测直接退化成 TEMP B-TREE(§4.2 反例二)。离线交错则是一次纯 PHP 数组操作,成本 O(60)。
其中:
- $\rho$ = 防重复系数。刚看过(<14 天)→
rep_c = 20;14 天后 →rep_c = 90;从未看过 →100。 - 同系列新帖(
series_id相同但thread_id不同)→ $\rho$ 强制为 100,不继承惩罚。 - $\sigma$ = 反刷可信度(§5.3),0.05 ~ 1.00。
series_id 定义:crc32(author_id || ':' || main_tag_id || ':' || 归一化标题前 12 字)。同作者 + 同主 Tag + 同标题前缀 → 同系列。这一条只用于「不继承惩罚」,不用于其他判定。
为什么权重 62/23/10/5:兴趣占大头(这是「个性化」的本义),热度次之(防止信息茧房彻底锁死),质量与新鲜度只做微调。三个数都进后台可调,但默认值写死在代码里,避免「设置项为空 → 权重全 0 → 全站 feed 变成随机」。
3.6 一轮 build 的完整时间预算
| 步骤 | 200 用户耗时 |
|---|---|
取脏用户(1 次查询,走 fa_dirty_user 主键) | 0.3ms |
每人召回(src=0 20 tag × 20 条 + src=1 10 邻居 × 8 条 ≈ 480 行,去重后约 350 行) | 1.1ms × 200 = 220ms |
| 每人打分(纯 PHP 算术,350 次) | 0.5ms × 200 = 100ms |
每人交错排 pos(60 个元素数组操作) | 0.02ms × 200 = 4ms |
批量写池(单事务 200 用户 × 60 行 = 12000 行 INSERT) | 约 180ms |
| 合计 | ≈ 505ms |
预算给的 6s 有约 12 倍余量,用来吸收「某用户 tag 特别多」「某用户池子要重排 6 页」的长尾。
写池走单事务:beginTransaction() → 逐用户 DELETE(按 user_id,走主键前缀)+ 批量 INSERT → commit()。注意规范 §10.2:catch 内必须 inTransaction() 判断再 rollBack(),否则二次抛异常逃逸成 500。
⚠️
DELETE FROM fa_feed_pool WHERE user_id = ?在WITHOUT ROWID表上是主键前缀删除,快;但不能写成DELETE ... WHERE expires_at < ?——那是全表扫描,会把 30 万行全过一遍并持有写锁。过期清理只走gcstage 的按user_id分批删。
四、前台组装 SQL
4.1 前台允许做什么、不允许做什么
| 允许 | 不允许 |
|---|---|
| 1 条语句读 10 行 | 任何 POW / EXP / julianday('now') |
ORDER BY 一个已落库的整数列 | ORDER BY 表达式 |
| PHP 里对 10 行做多样性二次排序(同作者去重) | 对 10 行重算分数 |
池子为空时降级为 src=2 兜底 10 条 | 池子为空时回退到核心 latestThreads(会让「池子坏了」与「新用户」表现同一现象,掩盖故障) |
4.2 排序键必须落库(实测两次推翻后的结论)
SQLite 的 ORDER BY 只有在排序键是索引列本身时才能免排序。实测两个反例:
反例一:ORDER BY 算术表达式
SELECT thread_id FROM fa_feed_pool
WHERE user_id = 7 ORDER BY ord_c * 2 + 100 DESC LIMIT 10;
实测计划(SQLite 3.39.2):
SEARCH fa_feed_pool USING PRIMARY KEY (user_id=?)
USE TEMP B-TREE FOR ORDER BY
→ 主键先把该用户全部 60 行取出来,再建临时 B 树排序。
反例二:UNION ALL 拼「9 条自然 + 1 条兜底」再外层排序(我最初的设计)
SELECT ... FROM (SELECT ... LIMIT 9 UNION ALL SELECT ... LIMIT 1) AS o
JOIN fa_thread_snapshot s ON ...
ORDER BY o.slot_kind ASC, o.ord_c DESC;
实测计划里两支各带一个 USE TEMP B-TREE FOR ORDER BY,且额外多两次 MATERIALIZE:
MERGE (UNION ALL)
LEFT
MATERIALIZE organic
SEARCH p USING INDEX idx_fa_feed_pool_rank (user_id=?)
SCAN organic
SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
USE TEMP B-TREE FOR ORDER BY ← ❌
RIGHT
MATERIALIZE spare
...
USE TEMP B-TREE FOR ORDER BY ← ❌
这两次都是我自己先写出来、再被
EXPLAIN打回的。 保留在文档里,是因为「看起来对、计划不对」的 SQL 在这类项目里最容易蒙混过关 —— 数据量小的时候它甚至更快。
最终形态:把展示位次 pos 落库,前台只按 pos 区间连续读。
4.3 组装语句(首页第 1 页:1 段连续读 + 10 次主键查找)
SELECT p.thread_id,
p.ord_c,
p.src,
s.title,
s.author_id,
s.author_name,
s.avatar,
s.category_id,
s.reply_count,
s.view_count,
s.series_id
FROM fa_feed_pool AS p
JOIN fa_thread_snapshot AS s ON s.thread_id = p.thread_id
WHERE p.user_id = :uid
AND p.pos >= :start
AND p.pos < :end
AND p.expires_at >= :now
AND s.status = 0
ORDER BY p.pos ASC
LIMIT 10;
:start / :end = 页起始位次与页尾,首页为 0 / 10。
实测执行计划(5000 用户 / 30 万池行 / 6 万快照行):
SEARCH p USING PRIMARY KEY (user_id=? AND pos>?)
SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
无 SCAN、无 TEMP B-TREE、无 MATERIALIZE。 WITHOUT ROWID 表的 PRIMARY KEY (user_id, pos) 即聚簇 B 树本身,(user_id=? AND pos>?) 是一次叶节点连续扫描。
实测耗时:500 次取 10 行(含 fetchAll)平均 0.034ms。
逐条对照红线:
| 项 | 状态 |
|---|---|
SELECT * | 无,11 个列全显式 |
| JOIN 表数 | 2(fa_feed_pool + fa_thread_snapshot),无派生表 |
| 实时衰减 / 共现 / 矩阵 | 无 |
NOT EXISTS / 相关子查询 | 无(兜底判重与"看过"判定均已离线) |
ORDER BY | 单列、主键内列,天然有序 |
| 参数绑定 | :uid / :start / :end / :now 全部绑定 |
验收断言(进 CI,不靠肉眼):
EXPLAIN QUERY PLAN 的结果里不得出现子串 "SCAN fa_feed_pool"
且不得出现 "TEMP B-TREE"
且不得出现 "MATERIALIZE"
任一命中即判定失败。调索引或调表结构,不许调 SQL 去凑。
4.4 10ms 预算分解(实测值)
| 环节 | 实测 / 估算 |
|---|---|
fa_feed_pool 主键连续读 10 行 | 0.012ms |
fa_thread_snapshot 10 次主键查找 | 0.018ms |
| PDO 取行 + 组数组(10 行) | 0.004ms |
| 查询侧合计(实测 0.034ms) | 0.034ms |
| 模板渲染(10 行卡片) | 不计入查询预算 |
余量 294 倍。 这个余量的意义不是「还能往里加东西」,而是:
- 2 核 VPS 被
cli/archive.php或备份任务抢 CPU 时仍然达标; - 池子从 60 行/人 扩到 300 行/人 时仍然达标(连续读,与总行数无关);
- 用户量从 5k 涨到 5 万时仍然达标(只跟单用户池子大小有关,与总规模无关)。
4.5 翻页(keyset,且天然就是 keyset)
因为 pos 本身就是 0..59 的稠密位次,翻页不需要"上一页最后一条的排序值"这种游标,直接用算数:
-- 第 n 页(n 从 1 开始)
:start = (:page - 1) * 10
:end = :start + 10
SELECT p.thread_id, p.ord_c, p.src, s.title, s.author_id, s.author_name,
s.avatar, s.category_id, s.reply_count, s.view_count, s.series_id
FROM fa_feed_pool AS p
JOIN fa_thread_snapshot AS s ON s.thread_id = p.thread_id
WHERE p.user_id = :uid
AND p.pos >= :start
AND p.pos < :end
AND p.expires_at >= :now
AND s.status = 0
ORDER BY p.pos ASC;
这比传统的「ord_c < 游标值」更稳:ord_c 是得分,同分会并列,游标法必须补 thread_id 做二级键,还要处理"并列跨页"。pos 是唯一的稠密整数,不存在并列,不存在跳页重复,也不存在 OFFSET 深翻。
代价是:池子重建(build)后 pos 会整体变化 → 用户停留在第 3 页时刷新,可能看到与上一页重叠的帖。这个可以接受(feed 不是精确分页的场景),但要在代码里明确:build 完成后递增 build_id,前端若带 build_id 且与当前不符,则回到第 1 页而不是继续第 3 页。
池子只有 6 页(60 行)。第 7 页起返回空 → 前端显示"没有更多了",不去核心 latestThreads 续接(否则会出现「个性化 6 页 + 突然变最新帖」的割裂)。
4.6 降级链(四级,全部显式)
| 级别 | 条件 | 行为 | 查询数 |
|---|---|---|---|
| L0 | pos 区间内 ≥10 行且 status=0 | 正常 10 条 | 1 |
| L1 | 区间内不足 10 行(部分帖被删/过期) | PHP 按实际条数渲染,不补位、不错位;下方提示「已为你展示 N 条」 | 1 |
| L2 | 该用户池子 0 行(已 build 过但被清空/全部过期) | 单表取 src=2 前 10 条(SELECT thread_id, ord_c FROM fa_feed_pool WHERE user_id=? AND src=2 AND expires_at>=? ORDER BY pos ASC LIMIT 10) | 1 |
| L3 | 该用户从未 build(新用户) | 不渲染 feed 区块,首页显示核心 latestThreads | 0 |
L1 的实测依据:把某用户全部 10 条的 status 置 1,查询返回 0 行、不报错(已验证)。所以 L1 不需要在 SQL 里"找替补",PHP 侧按返回条数渲染即可 —— 但如果整页 0 行,要走 L2 而不是显示空白。
L3 是最重要的一条:新用户第一次访问时池子是空的,此时绝不能同步 build(那等于前台算分,直接违反第一条红线)。正确处理:
- 这次先渲染核心
latestThreads(用户无感); route_after_dispatch里把该用户塞进fa_dirty_user(1 次 INSERT,非渲染期);- 5 分钟内 cron 的
build补上; - 用户下次刷新即见个性化。
为什么 L2 不直接回退核心列表:那会让"池子坏了"和"新用户"表现成同一种现象,故障被掩盖。L2 用 src=2 兜底至少还是插件自己的数据,后台观测页能一眼看出「该用户只剩兜底位」= 池子异常。
五、防刷防护(数据层)
前面四条是「怎么算得对」,这一条是「怎么算不被操纵」。原则:所有防护都在数据层与服务端,不依赖前端,也不给攻击者可见的反馈。
5.1 第一层:写入幂等(把重复直接吃掉)
fa_behavior 的唯一索引 (user_id, thread_id, action, fp):
- 同一用户对同一帖的「点击」只记一次(
fp含 10 分钟时间桶) - 「有效停留」按会话记一次(
fp含 session_id 哈希) - 「赞 / 评论」天然由核心
thread_votes/posts唯一约束保证,插件这层只做记录
→ 脚本狂点 1000 次,库里只有 1 行。刷分的第一步(刷数量)在这里就失效了。
5.2 第二层:单日单 Tag 软上限(把「刷很多」变成「刷一次」)
每日归并时,对每个 (user, tag, day) 的累计贡献做截断:
$$\text{contrib}(u,b,d) = \min\Big(\sum_{i \in (u,b,d)} w_i,\ \ \text{cap}(u)\Big)$$
$$\text{cap}(u) = 1.5 \times \mathrm{P95}\big({\text{contrib}(u,\cdot,\cdot)}_{30d}\big)$$
即:用该用户自己过去 30 天的 P95 当上限。正常用户永远碰不到;刷子刷到 500 也只等于自己历史最高的一次。
用 P95 而不是固定值,是因为活跃用户和潜水用户的正常量级差 20 倍,固定阈值不是误杀就是漏放。
5.3 第三层:行为可信度折扣 $\sigma(u)$
$$\sigma(u) = \mathrm{clamp}!\left(0.30 + 0.70 \cdot H(u) \cdot \min!\left(1, \frac{A(u)}{7}\right),\ 0.05,\ 1.00\right)$$
- $H(u)$ = 该用户 tag 分布归一化熵:$H = \dfrac{-\sum_b p_b \ln p_b}{\ln |B_u|}$。只在一个 tag 上狂点 → $H \to 0$。
- $A(u)$ = 账号注册天数。7 天内新号 → 线性打折。
三个可观测量,全部来自插件自己的数据,不需要问核心、不需要额外埋点。
为什么用熵而不是「频率」:正常用户也有狂热期(一天看 50 个同 tag 帖),频率判据会误杀;但正常用户的 tag 分布熵不会塌到 0.2 以下。熵区分的是「兴趣窄」和「被脚本驱动」,不是「活跃」和「不活跃」。
$\sigma$ 直接乘在 ord_c 上,所以被判定可疑的用户看不到自己的推荐变差——他的推荐确实变差了,但他不知道是为什么。
5.4 第四层:主/辅 Tag 闸(这是最关键的一条)
用户明确要求「主 Tag 参与计算,辅助 Tag 仅检索」。落到防刷上:
| 路径 | 主 Tag | 辅 Tag |
|---|---|---|
| 能否被召回 | ✅ | ✅(src=1) |
| 能否进打分公式 $\hat a$ | ✅ | ❌ |
| 能否进 Top-M(驱动传播) | ✅ | ❌ |
| 能否触发关联扩展 | ✅ | ❌ |
为什么这能防刷:刷分脚本的典型手法是「给目标帖挂一堆标签」,让它从多个 tag 方向被召回。但主槽恒为 1 个(作者填写顺序第 1 位,见 §2.4),挂 4 个标签和挂 1 个标签拿到的打分额度相同。
→ 一个帖最多拿 1 个主 Tag 的召回额度。 想多拿,只能多发帖,而多发帖会撞 §5.2 的日上限。
⚠️ 这里必须诚实:§2.4 已经查过,作者编辑一次帖就能重排
thread_tags.id,所以「顺序」本身挡不住作者刷自己的帖。它挡的是「脚本批量给别人的帖堆标签」——因为脚本改不了别人帖的标签顺序。 作者刷自己由 §5.2(单日单 Tag 截断)+ §5.3($\sigma$ 熵折扣)负责,不靠主/辅划分。不要把两层防护混为一谈。
5.5 第五层:有效阅读门控(点赞不能替代阅读)
- 「停留 ≥ 3000ms」才记
action=2(+0.5) - 只有
read_cnt ≥ 3的 tag 才有资格进 Top-M - 「赞」记 +1.0,但不增加
read_cnt
→ 刷 1000 个赞,read_cnt 仍是 0,该 tag 进不了 Top-M,不会污染召回;只会让那几个帖的 hot_c 略高,而 hot_c 权重只有 0.23,且被 §5.2 截断。
5.6 第六层:秒退负反馈需要交叉验证
「秒退 −0.4」是唯一能被用来打人的武器(刷别人的帖 → 让它降权)。所以:
- 单次秒退不生效,需同一
(user, thread)在不同会话(不同fp)累计 ≥2 次才落 −0.4 - 同一用户对同一作者的秒退若占其总秒退 > 60% 且样本 ≥10 → 该用户的负反馈整体作废(判定为定向攻击)
- 显式负反馈(
feedback=2「不感兴趣」)不受此限,但只影响自己的 feed,不影响帖子的全局分
5.7 第七层:影子封禁(不给反馈)
判定命中($\sigma < 0.15$ 且 contrib 触顶 ≥3 天)→ fa_behavior.applied = 2:
- 行为照常入库(便于审计与申诉)
- 不参与任何聚合
- 页面上没有任何提示、没有降级文案、接口返回码不变
→ 攻击者无法通过「分数有没有涨」来判断自己有没有被发现,也就无法调参绕过。
5.8 防刷参数汇总(全部进后台可调,带默认值)
| 参数 | 默认 | 作用 |
|---|---|---|
feed_behavior_daily_cap | 2000 | 单用户单日埋点上限,超出静默丢弃 |
feed_min_dwell_ms | 3000 | 有效阅读门槛 |
feed_cap_multiplier | 1.5 | P95 上限倍数 |
feed_sigma_floor | 0.15 | 影子封禁阈值 |
feed_main_tag_n | 2 | 主 Tag 个数 |
feed_top_m | 20 | Top-M 传播范围 |
⚠️ 规范 §11.32 的坑:后台保存必须用 array_key_exists 判断字段是否提交,缺失即跳过。写 (int)($_POST[$k] ?? 0) 会把「没渲染这个控件」静默写成 0 —— 那正好把 feed_sigma_floor 写成 0,等于关掉影子封禁。
六、挂载点与钩子清单
6.1 首页挂载(需要你拍板的地方)
首页 app/Views/home/index_default.php 只有一个钩子 announcements_display(第 17 行,公告位),没有列表锚点。三条可选路径:
| 方案 | 做法 | 核心改动 | 风险 |
|---|---|---|---|
| A(推荐) | controller_view_before 里判 $template === 'home/index_default' 且登录 → 替换 $data['latestThreads'] | 0 | 首页「论坛社区」区块语义从「最新」变「推荐」,需改区块标题 |
| B | 新增独立页 /feed,首页只放入口链接 | 0 | 不是「首页推荐」,不满足原需求 |
| C | 新增核心钩子 home_feed_after | 1 处核心改动 | 违反规范 §二「禁止修改核心文件」 |
建议 A + 兜底:首页替换列表,同时保留 /feed 独立页(便于调试、便于后台「为什么我看到这些」的自查)。
若走 A,latestThreads 的每个元素必须补齐核心视图用到的字段(id/title/username/avatar/level/category_name/reply_count/view_count/created_at/last_reply_at/is_pinned/is_highlighted/excerpt/excerpt_images/bucket_path),否则模板里 $thread['xxx'] ?? '' 会静默变空——这是最容易被漏掉的一步。
6.2 钩子与查询预算
| 钩子 | 用途 | 每页查询 |
|---|---|---|
init_after | 惰性建表(戳文件门控,命中即 return) | 0 |
route_register | /feed 前台路由 | 0 |
controller_view_before | 首页注入 feed | 1(仅首页 + 登录态) |
route_after_dispatch | 埋点写 spool 文件(不写库) | 0 |
thread_create_after | 新帖塞 fa_dirty_user + 增量边 | 1(仅发帖那一次请求) |
nav_plugin_links | 导航入口 | 0 |
layout_head_end | 按 $template 条件加载 CSS | 0 |
admin_route_register | 后台调参/观测页 | 0 |
每页总查询 = 1,在规范 §11.4-24 的 ≤5 红线内,且余量留给未来。
6.3 埋点为什么写文件而不是写库
规范 §11.31 的红线是「曝光/浏览类统计禁止每请求写库」。本插件更严格:连 route_after_dispatch 也不写库,而是 append 一行 JSON 到 data/spool/YYYYMMDD.ndjson:
{"u":12,"t":345,"a":2,"d":4200,"s":"a1b2c3","ts":1761700000}
原因三条:
- 零写锁:5 万次浏览/天 = 5 万次 SQLite 写,2 核机器上必然和发帖抢锁。append 一个 80 字节的行,
flock内部串行,成本 ~10μs。 - 天然批量:cron 一次读整个文件、一条事务灌库,比 5 万次单行 INSERT 快两个数量级。
- 可丢弃:磁盘满 / 文件损坏时,丢的是埋点,不是业务数据。
单文件按日切,超过 5MB 自动轮转;ingest 处理完改名 .done 并在 3 天后清理。
6.4 零内联
- CSS 全部在
assets/style.css,颜色用--mn-*变量(var(--mn-bg-card, #fff)这种写法,fallback = 浅色主题值) - JS 全部在
assets/script.js - 视图里不出现
<style>/<script>/style="..."($this->icon()自带内联 style,所以图标一律写字面量实体)
七、与开发规范的逐条对照
| 规范条款 | 本方案 |
|---|---|
| §4.1 生命周期类名推导 | 目录 feed_algo 含下划线 → 命名空间用 Plugin\feed_algo(逐字节等于目录名),候选路径 ① 必中,生命周期天然命中。不用 Plugin\FeedAlgo(会被 camelToSnake 推成 feed_algo,虽也能命中 ②,但不如 ① 稳) |
| §5.1 自动加载 | 全部驼峰以外按目录名写,无需 require_once |
| §10.3 SQLite DDL | 全 INTEGER/TEXT,无 AUTO_INCREMENT/VARCHAR/DATETIME/ENGINE= |
| 红线 5 原子扣减 | 本插件无库存/积分扣减;UPDATE ... SET co_cnt = co_cnt + :d 天然原子 |
| 红线 6 IN 占位符 | 所有 IN (...) 走 array_fill + intval,批大小 ≤900 |
| 红线 7 PDO 属性 | 不设任何 PRAGMA,完全用 DBFactory 的默认 |
| 红线 8 CSRF | 仅一个写接口(埋点),带 csrf;先 requireLogin() 后 Csrf::verifyOrDie()(§11.28 顺序) |
| §11.4-24 每页 ≤5 查询 | 实测 1 |
| §11.6 插件间不耦合 | 零引用其他插件类/库 |
| §11.15 建表兜底 | init_after + 戳文件 data/schema.version,内容比对(feed-schema-1) |
| §11.19 编码 | UTF-8 无 BOM + LF |
| §11.34 命名空间 | 见上,附 plugin_case_lint.py 自检 |
八、需要你与站长确认的三件事
① 首页形态走 A 还是 B
A = 替换首页「论坛社区」列表(零核心改动,但区块语义从「最新」变「推荐」,需要改标题文案);B = 只做 /feed 独立页。我建议 A,但这是产品语义变更,不该由我替你定。
② 冷启动的兜底口径
新用户 read_cnt = 0 → Top-M 为空 → 只能走纯高热。要不要在「纯高热期」降低热度权重、提高随机性(避免所有新用户看到一模一样的首页)?我倾向于「新用户前 3 次访问加入 20% 随机扰动」,但这会让每次刷新结果不同,需要站长确认能否接受。
③ 池子 TTL 与「刚看过」的时间精度
池子 TTL 我按 6 小时设计。副作用:用户 10 分钟前看过的帖,因为池子还没重建,仍会以 ×0.2 出现在池子里(而不是直接消失)。这是故意的(×0.2 就是需求里的「刚看过降权」,不是「不显示」)。如果你要的是「看过就别再出现」,那 ρ 语义要从 0.2 改成 0,池子 TTL 也要降到 30 分钟以内 —— 两条要一起改。
九、实施顺序(确认后)
| 步 | 内容 | 产出 |
|---|---|---|
| M1 | Plugin.php 建表 + init_after + 后台调参页 | 能建表、能改参数 |
| M2 | cli/feed_cron.php 的 edge / materialize / decay 三个 stage | 图谱与衰减可跑,带 --dry-run |
| M3 | ingest + 埋点 spool + 幂等指纹 | 行为能进库,重复被吃 |
| M4 | build + 前台 SQL + 首页注入 | 端到端可见 |
| M5 | 防刷三层(上限 / σ / 影子) | 压测脚本验证 |
| M6 | 双主题走查 + 三语言包 + 卸载残留自检 | 可发布 |
每步都要跑:php -l(8.2 + 8.0 双版本)、plugin_case_lint.py、EXPLAIN QUERY PLAN 断言、hook 查询计数断言。