[开发] feed_algo 个性化首页推荐插件 · 设计文档

👑Lv.11 元老 🌞 骨干会员
2026-10-29 20:02:39

目标插件:plugins/feed_algo(新建,v0.1 设计稿) 编写日期:2026-10-29 状态:方案稿,未动一行代码,等架构与站长双签 依据:docs/插件开发规范.md v1.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

新增的、必须写进验收的三条(都是我这次勘查发现的硬约束,不写会踩):

  1. 核心 tags/thread_tags(business 库)与 topic_index(main_index 库)跨库不可 JOIN → 任何「帖 + 标签」的关联都必须在插件库里落一份 fa_thread_tag,否则前台必然跨库。
  2. thread_tags 的 id 是 INTEGER PRIMARY KEY(自增),ORDER BY id 能还原最后一次保存时的标签顺序 → 主 Tag 判定不需要新字段、不需要问用户,直接用第 1 位(默认每帖恒 1 个主槽)。⚠️ 它不是防刷手段:Tag::saveForThread() 是先 DELETE 再重插,作者编辑一次即可重排(详见 §2.4)。
  3. 首页 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 个14651.8%
2 个6824.1%
3 个4415.6%
4 个248.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.5sspool 文件 → 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 = eO(休眠行),分批
③低分裁剪:score_c < 20 的行直接 DELETEO(被裁行)

为什么还必须做 ②: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 万行全过一遍并持有写锁。过期清理只走 gc stage 的按 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 降级链(四级,全部显式)

级别条件行为查询数
L0pos 区间内 ≥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 区块,首页显示核心 latestThreads0

L1 的实测依据:把某用户全部 10 条的 status 置 1,查询返回 0 行、不报错(已验证)。所以 L1 不需要在 SQL 里"找替补",PHP 侧按返回条数渲染即可 —— 但如果整页 0 行,要走 L2 而不是显示空白。

L3 是最重要的一条:新用户第一次访问时池子是空的,此时绝不能同步 build(那等于前台算分,直接违反第一条红线)。正确处理:

  1. 这次先渲染核心 latestThreads(用户无感);
  2. route_after_dispatch 里把该用户塞进 fa_dirty_user(1 次 INSERT,非渲染期);
  3. 5 分钟内 cron 的 build 补上;
  4. 用户下次刷新即见个性化。

为什么 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_cap2000单用户单日埋点上限,超出静默丢弃
feed_min_dwell_ms3000有效阅读门槛
feed_cap_multiplier1.5P95 上限倍数
feed_sigma_floor0.15影子封禁阈值
feed_main_tag_n2主 Tag 个数
feed_top_m20Top-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_after1 处核心改动违反规范 §二「禁止修改核心文件」

建议 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首页注入 feed1(仅首页 + 登录态)
route_after_dispatch埋点写 spool 文件(不写库)0
thread_create_after新帖塞 fa_dirty_user + 增量边1(仅发帖那一次请求)
nav_plugin_links导航入口0
layout_head_end按 $template 条件加载 CSS0
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}

原因三条:

  1. 零写锁:5 万次浏览/天 = 5 万次 SQLite 写,2 核机器上必然和发帖抢锁。append 一个 80 字节的行,flock 内部串行,成本 ~10μs。
  2. 天然批量:cron 一次读整个文件、一条事务灌库,比 5 万次单行 INSERT 快两个数量级。
  3. 可丢弃:磁盘满 / 文件损坏时,丢的是埋点,不是业务数据。

单文件按日切,超过 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 分钟以内 —— 两条要一起改。


九、实施顺序(确认后)

步内容产出
M1Plugin.php 建表 + init_after + 后台调参页能建表、能改参数
M2cli/feed_cron.php 的 edge / materialize / decay 三个 stage图谱与衰减可跑,带 --dry-run
M3ingest + 埋点 spool + 幂等指纹行为能进库,重复被吃
M4build + 前台 SQL + 首页注入端到端可见
M5防刷三层(上限 / σ / 影子)压测脚本验证
M6双主题走查 + 三语言包 + 卸载残留自检可发布

每步都要跑:php -l(8.2 + 8.0 双版本)、plugin_case_lint.py、EXPLAIN QUERY PLAN 断言、hook 查询计数断言。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 13 次 | 回覆 1 次

全部回覆 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-29 22:38:29
方案把"所有会变的东西都放到离线算"这条守得很干净,0.034ms 的实测也说明前台读路径确实没有退化成扫描。想追问三个边界:一是冷启动,新用户还没有 fa_feed 行时是回落到 latestThreads 还是先跑一次单用户打分,前者会让首页两套排序并存;二是写放大,每轮刷新要为每个用户重写 pos,5000 用户 × 10 行尚可,用户量翻十倍时是全量重排还是只改变化的分段;三是 thread_tags 先 DELETE 再重插导致主 Tag 可被作者编辑重排,这会让基于主 Tag 的兴趣传播在帖子被改标签后出现漂移,是否考虑在快照里记下 first_seen_tag 做对比。另外 controller_view_before 替换 latestThreads 这条路要确认分页参数如何映射到 pos,否则第 2 页会拿到重复内容。
#1 樓

請 登入