| 项目 | 内容 |
|---|---|
| 文档版本 | v0.1(规划稿,待评审) |
| 编写日期 | 2026-11-03 |
| 代码基线 | fh_reference/flinthub1.5.0(只读参考,不修改) |
| 架构依据 | sobbs-whitepaper/ARCHITECTURE.md |
| 目标产物 | 单文件 Go 程序 sobbs,直接接管 FH 1.5.0 的 data/ 目录 |
| 文档状态 | 需评审通过后,方可进入 B0 |
0. 文档导读
0.1 本方案要解决的问题
用 Go 重写 FlintHub 1.5.0,并满足一条硬性承诺:
同一份
data/目录,PHP 版与 Go 版可以交替启动,读写结果一致;切换只需改 Nginx upstream,不需要数据迁移。
这不是"重写一个论坛",而是"换一个进程去读写同一套已有数据"。因此本方案的重心不是功能列表,而是兼容契约、数据真相源、阶段验收。
0.2 阅读路径
| 角色 | 建议章节 |
|---|---|
| 项目负责人 / 评审人 | 第 1、2、6、9、10 章 |
| 后端开发 | 第 3、4、5、6、7 章 |
| 前端 / 模板开发 | 第 3.3、5.3、6 章(B7)、7.3 |
| 测试 / 运维 | 第 7、8、9 章及附录 A/B/E |
| 插件开发者 | 第 5.5、6 章(B8)、附录 C |
0.3 阶段推进规则
- 严格按 B0 → B1 → … → B10 顺序推进,前一阶段验证报告未通过,不进入下一阶段。
- 每个阶段结束必须产出
docs/reports/Bx-verify.md,内容至少包含:- 本阶段任务完成清单(逐条对应任务 ID);
- 执行的测试命令与结果;
- 兼容性用例结果(涉及数据层的阶段必填);
- 已知问题与是否阻断下一阶段;
- 下一阶段准入结论。
- 每个阶段只允许修改本阶段范围内的文件;跨阶段改动必须回到对应阶段补齐验收。
1. 项目定义
1.1 一句话目标
以 Go 标准库为主体重写 FlintHub 1.5.0,保持 data/、Cookie、会话、设置缓存、页面缓存、搜索索引、任务队列逐字节/逐语义兼容,并在此之上提供更安全的运行时与更清晰的模块边界。
1.2 核心目标
| 编号 | 目标 | 判定方式 |
|---|---|---|
| G1 | 数据 100% 兼容 | 同一 data/ 下,PHP ↔ Go 交替启动,第 7.3 节 10 条用例全绿 |
| G2 | 功能对等 | 前台 50 条路由、后台 84 条路由、API 7 条路由全部有 Go 实现或明确的下线决策 |
| G3 | 视觉与交互对等 | 64 个视图转写后,关键页面截图对比通过;CSS/JS 复用不破坏 |
| G4 | 单文件零依赖部署 | CGO_ENABLED=0 交叉编译,Linux 单二进制可启动 |
| G5 | 安全基线达标 | 第 7.7 节安全检查表全绿,无高危项 |
| G6 | 可回滚 | 同主版本内只换二进制;切换失败 5 分钟内切回 PHP 且数据不受损 |
| G7 | 可演进 | 插件、主题、迁移、升级都有明确扩展点,不把分片写成硬依赖 |
1.3 非目标(本版本明确不做)
| 项 | 说明 |
|---|---|
| 不做数据库结构重构 | 不合并表、不改字段类型、不新增必填字段 |
| 不做数据迁移脚本 | 不把现有数据搬到新结构;只做"同构接管" |
| 不支持运行时加载 Go 插件 | 插件后端编译期注入;运行时只安装模板/静态资源 |
| 不重写前端框架 | 继续复用 FH 的 CSS/JS/字体/图标与页面结构 |
| 不追求 API 全 JSON 化 | 前台仍以 HTML 渲染为主,/api 保持 JSON |
| 不做多租户 / 多站点 | 单站点单实例 |
1.4 发布阻断红线(任何一条不满足即不得上线)
- 向现有表增加必填字段,或删除/改名/改类型。
- 把
sessions.data改成 JSON 或其他格式。 - 用 SQLite
AUTOINCREMENT取代global_id.sqlite发号。 - 正文写到
extern/之外的位置,或改变t/r前缀语义。 - 改变时间字段单位(秒 ↔ 毫秒),或把 business 表的本地时间字符串改成整数。
- 重命名/移动
data/meta/下的库文件。 - 升级或部署时只拷
.sqlite不带-wal/-shm。 - 允许未验签的插件包安装。
- 响应体出现
err.Error()、绝对路径、SQL 片段。 data/、protected/、logs/可被 Web 直接访问。
2. 现状盘点
2.1 FH 1.5.0 代码规模(实测)
| 项 | 数量 / 值 |
|---|---|
| PHP 文件 | 192 个,约 2.97 MB |
| 控制器文件 | 33 个(前后台含同名类) |
| Helper 类 | 34 个 |
| Model 类 | 7 个(Category / Message / Post / Setting / Tag / Thread / User) |
| 视图文件 | 64 个(含 7 个 _components 组件、layouts、errors) |
| 语言包 | 3 套(zh.php / en.php / zh_tw.php) |
| 核心插件钩子 | 49 个(以 docs/插件开发规范.md §9.1 为准) |
| 路由注册 | 141 条(前台 50、后台 84、API 7,见附录 A) |
| 前端资源 | CSS 8 个、JS 10 个、lib 3 个、字体 4 个、奖章 10 个、PWA 图标 5 个 |
2.2 必须理解的 8 个机制
| # | 机制 | 关键事实 | 对 Go 的要求 |
|---|---|---|---|
| 1 | SplitDB 分片 | 季度目录 + 32 桶;读路径以 main_index.bucket_path 为准 | 不做二次哈希;支持 32→64/128 扩容路由 |
| 2 | 全局发号 | global_id.sqlite 的 id_generator;BEGIN IMMEDIATE 事务 | 禁止 AUTOINCREMENT;取号必须原子 |
| 3 | 正文外置 | extern/{年}/{季}/{桶}/t|r{id}.txt,另有 .bin/.idx 归档块 | 读优先 .bin/.idx,回退 .txt;写入原子 |
| 4 | 会话 | sessions(id,data,expires);data 是 PHP 序列化串;Cookie 名 PHPSESSID;TTL 1800 | 必须实现 PHP session 编解码,读改写保留未知键 |
| 5 | 设置缓存 | protected/settings_cache.php = PHP 包装头 + JSON | Go 写设置后必须原子重建该文件 |
| 6 | 页面缓存 | data/runtime/pages/{key}.html,按主题/语言/摘要偏好分键 | 共用或独立命名空间需显式配置;发帖/回帖必须失效 |
| 7 | 搜索索引 | data/meta/search/search_{YYYYQn}.sqlite,token/type/target_id/weight | 分词结果必须与 PHP 侧一致,否则索引漂移 |
| 8 | 任务队列 | meta/task_queue/queue_0..2.sqlite,租约 + 重试 | 与 PHP worker 共享时,锁文件与租约语义必须一致 |
2.3 白皮书已确定、本方案直接继承的结论
- 纯标准库:
net/http+html/template+database/sql+modernc.org/sqlite;不引入 Gin/Echo/ORM。 - 模板外置:禁止
embed;themes/目录 + mtime 缓存失效。 - 插件编译期注入:后端静态编译,运行时只装视图与资源;签名校验(Ed25519 + SHA-256)。
- 只加不删的迁移铁律,
migrations/NNNN_*.sql幂等、可中断恢复。 - 请求生命周期复刻 FH,钩子名沿用 FH 命名。
- 安全基线:路径穿越、SQL 注入、XSS、CSRF、会话固定、资源耗尽逐项防护。
2.4 事实冲突清单(白皮书 vs FH 1.5.0 实际代码)
这一节是本方案与"直接照抄白皮书"的区别所在。以下冲突必须在 B0 阶段固化为测试夹具,后续实现以实际代码/实际库为准,白皮书作为原则依据。
| 编号 | 冲突点 | 白皮书说法 | FH 1.5.0 实际 | 本方案处置 |
|---|---|---|---|---|
| C1 | 时间单位 | 全库秒级 int64 | business.sqlite 多为 TEXT Y-m-d H:i:s(users/messages/attachments/notifications/audit_logs 等);main_index 与桶为 INTEGER 秒 | 双轨制:business 按本地时间字符串读写;索引/桶按秒级整数。详见 4.4 |
| C2 | 博客表 | 实测 23 张表,含 blogs、blog_categories、blog_comments | 1.5.0 Schema.php 核心 DDL 不创建这 3 张表(为原站遗留/插件数据) | 不删除、不改写;B0 采样确认是否真实存在;由 D1 决策归属 |
| C3 | 表数量 | 23 张 | 核心 DDL 20 张;+3 张遗留博客表 = 23 | Go 只"确保"20 张核心表;3 张遗留表按只读/插件归属处理 |
| C4 | 配置来源 | 新增 config.json | 现网真实配置在 config.php(define() 常量),且 index.php 在连库前就读取 settings_cache.php | Go 必须同时支持 config.php 只读解析与 config.json 进程配置,优先级见 3.8 |
| C5 | 设置缓存 | 未展开 | settings_cache.php 是 <?php /* ... */ ?> + JSON;Settings::get() 与 index.php 都读它 | 实现同格式解析与原子重建;详见 4.8 |
| C6 | 主题 | 新增 themes/ 模板层 | FH 主题是 CSS 变体 + 选项,页面结构写死在 PHP 视图;Cookie site_theme 保存键 | Go 新增模板层,必须保留原主题键、CSS 类名与 Cookie 语义;详见 B7 |
| C7 | 插件 | Go 插件 + 钩子 | PHP 插件(49 钩子,运行时 include) | 静态 Go 插件;钩子名对齐;PHP 插件不执行,给出迁移边界 |
| C8 | 分片扩容 | 只提 32 桶 | 有 SPLITDB_LAST_EXPANSION_AT / SPLITDB_PREV_BUCKET_SIZE 扩容路由 | 必须实现 bucketForWrite,新帖落 [PREV, NEW) 范围 |
| C9 | 发号实现 | 建议 UPDATE ... RETURNING | 为兼容 SQLite 3.33,改用 BEGIN IMMEDIATE + SELECT/UPDATE | Go 沿用 BEGIN IMMEDIATE,不依赖 RETURNING |
| C10 | 页面缓存 | 只提游客列表页 | 缓存键含主题/语言/摘要偏好,另有 category_stats.cache、category_<id>_latest.json | 一并纳入兼容清单;详见 4.9 |
| C11 | 上传目录 | 未细述 | assets/uploads/,白名单后缀,MAX_FILE_SIZE=5MB | 沿用路径与 URL;上传走 Go 安全校验 |
| C12 | 时区 | 未提 | config.php 执行 date_default_timezone_set('Asia/Shanghai') | Go 用站点时区解析 business 时间;编译内置 time/tzdata |
| C13 | 根目录占位库 | 站点根 data/business.sqlite 为 0 字节 | 真实库在 data/meta/ | 启动自检识别 0 字节占位并拒绝误连 |
| C14 | 退役表 | threads/posts 0 行 | Post/Thread 模型仍存在,但真相源为索引+桶+extern | Go 不映射这两张表,不删除不写入 |
3. 总体架构
3.1 设计原则
| 原则 | 落地要求 |
|---|---|
| 兼容优先 | 任何"更优雅"的重构,若会改变磁盘字节、Cookie、缓存文件,一律不做 |
| 真相源唯一 | 读正文只走 topic_index/桶/extern;threads/posts 不参与 |
| 薄基础设施 | 不引入框架;路由/上下文/中间件自研且可单测 |
| 失败可见 | 数据库/路径/格式异常必须记日志并返回稳定错误码,不静默降级 |
| 读写分离 | 读请求直接走连接池;写操作可走单写者队列,读绝不排队 |
| 可回滚 | 迁移只加不删;升级失败可用备份 + 旧二进制恢复 |
3.2 技术选型
| 层 | 选型 | 约束 |
|---|---|---|
| 语言 | Go 1.22+ | 只用标准库 + modernc.org/sqlite + golang.org/x/crypto |
| HTTP | net/http + 自研 lib/router | 支持 {id} 命名参数、.html 后缀、子目录 BASE_PATH |
| 模板 | html/template 外置 themes/ | 禁止 embed 模板;mtime 失效;FuncMap 提供 raw/asset/url/pagination/t |
| 数据库 | modernc.org/sqlite | CGO_ENABLED=0;DSN 带 _pragma=busy_timeout(5000)、_pragma=journal_mode(WAL) |
| 会话 | 自研 lib/session/phpsess | 双向 PHP session 序列化;PHPSESSID |
| 日志 | log/slog | JSON 结构化 + request_id;文件按天/大小轮转(外部 logrotate 或自研) |
| 时区 | time/tzdata 内置 | 解析 business 本地时间不依赖系统 tzdata |
| 前端 | 原样复用 FH assets/ | CSS/JS/字体/图标不重写;模板只转写 HTML 结构 |
3.3 源码目录结构(建议)
d:\sobbs\
├── cmd/sobbs/main.go # 入口:加载配置 → 自检 → 启动 HTTP
├── lib/
│ ├── router/ # 路由、参数、方法、BASE_PATH
│ ├── context/ # 请求上下文、UserID/Role/IP/Lang/RequestID
│ ├── middleware/ # Recovery/RequestID/RealIP/SecurityHeaders/BodyLimit/Timeout/CSRF/Auth/RateLimit
│ ├── config/ # config.json + config.php 解析
│ ├── db/ # 连接管理、PRAGMA、LRU、事务、重试
│ ├── splitdb/ # schema/shardrouter/id/extern/pagecache/settings/search/queue
│ ├── session/ # session store + phpsess 编解码
│ ├── cache/ # 进程内 KV(TTL + 主动失效)
│ ├── template/ # 加载器、FuncMap、mtime 缓存
│ ├── i18n/ # 三语包加载、键集合自检
│ ├── security/ # SafeJoin、HTML 净化、密码、token、限流
│ ├── upload/ # 上传校验、缩略图、随机命名
│ ├── mail/ # SMTP(异步发送)
│ ├── plugin/ # 注册表、钩子总线、动态资源包
│ └── compat/ # PHP 时间/序列化/配置/缓存格式兼容
├── app/
│ ├── handlers/ # 前台 handler(forum/thread/post/auth/...)
│ ├── admin/ # 后台 handler
│ ├── api/ # /api handler
│ ├── services/ # 业务服务(事务编排)
│ └── models/ # 数据访问(SQL 逐字段对齐)
├── themes/modern/ # 外置模板(layout + partials + pages;沿用 FH 默认主题键 modern)
├── plugins/ # 静态插件(plugin.go + plugin.json + views/assets)
├── assets/ # 复用 FH 静态资源(css/js/fonts/img/medals/pwa/uploads)
├── migrations/ # 只加不删的 SQL 迁移
├── tools/ # compat/dbcheck/langimport/bench/doctor
├── testdata/fixtures/ # FH 只读样本(脱敏)+ 校验清单
├── docs/ # 本方案、兼容清单、阶段验证报告
└── go.mod
3.4 生产部署布局(与 FH 同构)
/var/www/sobbs/
├── sobbs # 单文件二进制
├── config.json # 进程级配置(监听、日志、超时)
├── config.php # 存量站原有配置(Go 只读解析,不执行)
├── data/ # 与 FH 完全同构,直接接管
│ ├── meta/{business,main_index,sessions,global_id}.sqlite
│ ├── meta/search/search_YYYYQn.sqlite
│ ├── meta/task_queue/queue_0..2.sqlite
│ ├── bucket/{active,archive}/YYYYQn/<0..31>.sqlite
│ ├── extern/YYYY/Qn/<bucket>/t|r<id>.txt
│ ├── runtime/pages/*.html
│ ├── runtime/category_stats.cache
│ ├── runtime/category_<id>_latest.json
│ ├── lock/cron_trigger.lock
│ └── log/
├── themes/modern/
├── plugins/
├── assets/ # 含 assets/uploads/
└── protected/ # 仅存量兼容:settings_cache.php、level_config_cache.php、rate_cache
3.5 分层与依赖规则
cmd → app/handlers, app/admin, app/api
app/* → app/services → app/models → lib/splitdb → lib/db
app/* → lib/template, lib/i18n, lib/security, lib/upload
lib/plugin ← 只依赖 lib/context、lib/router、lib/splitdb 的公开接口
硬性规则:
lib/splitdb不允许 importapp/*。- handler 不直接写 SQL;写操作必须经 service。
- 插件只能通过公开接口访问数据库与钩子,不得直接引用 handler。
- 任何跨层错误必须用哨兵错误包装(
%w),出口统一翻译。
3.6 请求生命周期(对齐 FH index.php)
① 加载 config.json(失败即启动失败,输出可读原因)
② 读取 config.php 兼容常量(data 路径、桶大小、上传、邮件、CRON_KEY、时区)
③ 初始化 slog + 生成/透传 request_id
④ 打开 business/main_index/sessions/global_id;分片桶按需懒加载
⑤ 会话解析:Cookie → sessions 表 → PHP 反序列化 → 还原身份
⑥ 构建 Context(UserID/Role/IP/Lang/Theme/RequestID)
⑦ 全局中间件:Recovery → RequestID → SecurityHeaders → BodyLimit → Auth → RateLimit → CSRF
⑧ 游客页面缓存前置命中(仅 GET、无登录 Cookie)
⑨ 路由分发:静态资源 → 前台 → /api → 后台(鉴权)→ 插件路由
⑩ 业务处理 → 模板渲染 / JSON
⑪ 收尾:计数批量回写、缓存刷新、会话续期、释放连接
与 FH 的差异(必须记录并在验收中说明):
| 项 | FH | Go | 原因 |
|---|---|---|---|
route_before_dispatch | 历史行为会派发两次 | 只派发一次 | 白皮书附录 D 已确认;插件迁移需注意 |
| 会话写入 | lazy_write 下只 updateTimestamp | 显式续期 expires | 避免 30 分钟无故掉线 |
| 计数回写 | register_shutdown_function | 响应完成后批量 flush | 等价语义 |
3.7 并发与写模型
| 场景 | 方案 |
|---|---|
| 读 | 每个库独立 *sql.DB;MaxOpenConns:business/index 8、bucket 4、global_id 1、sessions 4 |
| 写 | 默认直接写 + SQLITE_BUSY 指数退避重试;高并发阶段启用单写者队列(可选开关) |
| 发号 | global_id 连接串行,MaxOpenConns=1 + BEGIN IMMEDIATE |
| 桶连接 | 路径级 LRU(默认 8 个常驻),归档桶连接设 PRAGMA query_only=1 |
| 后台任务 | 独立 goroutine / 独立进程 sobbs worker,带优雅退出与上下文超时 |
| 禁止 | 请求内同步 HTTP/邮件/对象存储外呼;无缓冲 channel 永久阻塞 |
3.8 配置体系
三层配置,优先级从高到低:
环境变量(FLINTHUB_SITE_URL / FLINTHUB_CRON_KEY 等)
> 站点根 config.json(Go 进程级)
> 站点根 config.php(存量兼容,只读解析)
> 内置默认值
config.json 建议字段(只放进程级):
{
"listen": "127.0.0.1:8080",
"base_path": "",
"data_dir": "./data",
"theme": "modern",
"session_cookie": "PHPSESSID",
"session_ttl": 1800,
"page_cache_shared": false,
"timezone": "Asia/Shanghai",
"log_level": "info",
"log_dir": "./logs",
"trusted_proxies": ["127.0.0.1"],
"read_timeout": 15,
"write_timeout": 30,
"queue_mode": "auto"
}
config.php 必须解析的常量(只允许标量字面量,不执行 PHP):
| 常量 | 用途 |
|---|---|
SPLITDB_DATA_PATH / SPLITDB_BUCKET_SIZE | 数据根、桶数量 |
SPLITDB_LAST_EXPANSION_AT / SPLITDB_PREV_BUCKET_SIZE | 扩容路由 |
UPLOAD_PATH / UPLOAD_URL / MAX_FILE_SIZE / ALLOWED_EXTENSIONS | 上传 |
MAIL_DRIVER / MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS / MAIL_ENCRYPTION / MAIL_FROM_ADDR / MAIL_FROM_NAME / MAIL_PASS_KEY | 邮件 |
CRON_KEY | cron 触发密钥 |
DEFAULT_THEME / POSTS_PER_PAGE / THREADS_PER_PAGE | 展示 |
BASE_PATH / SITE_URL | 子目录部署与绝对链接 |
date_default_timezone_set(...) | 站点时区 |
解析失败策略:不静默兜底。缺失关键常量时启动失败并打印缺失项;MAIL_PASS 等敏感值只进内存,不写日志。
3.9 错误、日志与可观测性
| 项 | 规范 |
|---|---|
| 错误判定 | 哨兵错误 ErrNotFound/ErrForbidden/ErrUnauthorized/ErrConflict/ErrServiceUnavailable |
| 对外 | 稳定 code + 人话提示;HTML 页面沿用 FH 404/429 视图 |
| 对内 | slog.Error 带 request_id、路径、用户 ID、耗时、错误链 |
| request_id | 接受合法 X-Request-ID,否则 crypto/rand 生成 16 字节;响应头回写 |
| 日志红线 | 不记录明文密码、token、完整 Cookie、SQL 参数中的敏感值 |
| 健康检查 | GET /healthz(仅本机或内网白名单):DB 可读、队列可写、模板可加载 |
| 慢请求 | >500ms 记 WARN;>2s 记 ERROR |
3.10 安全基线(摘要,详细见 7.7)
- 统一
SafeJoin,所有模板名/主题名/插件名/附件名白名单。 - 全部 SQL 参数化;排序/筛选走白名单映射。
- 默认
html/template转义;raw仅用于服务端净化后的正文。 - CSRF:表单隐藏字段 + JSON
X-CSRF-Token;30 分钟滑动续期。 - 会话:
PHPSESSID、HttpOnly、SameSite=Lax、HTTPS 自动 Secure、登录换 SID。 - 响应头按白皮书 11.6 固定;CSP 不含
blob:。 - 请求体 2MB(上传单独限制)、Header 1MB、超时 5/15/30/60s。
4. 数据兼容设计(核心章节)
4.1 兼容契约
| 层次 | 契约 | 验收方式 |
|---|---|---|
| 文件布局 | 目录名、库名、桶编号、正文命名完全一致 | 文件树 diff + 路径单测 |
| 表结构 | 表名/字段/类型/默认值与 FH 一致 | PRAGMA table_info 快照对比 |
| 字符编码 | UTF-8 无 BOM | 读库后逐值 utf8.Valid |
| 时间 | business=TEXT 本地时间;index/bucket=秒级整数 | 双端写入后逐字段比对 |
| 布尔 | 0/1 | 模型层禁止 bool 直写 |
| 会话 | PHP session 序列化 | 编解码往返 + 双端登录 |
| 正文 | 外置 extern/,索引库只存摘要 | 字节级比对 |
| 缓存 | 设置/页面/统计快照格式一致 | 文件内容比对 |
| 队列 | 表结构 + 租约 + 重试语义 | 双端消费交叉测试 |
| 搜索 | 季度库 + token/weight 语义 | 同一帖分词集合对比 |
4.2 库与表清单
4.2.1 data/meta/business.sqlite(主库)
核心 20 张(FH 1.5.0 Schema::ensureBusiness 创建):
| 表 | 用途 | 时间格式 | Go 写入 |
|---|---|---|---|
users | 用户、角色、组、积分、等级、记住登录 token、邮箱验证 | TEXT | 是 |
categories | 版块 | TEXT | 是 |
threads | 退役表(0 行) | TEXT | 否 |
posts | 退役表(0 行) | TEXT | 否 |
messages | 私信 | TEXT | 是 |
attachments | 附件元数据 | TEXT | 是 |
settings | KV 设置 | - | 是 |
online_users | 在线心跳 | TEXT | 是 |
password_resets | 重置 token | TEXT | 是 |
points_log | 积分流水 | TEXT | 是 |
viewed_replies | 回复浏览去重 | TEXT | 是 |
level_config | 等级规则 | - | 是 |
user_groups | 用户组 | - | 是 |
category_permissions | 版块×组权限(含 can_attach) | - | 是 |
tags | 标签 | TEXT | 是 |
thread_tags | 主题标签关联 | - | 是 |
thread_votes | 主题投票 | TEXT | 是 |
post_votes | 回复投票 | TEXT | 是 |
notifications | 通知 | TEXT | 是 |
audit_logs | 审计 | TEXT | 是 |
遗留 3 张(实测库存在,1.5.0 核心 DDL 不创建):
| 表 | 处置 |
|---|---|
blogs | 保留不动;由 D1 决定是 Go 博客插件接管还是只读 |
blog_categories | 同上 |
blog_comments | 同上 |
Schema 策略:Go 的 ensureBusiness 必须逐字复刻 FH DDL 与列补丁(如 category_permissions.can_attach),不得新增 FH 不认识的列;只允许新增可选列/新表,且有默认值。
4.2.2 data/meta/main_index.sqlite
topic_index:id/uid/title/create_time/last_reply_time/view_count/reply_count/status/category_id/is_pinned/is_highlighted/color/reply_to_view/deleted_at/bucket_path/is_archived/excerpt/excerpt_images,时间 INTEGER。reply_index:id/pid/uid/create_time/update_time/status/bucket_path。- 索引必须与 FH 同名同列序(
idx_topic_index_list、idx_topic_index_pinned_ct、idx_topic_index_hot、idx_topic_index_uid、idx_reply_index_pid、idx_reply_index_uid、idx_reply_index_status等),否则查询计划劣化。 - WAL 模式必须校验;非 WAL 拒绝启动(fail-closed)。
4.2.3 桶库 bucket/{active,archive}/YYYYQn/<n>.sqlite
topic:id/uid/category_id/title/create_time/update_time/last_reply_time/view_count/reply_count/status/is_pinned/is_highlighted/color/reply_to_view/deleted_at/extern_path/excerpt/excerpt_images。
reply:id/pid/uid/create_time/update_time/status/deleted_at/extern_path。
索引:idx_topic_category、idx_reply_pid、idx_reply_pid_status_ct;建表后写 PRAGMA user_version = 2(与 FH BUCKET_SCHEMA_VERSION 一致),命中即跳过 DDL。
归档桶:PRAGMA query_only=1,只读;禁止回复/编辑。
4.2.4 sessions.sqlite
CREATE TABLE sessions (id TEXT PRIMARY KEY, data TEXT, expires INTEGER NOT NULL);
CREATE INDEX idx_sessions_expires ON sessions (expires);
4.2.5 global_id.sqlite
CREATE TABLE id_generator (table_name TEXT PRIMARY KEY, last_id INTEGER DEFAULT 0);
INSERT OR IGNORE ... ('topic',0),('reply',0);
4.2.6 meta/task_queue/queue_0..2.sqlite
CREATE TABLE task_queue (
id INTEGER PRIMARY KEY,
type TEXT,
data TEXT,
status TEXT DEFAULT 'pending',
create_time INTEGER,
update_time INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
lease_token TEXT,
lease_until INTEGER,
dedupe_key TEXT
);
-- idx_status, idx_update_time
状态机:pending → processing(租约) → done | failed;未知类型记录日志后丢弃(与 FH 一致,避免无限重试堆积)。
4.2.7 meta/search/search_YYYYQn.sqlite
CREATE TABLE search_index (
token TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 1, -- 1=帖子 2=博客
target_id INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 1,
created_at TEXT -- 'Y-m-d H:i:s'(注意:不是整数)
);
-- idx_search_index_token(token)、idx_search_index_target(type,target_id)、
-- idx_search_index_created(created_at)、UNIQUE(token,type,target_id,weight)
写入按目标创建时间所在季度路由;删除按 target 扫描季度;查询逐 token 等值查询后归并(避免 IN (...) 走错索引)。
4.3 时间双轨制(最重要的兼容细节)
| 数据面 | 存储格式 | Go 读写规则 |
|---|---|---|
| business 表(users/messages/...) | TEXT 2006-01-02 15:04:05 | 按站点时区(默认 Asia/Shanghai)格式化/解析;禁止写 Unix 整数 |
| main_index / 桶 | INTEGER 秒 | 一律 time.Unix() 秒;禁止毫秒 |
| extern 文件 | 文件名含 ID,不含时间 | 不解析 |
| sessions.expires | INTEGER 秒 | 同 FH |
| queue.create_time/update_time/lease_until | INTEGER 秒 | 同 FH |
| search.created_at | TEXT Y-m-d H:i:s | 与 business 同一本地时间格式;按此路由季度 |
实现建议:
// lib/compat/dbtime
func FormatBusiness(t time.Time, loc *time.Location) string // "2006-01-02 15:04:05"
func ParseBusiness(s string, loc *time.Location) (time.Time, error)
func UnixSeconds(t time.Time) int64
验收:同一操作在 PHP 与 Go 写入后,对同一行做字段级 diff,时间字符串逐字节一致(时区一致时)。
4.4 分片路由
| 函数 | 规则 |
|---|---|
quarter(ts) | YYYY + Q + ceil(month/3) |
bucket(id,size) | id % size |
bucketForWrite(id, created) | 扩容生效(LAST_EXPANSION_AT>0 && created>=LAST 且 PREV<SIZE)→ PREV + id%(SIZE-PREV);否则 id%SIZE;非法配置回落 id%SIZE 并记日志 |
bucketRel(q,b) | bucket/active/{q}/{b}.sqlite |
bucketArchiveRel(q,b) | bucket/archive/{q}/{b}.sqlite |
bucketFromPath(p) | 严格正则解析;非法抛错 |
| 读取路由 | 只以 topic_index.bucket_path / reply_index.bucket_path 为准,绝不重算 |
4.5 外置正文(Extern)
路径:
extern/{YYYY}/{Qn}/{bucket}/t{topicID}.txt
extern/{YYYY}/{Qn}/{bucket}/r{replyID}.txt
归档块(同目录):
t|r{floor(id/1000)*1000}.bin # [4字节大端长度][正文]...
t|r{floor(id/1000)*1000}.idx # JSON { "id": offset }
读写顺序与红线:
- 路径安全校验(拒绝 NUL、
..、越出data/)。 - 读:进程内 LRU →
.bin/.idx→.txt;.bin存在但.idx缺失、ID 不在 idx → 回退.txt,前台不得 404。 .bin长度异常(<=0 或 >16MB)或短读 → 记 ERROR 并回退.txt。- 写:先写同目录临时文件 → 原子 rename;再更新 DB;旧路径按需删除。
- 删除:删
.txt+ 在.idx.lock下从 idx 移除并原子重写;.bin内死字节保留(不可变归档)。 - 局部读(摘要链路):只读前 N 字节,不污染完整正文缓存。
- 新帖只写
.txt,不写.bin/.idx;归档任务另行处理。
4.6 全局 ID
- 库:
meta/global_id.sqlite,表id_generator(table_name,last_id)。 - 算法:
BEGIN IMMEDIATE→INSERT OR IGNORE种子 →SELECT last_id→UPDATE last_id+1→COMMIT。 busy_timeout:取号期间 1000ms,结束恢复 5000ms;锁竞争重试 3 次,5–20ms 抖动。- 禁止:
AUTOINCREMENT、内存自增、按桶独立发号。
4.7 会话(PHP 兼容)
| 项 | 值/规则 |
|---|---|
| 库 | meta/sessions.sqlite |
| 表 | sessions(id TEXT PK, data TEXT, expires INTEGER) |
| TTL | 1800 秒;每次读/写续期 expires=now+1800 |
| 有效判定 | expires > now |
| SID | Go 侧生成 32 位十六进制(属于 PHP 允许字符集);PHP 侧创建的历史 SID 不做长度硬校验 |
| 校验 | 严格模式:SID 必须存在于库且未过期,否则丢弃并重新生成(validateId 语义) |
| 登录 | 换新 SID,删除旧行 |
| Cookie | PHPSESSID、Path=/(子目录为 BASE_PATH)、HttpOnly、SameSite=Lax、HTTPS 下 Secure |
| 数据格式 | PHP session 序列化:`user_id |
编解码要求:
- 支持
s(字符串,长度按字节)、i、b、d、N、a(嵌套数组)。 - 双向可逆:解析 → 重编码 → 与原文逐字节一致(未知键必须保留原顺序)。
- 读改写:只修改需要的键,未识别键原样保留;禁止整表重写为 JSON。
- 遇到不支持的标记:不重写该会话(返回只读),记 WARN;不得破坏用户登录态。
4.8 设置缓存与运行时缓存
| 文件 | 格式 | Go 动作 |
|---|---|---|
protected/settings_cache.php | <?php /* settings cache — do not include */ ?>\n{JSON}\n | 启动先解析;写设置后原子重建(tmp+rename) |
data/runtime/pages/*.html | HTML | 见 4.9 |
data/runtime/category_stats.cache | JSON/自定义 | 发帖/回帖/删帖后失效或重建 |
data/runtime/category_<id>_latest.json | JSON | 同上 |
protected/level_config_cache.php | PHP 包装 + 数据 | 等级配置变更后重建(与 FH 同步) |
设置写流程:
DB UPDATE/UPSERT settings
→ 清进程内设置缓存
→ 从 DB 全量读取 → 生成 JSON → 原子写 settings_cache.php
→ 失效相关页面缓存
4.9 页面缓存
| 项 | 规则 |
|---|---|
| 目录 | data/runtime/pages/ |
| 键 | 实际构造顺序:{页面名}[_pref{N}][_{lang}][_t{主题}];默认语言/默认主题不追加后缀(保证存量缓存零影响);?preview_theme= 单独命名 |
| 生效范围 | 仅游客 GET;登录用户不缓存 |
| 失效 | 发帖、回帖、删帖、置顶/高亮、版块变更、设置变更、主题变更 |
| 响应头 | Cache-Control: private, no-cache + 统一安全头 |
| 共存模式 | page_cache_shared=false 时 Go 写 pages_go/,避免两端模板差异互相污染;B10 视觉对齐后切 true |
4.10 搜索索引
- 分词:必须移植 FH
Segmenter(含词典与停用词),以"同一文本 → token+weight 集合完全一致"为验收。 - 写入:新帖/编辑/删除后写
rebuild_search队列任务;消费者调用索引重建。 - 重建:
cli/search_rebuild可全量重建;季度文件缺失时按目标时间创建。 - 双端共存:B2 完成后如分词一致性未达标,Go 只入队、不自行消费,交由 PHP worker 处理(配置项
search_consumer=php|go)。
4.11 任务队列与定时任务
| 项 | 规则 |
|---|---|
| 队列数 | 3(queue_0..2.sqlite) |
| 入队 | 随机选队列;dedupe_key 去重 |
| 消费 | 认领时写 lease_token/lease_until;租约超时可重领;retry_count 上限 3 |
| 锁 | 共享 data/lock/cron_trigger.lock(flock 语义)与 PHP cron 互斥 |
| 触发 | GET /cron/trigger 校验 X-Cron-Key(或 ?key= 过渡),空 key 返回 503 |
| 维护 | 会话 GC、审计清理、限流缓存清理、计数器校准、搜索重建、归档 |
已知任务类型(以 FH consumer 注册为准):rebuild_search、stats;B0 必须枚举全部类型并落表,Go consumer 对未知类型"记录 + 丢弃"。
4.12 附件与上传
| 项 | 规则 |
|---|---|
| 目录 | assets/uploads/(UPLOAD_PATH) |
| URL | /assets/uploads/(UPLOAD_URL) |
| 命名 | 随机名 + 白名单后缀;支持 YYYY-MM/ 与 seed_avatars/、covers/、pwa/ 命名空间 |
| 限制 | MAX_FILE_SIZE=5MB;后缀 jpg/jpeg/png/gif/webp/pdf/zip/doc/docx |
| 校验 | 扩展名白名单 + MIME 嗅探 + 图片解码校验;禁止 SVG 上传(种子头像除外) |
| 下载 | filepath.Base + SafeJoin + 登录/权限校验 |
4.13 兼容红线(实现期逐条检查)
- 不写
threads/posts。 - 不新增 FH 不认识的必填列。
- 不改
bucket_path、extern_path的语义。 - 不改
.idx的 JSON 结构(键必须是字符串 ID,值必须是整数偏移)。 - 不把会话数据写成 JSON/Go gob。
- 不在
data/外存储正文。 - 不用
AUTOINCREMENT。 - 不在启动时自动"修复/重排"现有数据。
- 不在 PHP 共存期轮换
remember_token(默认只校验)。 - 不在未对齐分词前让 Go 消费搜索重建任务。
5. 模块映射与功能清单
5.1 FH Helper → Go 包映射
| FH Helper | Go 位置 | 阶段 |
|---|---|---|
| Auth / RememberMe / Csrf / Permission | lib/session, lib/security, app/services/auth | B3 |
| Settings / Cache / PageCache | lib/splitdb/settings, lib/cache, lib/splitdb/pagecache | B2 |
| Upload / Content / Emoji | lib/upload, lib/security/content, app/services/content | B4 |
| Search / Segmenter / SearchRebuild | lib/splitdb/search, lib/compat/segmenter | B2/B5 |
| Mailer | lib/mail | B5 |
| Notification / Points / Vote / Tag | app/services/* | B4/B5 |
| Theme / UserPrefs / I18n | lib/template, lib/i18n, app/services/prefs | B1/B7 |
| Url / RewriteRules / RewriteProbe | lib/router(BASE_PATH、伪静态) | B1 |
| Captcha / PoW / RateLimiter / KeyGuard | lib/security/* | B3 |
| AuditLog | app/services/audit | B3 |
| Plugin / PluginUpdater / Market / Updater / release_keys | lib/plugin, app/services/market, app/services/updater | B8/B9 |
5.2 路由与控制器映射
完整路由清单见附录 A。分组如下:
| 分组 | 数量 | Go 包 |
|---|---|---|
| 前台页面 | 50 | app/handlers/* |
| 后台管理 | 84 | app/admin/* |
| API | 7 | app/api/* |
| 插件路由 | 动态 | lib/plugin 注册 |
5.3 视图清单(64 个)
| 分组 | 文件 | 阶段 |
|---|---|---|
| 布局/错误/离线 | layouts/main.php、errors/404.php、errors/429.php、offline.php | B1/B3 |
| 认证 | auth/login、auth/register、auth/forgot_password、auth/reset_password、auth/message | B3 |
| 论坛/主题/发帖 | forum/index、forum/category、thread/show、thread/edit、post/create、post/edit | B4 |
| 消息/通知/搜索/标签/用户/主题设置 | message/*、notifications/index、search/index、tags/*、user/show、theme/settings | B5 |
| 后台 | admin/*(31 个) | B6/B9 |
| 组件 | _components/*(7 个) | B7 收口 |
5.4 通用组件(必须优先转写)
left_sidebar、right_sidebar、mobile_sidebar、notification_bell、attachment_card、image_lightbox、footer_beian。
它们被大量页面引用,先完成组件再铺页面,能显著降低返工。
5.5 插件与钩子
- 49 个钩子按生命周期分组(完整表在 B0 从 FH
插件开发规范.md§9.1 复制为docs/COMPAT_HOOKS.md):- 初始化/路由:
init_after、route_register、route_before_dispatch; - 布局/导航:
layout_head_end、nav_plugin_links、footer_links、controller_view_before; - 内容:
topic_created、post_created、upload_url等; - 用户/后台/搜索等其余分组。
- 初始化/路由:
- Go 钩子总线要求:注册顺序 + priority 稳定排序;单个监听器 panic 隔离;同步执行;同一事件可重试,监听器需幂等。
- 插件形态:
- 静态插件:
plugins/<id>/plugin.go+plugin.json+views/+assets/,编译期空白导入; - 动态资源包:仅视图/静态资源,可在线安装;Ed25519 验签 + SHA-256 + ZIP Slip 防护。
- 静态插件:
- PHP 插件兼容边界:不执行 PHP;列出可迁移插件(blog/checkin 优先)、暂不支持插件(依赖 PHP 运行时的 AI/统计类需重写)。
6. 分阶段实施计划
说明:以下任务按依赖排序,不含工期估算。每个阶段结束必须提交验证报告并评审通过。
B0 工程基线与兼容样本
目标:在不写业务代码的前提下,把"什么是兼容"固化成可执行资产。
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B0-T1 | 初始化 Go 模块与目录 | go mod init sobbs;cmd/sobbs、lib、app、themes、plugins、tools、testdata | go build ./... 通过 |
| B0-T2 | 编码规范与 CI | gofmt、go vet、staticcheck、errcheck、go test -race;依赖方向检查脚本 | CI 全绿;新增依赖需评审 |
| B0-T3 | FH 只读样本库 | 采集 data/ 全目录(含 -wal/-shm)、extern .txt/.bin/.idx、sessions 样本、settings/page/level caches;脱敏后放 testdata/fixtures | 样本校验和清单;关键表行数快照 |
| B0-T4 | 兼容工具链 | tools/dbdump(表结构+行校验和)、tools/dualrun(PHP/Go 交替启停)、tools/externcheck、tools/phpsess | 工具可一键跑通;输出可比对报告 |
| B0-T5 | config.php 只读解析器 | 仅解析标量 define() 与 date_default_timezone_set;不 eval;缺失项报错 | 单测覆盖常量、引号、数组(ALLOWED_EXTENSIONS) |
| B0-T6 | 启动自检 doctor | 检查数据目录、0 字节占位库、关键表、WAL、时区、写权限、CRON_KEY、上传目录 | sobbs doctor 对样本库输出逐项 OK/FAIL |
退出条件:样本库可被工具完整读取;配置解析与自检通过;兼容清单(C1–C14)有测试用例占位。
B1 基础设施层
目标:路由、上下文、中间件、数据库、缓存、会话、模板骨架可独立运行并通过单测。
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B1-T1 | 日志与 request_id | slog JSON;request_id 生成/透传;字段规范 | 单测 + 日志样例评审 |
| B1-T2 | 路由 | {id} 参数、方法匹配、.html 后缀兼容、BASE_PATH、404/405、路由组 | 路由表单测;与 FH 路径逐条对照 |
| B1-T3 | Context | UserID/Role/GroupID/IP/Lang/Theme/RequestID;登录态只读快照 | 单测 |
| B1-T4 | 中间件链 | Recovery、RequestID、SecurityHeaders、BodyLimit、Timeout、Auth、RateLimit、CSRF 挂载点 | 集成测试:异常不泄内部错误 |
| B1-T5 | 数据库管理 | 连接池、PRAGMA(WAL/busy_timeout/synchronous/cache_size/temp_store/foreign_keys=OFF)、非 WAL fail-closed、LRU 桶连接、归档 query_only | 并发读写测试无 database is locked |
| B1-T6 | 进程内缓存 | KV + TTL + 主动失效 + singleflight | 单测;失效路径覆盖 |
| B1-T7 | PHP session 编解码 | s/i/b/d/N/a;字节长度;未知键保留;往返逐字节 | 往返测试 + fuzz + 样本会话 |
| B1-T8 | 会话存储 | 读/校验/续期/写/换 SID/GC;Cookie 属性 | 与 PHP 交替登录用例 |
| B1-T9 | 模板骨架与 i18n | 外置 themes/modern;layout/partials;FuncMap(raw/asset/url/pagination/t);mtime 失效;三语键集合自检 | 热更新测试;三语键差集为空 |
| B1-T10 | 静态资源服务 | /assets、/themes、/plugins 安全直出;MIME、缓存头、禁目录列表 | 路径穿越测试全拒绝 |
退出条件:go test -race ./lib/... 全绿;最小站点(首页占位、404、静态资源)可启动。
B2 SplitDB 数据层
目标:读写 FH 数据目录的每一个字节级对象,并通过双端校验。
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B2-T1 | business schema ensure | 逐字复刻 20 张表 DDL 与列补丁;不新增必填列 | 与样本库 PRAGMA table_info 逐列 diff 为空 |
| B2-T2 | main_index ensure | 2 表 + 全部索引同名同列序 | 索引快照 diff 为空 |
| B2-T3 | 桶 schema ensure | topic/reply + 3 索引 + user_version=2 | 新桶创建与老桶幂等 |
| B2-T4 | ShardRouter | quarter/bucket/bucketForWrite/路径/前缀/校验 | 表驱动单测覆盖扩容边界 |
| B2-T5 | IDGenerator | BEGIN IMMEDIATE、种子、重试、busy_timeout 还原 | 并发取号 10k 次无重复/无跳号异常 |
| B2-T6 | Extern 读 | .bin/.idx → .txt、limit 局部读、LRU、异常回退 | 样本 .bin/.idx 与 .txt 字节一致 |
| B2-T7 | Extern 写/删 | 原子写、旧路径清理、idx 锁内移除、死字节保留 | 写后 PHP 可读;删后两端均不可读 |
| B2-T8 | PageCache | 键组合、游客命中、失效、响应头 | 与 FH 缓存键逐名对比 |
| B2-T9 | Settings 缓存 | 解析/重建 settings_cache.php;统计快照与计数器 | Go 改设置 → PHP 读到新值 |
| B2-T10 | 搜索存储与分词 | 季度库、写入/删除/查询、Segmenter 移植 | 同文本 token/weight 集合完全一致 |
| B2-T11 | 队列存储 | 3 库、入队、租约认领、完成/重试、去重 | 与 PHP worker 交叉消费无重复执行 |
| B2-T12 | 迁移与 bootstrap | schema_migrations、只加不删、幂等探测、目录树创建 | 重复执行结果一致 |
退出条件:用 FH 样本库启动 Go,完成"读主题/回复/设置/会话"与"写新主题/回复/设置";PHP 端逐项读回一致;报告附字节级比对。
B3 认证与用户
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B3-T1 | 注册 | 用户名/邮箱规则、唯一性、初始组/等级/积分、审计 | 正反向用例 |
| B3-T2 | 登录 | bcrypt 校验($2y$/$2a$/$2b$)、封禁判定、换 SID、审计 | 与 PHP 账号互通 |
| B3-T3 | 记住登录 | 共存期只校验不轮换;完全切换后按 FH 规则轮换 | 双端交替不掉线 |
| B3-T4 | CSRF | token 生成/校验/滑续期/轮换/一次性消费;403 友好页 | 表单+JSON 用例 |
| B3-T5 | 权限模型 | role/group_id、user_groups、category_permissions、管理员组=4 | 与 FH 判定逐用例一致 |
| B3-T6 | 个人中心 | 资料、头像、签名、偏好、主题/布局 | 读写与视图对等 |
| B3-T7 | 找回密码 | token 一次性、短时效、邮件异步、重置后下线所有会话 | 正向 + 重放拒绝 |
| B3-T8 | 邮箱验证 | 发送/验证/重发节流 | 与 FH 语义一致 |
| B3-T9 | 限流/验证码/PoW | IP+账号双维度;文件或独立存储;PoW 校验 | 触发限流返回 429 视图 |
| B3-T10 | 审计与封禁 | 审计表写入、封禁拦截、节流 | 封禁用户所有入口均失效 |
| B3-T11 | 认证视图 | login/register/forgot/reset/message | 与 FH 截图对比 |
退出条件:PHP 建会话 → Go 识别;Go 建会话 → PHP 识别;密码/CSRF/权限用例全绿。
B4 论坛核心读写
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B4-T1 | 首页 | 统计、可见版块、最新/热门、置顶、在线 | 排序与 FH 完全一致 |
| B4-T2 | 版块页 | 分页、筛选、排序白名单、权限过滤 | 边界页码/空版块 |
| B4-T3 | 主题详情 | topic_index + 桶 + extern + 回复分页 + 附件 + 投票状态 | 正文逐字节一致 |
| B4-T4 | 发帖事务 | 取号 → 建桶写行 → extern → 索引 → 计数/标签/队列;失败补偿 | 断电式中断测试无半成品 |
| B4-T5 | 回帖事务 | 父帖桶写 reply → extern → reply_index → 计数/通知/积分 | 楼层顺序与时间正确 |
| B4-T6 | 编辑主题/回复 | 权限、版本、extern 重写、索引更新 | 编辑后两端一致 |
| B4-T7 | 删除/恢复/回收站 | 软删、硬删、归档只读、级联清理 | 无残留正文/索引 |
| B4-T8 | 上传与附件 | 类型/大小/MIME、随机名、缩略图、下载鉴权 | 恶意文件名/伪造 MIME 拒绝 |
| B4-T9 | 标签 | 关联、热门、详情、清理 | 计数一致 |
| B4-T10 | 投票/收藏/浏览 | 唯一约束、去重、计数原子更新 | 并发重复提交不重复计数 |
| B4-T11 | 版务操作 | 置顶/高亮/颜色/移动版块/归档 | 索引与桶同步 |
| B4-T12 | 计数一致性 | 增量更新 + 全量校准 + 每日兜底 | 与 FH runtimeBuild 结果一致 |
| B4-T13 | 论坛视图 | forum/index、forum/category、thread/show、post/create、post/edit、thread/edit | 截图 + 交互对比 |
退出条件:完成 7.3 兼容用例第 3–6、8–10 条;发帖/回帖在 PHP 端可正常展示。
B5 消息、通知、搜索、用户主页、PWA
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B5-T1 | 私信 | 收件箱/已发/撰写/详情/已读 | 与 FH 字段一致 |
| B5-T2 | 通知 | 类型、聚合、已读、铃铛 | 未读数一致 |
| B5-T3 | 搜索 | 查询解析、跨季度归并、过滤、分页、重建任务 | 与 PHP 搜索结果前 N 一致 |
| B5-T4 | 标签页 | 列表/详情/分页 | 对等 |
| B5-T5 | 用户主页 | 资料、帖子、回复、统计 | 对等 |
| B5-T6 | 主题/布局偏好 | site_theme Cookie、布局切换、侧栏开关 | 切换即时生效 |
| B5-T7 | PWA | manifest、offline、图标、SW 头 | 安装/离线可用 |
| B5-T8 | 在线状态 | 心跳、在线数、清理 | 与 FH 阈值一致 |
| B5-T9 | 邮件 | SMTP、异步、失败重试、日志脱敏 | 测试邮箱收到验证/重置信 |
退出条件:搜索索引读写双向可用;通知/私信/主页视图通过。
B6 后台核心
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B6-T1 | 仪表盘 | 站点概览、版本、告警、快捷入口 | 数据与 FH 一致 |
| B6-T2 | 数据库概览/引擎/设置 | 库大小、WAL、表行数、维护入口 | 只读安全 |
| B6-T3 | 站点设置(基础/系统/显示/邮件/搜索/队列/PWA) | 写 DB + 重建 settings_cache + 失效缓存 | PHP 端即时读到 |
| B6-T4 | 用户管理 | 列表、搜索、编辑、封禁、删除、积分/等级 | 权限与审计 |
| B6-T5 | 主题管理 | 列表、编辑、置顶、高亮、移动、删除 | 与桶/索引同步 |
| B6-T6 | 版块/标签管理 | CRUD、排序、权限入口 | 前台即时生效 |
| B6-T7 | 回收站 | 恢复/硬删/批量 | 无残留 |
| B6-T8 | 等级/用户组/权限 | 规则、组、版块权限矩阵 | 前台判定一致 |
| B6-T9 | 审核/审计日志 | 待审内容、日志查询与清理 | 审计完整 |
| B6-T10 | 维护 | 重建统计、清理缓存、搜索重建、会话 GC | 幂等、可重复执行 |
| B6-T11 | 后台视图 | 31 个 admin 视图文件转写 | 截图对比 |
退出条件:后台所有"会写数据"的操作在 PHP 端可见;设置缓存文件一致。
B7 视图收口、主题与响应式
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B7-T1 | 模板规范 | layout/define/partial、命名、上下文结构文档化 | 所有页面统一布局 |
| B7-T2 | 组件转写 | 7 个 _components 优先完成 | 复用页面无重复代码 |
| B7-T3 | 剩余页面 | 逐页对照 64 个视图清单销项 | 清单 100% 关闭 |
| B7-T4 | CSS/JS 复用 | 原样拷贝 assets;类名/ID/数据属性不改 | 视觉回归通过 |
| B7-T5 | 编辑器 | marked/turndown/lightbox/预览/上传 | 发帖链路可用 |
| B7-T6 | 响应式/暗色 | 三栏、移动侧栏、主题变量、夜间模式 | 多断点截图 |
| B7-T7 | 三语 | zh/en/zh_tw 键一致;页面缓存语言分键 | 切换无空白/无英文兜底 |
| B7-T8 | 资源指纹与缓存 | ?v=mtime;CSP 兼容 | 改 CSS 立即生效 |
| B7-T9 | 页面缓存共享 | 视觉对齐后切 page_cache_shared=true | 双端命中同一缓存无差异 |
退出条件:64 个视图销项完成;关键页面(首页/版块/主题/发帖/登录/后台首页)截图对比通过。
B8 插件体系与生态
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B8-T1 | 钩子总线 | 49 钩子注册/派发/优先级/panic 隔离/幂等 | 钩子表单测 |
| B8-T2 | 插件接口与注册表 | Name/Version/Init/RegisterRoutes;编译期空白导入 | 示例插件可开关 |
| B8-T3 | plugin.json 兼容 | 字段语义与 FH 一致;ID=目录名;activated 状态读写 | 与 FH 清单互读 |
| B8-T4 | 动态资源包安装 | ZIP entry 校验、体积上限、原子移动、类型白名单 | ZIP Slip/炸弹测试拒绝 |
| B8-T5 | 签名与信任 | Ed25519 + SHA-256 + min_core;无签名仅开发者模式 | 篡改包拒绝 |
| B8-T6 | 后台插件页 | 列表、编辑配置、启用/停用、卸载、权限强制 | 操作落盘一致 |
| B8-T7 | 博客插件 | 兼容遗留 blogs 表(D1 决策)或自有表 | 文章读写/评论 |
| B8-T8 | 签到插件 | 积分、连续签到、审计 | 与 FH 规则一致 |
| B8-T9 | 插件迁移指南 | 49 钩子映射、PHP→Go 改造示例、限制清单 | 文档评审通过 |
退出条件:至少 2 个静态插件可启停;动态资源包可安装/卸载;钩子派发不破坏主流程。
B9 高级后台、异步与运维
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B9-T1 | 队列 worker | 长驻进程、租约、退避、优雅退出、指标 | 与 cron 互斥 |
| B9-T2 | cron 触发 | X-Cron-Key、空 key 503、flock 兼容 | 与 PHP cron 共用锁不重入 |
| B9-T3 | 归档任务 | 18 个月冷数据移入 archive、只读、路径更新 | 归档后前台可读、不可回复 |
| B9-T4 | 备份/恢复 CLI | 整目录(含 wal/shm)、校验行数、恢复演练 | 备份可恢复 |
| B9-T5 | 升级器 | 版本语义、预检、备份、锁、应用、回滚 | 同 MAJOR 可回滚 |
| B9-T6 | 插件市场 | 清单同步、授权、下载、验签、安装 | 与 FH 市场协议兼容 |
| B9-T7 | 主题上传/编辑 | 路径白名单、ZIP 安全、CSS 保存 | 上传/删除安全 |
| B9-T8 | 日志与监控 | 轮转、慢请求、队列积压、5xx 指标 | 指标可导出 |
| B9-T9 | 热升级 | syscall.Exec 或 fork+exec;Connection: close | 低峰升级可回滚 |
| B9-T10 | 运维 CLI | doctor/backup/restore/verify-index/rebuild-search/migrate | 命令帮助与退出码规范 |
退出条件:队列/归档/备份/升级四类演练通过;无数据丢失。
B10 全量兼容验收与发布
| ID | 任务 | 实现要点 | 验收 |
|---|---|---|---|
| B10-T1 | 10 条双端用例 | 按白皮书 10.3 逐条执行(本方案 7.3 已展开) | 全绿 |
| B10-T2 | 极端样本 | 超长标题、emoji、引号/script、中文用户名、空正文、超大页码 | 两端一致 |
| B10-T3 | 字节级校验 | extern 内容、settings/page cache、session data | diff 为空 |
| B10-T4 | 性能压测 | 2C2G 目标;静态页/主题页/写入 | P95 达标,无 5xx,无锁错误 |
| B10-T5 | 安全检查 | 路径/SQL/XSS/CSRF/上传/限流/头部 | 高危项清零 |
| B10-T6 | 灰度切换演练 | 0→5%→50%→100%,回退秒级 | 演练报告 |
| B10-T7 | 打包发布 | Linux amd64/arm64、Windows 开发包;校验和 | 单文件可启动 |
| B10-T8 | 文档交付 | 部署、运维、插件开发、兼容说明、已知差异 | 评审通过 |
退出条件:发布检查表全绿;评审会签通过;可进入灰度。
7. 测试策略
7.1 测试分层
| 层 | 范围 | 工具 |
|---|---|---|
| 单元 | router、phpsess、shardrouter、config、safejoin、模板函数 | go test + table-driven |
| 集成 | handler + 临时 data 目录 + 真实 SQLite | httptest + fixtures |
| 兼容 | 与 FH 样本/PHP 双端 | tools/dualrun + dbdump |
| 端到端 | 浏览器关键路径 | browser automation |
| 性能 | 列表/主题/写入/搜索 | k6 或 wrk |
| 安全 | gosec、govulncheck、fuzz | CI |
7.2 兼容夹具要求
- 覆盖所有库与目录(含
-wal/-shm)。 - 至少包含:正常主题、超长正文、纯 emoji 标题、已删除主题、归档主题、
.bin/.idx归档块、损坏.idx、缺失桶文件。 - 会话样本:匿名、登录、管理员、封禁、过期。
- 每次样本变更必须更新校验和清单,禁止手工改样本。
7.3 双端切换用例(发布门槛)
按白皮书 10.3 执行,并补充 Go→PHP 方向:
| # | 用例 | 期望 |
|---|---|---|
| 1 | PHP 注册登录,Go 携带 Cookie | 仍登录 |
| 2 | Go 登录,PHP 携带 Cookie | 仍登录 |
| 3 | Go 发帖(中文+富文本) | PHP 正确显示 |
| 4 | PHP 回帖,Go 读取 | 楼层/时间正确 |
| 5 | 两端改设置 | 另一端即时生效 |
| 6 | Go 删除主题,PHP 查看 | 已删除、无残留 |
| 7 | 归档帖 Go 回复 | 拒绝且提示一致 |
| 8 | 分页/排序/置顶/高亮 | 顺序完全一致 |
| 9 | remember_token 交替登录 | 不掉线(共存期不轮换) |
| 10 | 页面缓存命中 | 两端渲染无差异(共享模式下) |
7.4 PHP 会话编解码专项
- 单元往返:解析 → 编码 → 逐字节比较。
- Fuzz:随机键/嵌套数组/超长字符串/非法序列化串,要求不 panic、不丢未知键。
- 双端:Go 改写
csrf_time后 PHP 能继续读取。
7.5 数据一致性专项
dbdump:表结构、行数、关键列校验和。- 写入后:桶行、
topic_index、reply_index、extern、搜索索引、计数器六处一致。 - 中断测试:事务中杀进程,重启后无半成品(
schema_migrations、队列租约)。 - 并发测试:同帖并发回复、并发投票、并发取号。
7.6 性能门槛(建议)
| 场景 | 目标 |
|---|---|
| 游客首页(命中页面缓存) | P95 < 50ms |
| 版块列表(1000 帖) | P95 < 150ms |
| 主题详情(50 回复) | P95 < 200ms |
| 发帖/回帖写入 | P95 < 250ms |
| 搜索(一年数据) | P95 < 500ms |
| 2 小时压测 | 无 5xx、无 database is locked 持续增长 |
7.7 安全检查表
| 项 | 要求 |
|---|---|
| 路径穿越 | 所有名字输入过 SafeJoin;fuzz 全拒绝 |
| SQL | 全参数化;排序白名单 |
| XSS | 默认转义;净化器白名单标签/协议 |
| CSRF | 所有状态变更校验;登录/登出轮换 |
| 会话 | 固定防护、换 SID、Cookie 属性 |
| 上传 | 后缀+MIME+大小+随机名;禁可执行类型 |
| 限流 | 登录/注册/找回/搜索/上传 |
| 信息泄露 | 错误响应无内部细节;目录不可列 |
| 依赖 | govulncheck 无已知高危 |
| 日志 | 无密码/token/完整 Cookie |
7.8 CI 流水线
lint → unit → integration → race → compat(fixtures) →
nightly: dualrun(PHP) + 性能基准 + 安全扫描
8. 迁移与上线
8.1 上线前数据准备
- 整目录备份
data/(含-wal/-shm),校验关键表行数。 - 确认
data/meta/business.sqlite存在且非 0 字节;根目录 0 字节占位库忽略。 - 确认
protected/settings_cache.php可写;data/lock、data/runtime可写。 - 记录当前 PHP 版本、FH 版本、桶大小、扩容时间戳。
8.2 灰度切换
| 阶段 | 流量 | 观察 | 回退 |
|---|---|---|---|
| 0 | 内网 | 接口正确性、数据 diff | 切回 PHP |
| 1 | 5% 读 | 5xx、P95、缓存命中 | 切回 PHP |
| 2 | 50% 读写 | 数据一致性抽检、队列 | 切回 PHP |
| 3 | 100% | 全量指标 | 切回 PHP |
切换动作只改 Nginx upstream;不迁移数据。
8.3 回滚
- 同 MAJOR:直接换回旧二进制,不碰 data/。
- 升级失败:用备份覆盖
data/+ 放回旧二进制。 - 不允许用
rsync --delete同步data/。
8.4 运行方式
- systemd:
ExecStart=/var/www/sobbs/sobbs,Restart=on-failure,工作目录站点根。 - Nginx:静态
/assets、/themes、/plugins直出,其余反代127.0.0.1:8080。 - 定时任务:系统 cron 调用
curl -H "X-Cron-Key: ..." /cron/trigger,或sobbs worker常驻。
8.5 监控指标
| 指标 | 告警阈值 |
|---|---|
| 5xx 率 | > 0.5% 持续 5 分钟 |
| P95 延迟 | 超 7.6 门槛 2 倍 |
SQLITE_BUSY 日志 | > 10 次/分钟 |
| 队列积压 | pending > 1000 或最老任务 > 10 分钟 |
| 搜索索引漂移 | 重建后 target 数差 > 0 |
| 页面缓存失效失败 | 任意一次写失败 |
9. 风险登记表
| ID | 风险 | 影响 | 概率 | 缓解措施 | 检测信号 |
|---|---|---|---|---|---|
| R1 | 漏拷 -wal/-shm | 空库、数据"消失" | 中 | 备份/部署脚本强制整目录;启动校验行数 | doctor FAIL |
| R2 | 时间格式混用 | 排序错、显示 1970 | 高 | 双轨制封装;字段级 diff 测试 | 双端对比不一致 |
| R3 | 会话格式不兼容 | 切换掉登录 | 高 | phpsess 往返 + 双端登录 | 双端用例 1/2 失败 |
| R4 | settings_cache 不同步 | 后台设置不生效 | 中 | 写 DB 后原子重建;启动对账 | PHP 读旧值 |
| R5 | 页面缓存跨端污染 | 换主题/语言不生效 | 中 | 默认独立命名空间;对齐后共享 | 截图差异 |
| R6 | SQLite 写竞争 | 502/超时 | 中 | WAL + busy_timeout + 重试 + 单写者 | BUSY 日志 |
| R7 | 扩容路由错误 | 新帖进旧桶 | 低 | bucketForWrite 表驱动测试 | 桶分布异常 |
| R8 | extern 路径/idx 漂移 | 正文串读/丢失 | 中 | SafeJoin + idx 锁 + 回退 .txt | 字节 diff |
| R9 | 分词不一致 | 搜索结果漂移 | 高 | 移植 + token 集合验收;未达标交 PHP | 搜索对比 |
| R10 | 64 视图转写回归 | 视觉/交互破损 | 高 | 组件优先 + 截图回归 + 逐页销项 | 视觉 diff |
| R11 | 后台范围蔓延 | 延误主线 | 高 | B6 核心 / B9 高级分阶段;市场/升级可延期 | 里程碑评审 |
| R12 | PHP 插件不可用 | 站长功能缺失 | 高 | 明确边界;优先重写 blog/checkin | 插件清单 |
| R13 | bcrypt 兼容 | 切换后无法登录 | 低 | 校验多前缀;生成 PHP 可验格式 | 登录测试 |
| R14 | config.php 解析脆弱 | 启动失败/错路径 | 中 | 仅标量解析 + 明确报错 + config.json 覆盖 | doctor |
| R15 | modernc SQLite 行为差异 | 锁/事务异常 | 低 | 固定版本;禁用 RETURNING 依赖;并发测试 | race/并发用例 |
| R16 | 缓存快照文件漏维护 | 列表陈旧 | 中 | 纳入写路径失效清单 | 内容对比 |
10. 待决策项(需评审确认)
| ID | 决策点 | 选项 | 建议默认 |
|---|---|---|---|
| D1 | 遗留博客 3 表归属 | A. Go 博客插件接管;B. 只读保留;C. 上线前迁移到插件自有库 | A(保持数据可读,表名不变) |
| D2 | PHP 插件兼容范围 | A. 仅核心功能;B. 尽力兼容全部;C. 提供迁移工具 | A + C |
| D3 | 市场/升级器是否进 v1 | A. 进;B. 延后到 v1.1 | B(先保证核心稳定) |
| D4 | 页面缓存共存策略 | A. 立即共享;B. 独立目录,B10 后共享 | B |
| D5 | 设置缓存写权 | A. Go 写;B. 仅 PHP 写,Go 只读 | A(否则 Go 改设置 PHP 不生效) |
| D6 | remember_token 轮换时机 | A. 共存期不轮换;B. 立即轮换 | A |
| D7 | 会话 TTL | A. 保持 1800;B. 改为更长 | A |
| D8 | 时区来源 | A. config.php;B. config.json 覆盖 | A + B 可覆盖 |
| D9 | 写队列默认 | A. 默认直写;B. 默认单写者 | A,高并发再开 |
| D10 | 上传直出 | A. Nginx;B. Go | A(生产),Go 仍支持下载鉴权 |
| D11 | 分词器实现 | A. 完全移植;B. 简化分词 + 重建索引 | A(否则索引漂移) |
| D12 | 前台是否 JSON 化 | A. 保持 HTML;B. API 化 | A |
11. 附录
附录 A:路由清单(来自 index.php)
前台(50)
GET / GET /index.php
GET /__rewrite_probe GET /manifest.webmanifest
GET /offline GET /forum
GET /forum/category/{id} GET /thread/{id}
POST /thread/{id} POST /thread/{id}/delete
GET /thread/{id}/edit POST /thread/{id}/edit
GET /post/new POST /post/new
GET /post/{id}/edit POST /post/{id}/edit
GET /attachment/{id} GET/POST /login
GET/POST /register POST /logout
GET/POST /forgot-password GET/POST /reset-password/{token}
GET /verify-email/{token} GET/POST /resend-verification
GET/POST /notifications GET/POST /profile
GET /message GET/POST /message/inbox
GET/POST /message/sent GET/POST /message/compose
GET /message/view/{id} GET/POST /search
GET /tags GET /tag/{id}
GET/POST /theme-settings POST /layout-toggle
POST /toggle-sidebar GET /u/{id}
后台(84,按模块)
| 模块 | 路由数 | 说明 |
|---|---|---|
| 仪表盘 | 2 | /、/dismiss-key-warn |
| 数据库 | 7 | /database、engine、overview、settings |
| 站点设置 | 17 | basic/system/display/mail/search/queue/pwa + test-rewrite |
| 用户 | 2 | 列表/编辑 |
| 主题 | 2 | 列表/编辑 |
| 版块 | 2 | 列表/编辑 |
| 标签 | 2 | 列表/编辑 |
| 回收站 | 2 | 列表/操作 |
| 等级 | 2 | 列表/编辑 |
| 审核 | 2 | 列表/操作 |
| 维护 | 2 | 列表/操作 |
| 用户组/权限 | 4 | groups、permissions |
| 审计日志 | 1 | /logs/audit |
| 主题外观 | 6 | 默认/保存 CSS/选项/上传/删除 |
| 插件 | 7 | 列表/编辑/启停/卸载/权限强制 |
| 升级 | 15 | check/preflight/fetch/unlock/allow-unsigned/backup/apply/rollback/... |
| 市场 | 9 | sync/callback/install/activate/auth-start/auth-clear/selfcheck/prune |
API(7)
POST /vote POST /upload POST /fetch-image
GET /captcha GET /pow GET /favorites-count
GET /post/{id}/body
附录 B:数据库对象清单
| 库 | 表/对象 | 关键约束 |
|---|---|---|
| business.sqlite | 20 核心 + 3 遗留 | 不删不改;只加可选列 |
| main_index.sqlite | topic_index、reply_index | 索引同名同列序 |
| bucket/active | topic、reply | 秒级时间;user_version=2 |
| bucket/archive | 同上 | query_only=1 |
| sessions.sqlite | sessions | id 主键;expires 索引 |
| global_id.sqlite | id_generator | 种子 topic/reply |
| queue_0..2 | task_queue | 租约 + 重试 |
| search_YYYYQn | search_index | created_at 为 TEXT;唯一(token,type,target_id,weight) |
附录 C:49 钩子分组(B0 落表)
| 分组 | 代表钩子 |
|---|---|
| 初始化/路由 | init_after、route_register、route_before_dispatch |
| 布局/渲染 | layout_head_end、controller_view_before、footer_links |
| 导航/入口 | nav_plugin_links 等 nav_* |
| 内容生命周期 | topic_created、post_created 等 |
| 上传/资源 | upload_url 等 |
| 用户/权限 | user_* 等 |
| 后台 | admin_* 等 |
| 搜索/其他 | search_* 等 |
完整 49 条表由 B0 从
fh_reference/flinthub1.5.0/docs/插件开发规范.md§9.1 复制生成docs/COMPAT_HOOKS.md,并要求"钩子数/调用点数/双向差集"自检脚本通过。
附录 D:视图清单(64)
| 目录 | 文件数 | 说明 |
|---|---|---|
layouts/ | 1 | main.php |
_components/ | 7 | 侧栏、通知铃、附件卡、灯箱、备案页脚 |
auth/ | 5 | 登录/注册/找回/重置/提示 |
forum/ | 2 | 首页/版块 |
thread/ | 2 | 详情/编辑 |
post/ | 2 | 新建/编辑 |
message/ | 4 | 收件/已发/撰写/详情 |
notifications/ | 1 | 通知列表 |
profile/ | 1 | 个人中心 |
search/ | 1 | 搜索 |
tags/ | 2 | 列表/详情 |
theme/ | 1 | 主题设置 |
user/ | 1 | 用户主页 |
errors/ | 2 | 404/429 |
| 根 | 1 | offline.php |
admin/ | 31 | 后台页面与子目录(含 _sidebar、_market_action、database/、logs/) |
附录 E:关键常量与 Cookie 对照
| 对象 | FH 名 | Go 处理 |
|---|---|---|
| 会话 Cookie | PHPSESSID | 原样 |
| 记住登录 | remember_token / remember_user_id | 共存期只校验 |
| 主题 | site_theme | 原样 |
| 语言 | flinthub_lang | 原样(zh/en/zh_tw) |
| 上传 | UPLOAD_PATH=assets/uploads/ | 原样 |
| 分页 | POSTS_PER_PAGE/THREADS_PER_PAGE=20 | 原样,可配置 |
| 桶 | SPLITDB_BUCKET_SIZE=32 | 原样,支持扩容 |
| 定时密钥 | CRON_KEY + X-Cron-Key | 原样 |
附录 F:术语
| 术语 | 含义 |
|---|---|
| 主库 | business.sqlite |
| 索引库 | main_index.sqlite |
| 桶 / 分片 | 季度目录下的 *.sqlite |
| 外置正文 | extern/ 下的 t/r*.txt 与 .bin/.idx |
| 真相源 | 读取内容时唯一可信的数据来源 |
| 兼容夹具 | 用于验证 FH 数据读写的只读样本与脚本 |
| 阶段门 | 每阶段结束的验证报告评审 |