SoBBS 架构白皮书
项目代号:SoBBS | 底层语言:纯血 Go (Golang) | 项目域名:sobbs.cn
技术定位:极致轻量 · 单文件部署 · 超强 API 后端 · 复刻 FlintHub 微内核生态
基线版本:FlintHub PHPV1.5.0(剥离博客后的纯净版架构)| 项 | 内容 |
|:--|:--|
| 文档名 |ARCHITECTURE.md(项目库根级) |
| 文档版本 | V1.0 · 定稿 |
| 定稿日期 | 2026-11-03 |
| 状态 | 立项定稿,可据以开工 |
| 事实基线 | FlintHub PHP V1.5.0 代码实测,非纯方案推演 |
| 适用读者 | 后端开发、插件开发者、运维、站长 |修订记录
| 版本 | 日期 | 变更 |
|:--|:--|:--|
| V1.0 | 2026-11-03 | 合并原第九、十、十一章;新增「生产级安全基线」「版本契约与平滑升级」「日志与可观测性」三章;补入「数据兼容性与双端切换验收」;对原草案中 9 处与 FH 实际实现不符的描述做了事实修正(逐条见 [附录 D](#附录-d与原草案的差异说明)) |摘要
- 一次降维进化,不是重写。 SoBBS 继承 FlintHub(下称 FH)的全部业务语义与数据资产,只替换运行时:用常驻内存 + 协程并发,替掉 FPM 短进程模型。
- 数据零迁移。 不重做表结构、不重做分片布局、不重做正文外置格式,Go 直接读写 FH 现有的 SplitDB 数据目录,PHP ⇄ Go 双后端可随时切换、互为兜底。
- 零第三方框架。 仅用
net/http、database/sql、html/template与一个纯 Go SQLite 驱动,路由、模板、缓存、会话、日志全部自研。 - 插件即契约。 接口 + 注册制 + 编译期注入,配 Ed25519 签名校验;动态可换的只有视图、静态资源、配置。
- 可运营。 单二进制替换升级、幂等增量迁移、同主版本内可回滚、全链路
request_id。
一句话:把 FH 的业务沉淀与架构美学,搬进一个 18MB 的文件里,并且随时能搬回去。
目录
- [一、核心理念与永久原则](#一核心理念与永久原则)
- [二、核心架构:动静分离与 MVC](#二核心架构动静分离与-mvc)
- [三、插件体系底层设计](#三插件体系底层设计核心灵魂)
- [四、复刻 FH 实施路线图](#四复刻-fh-实施路线图)
- [五、全局状态与中间件设计](#五全局状态与中间件设计)
- [六、双端无缝 Session 机制](#六双端无缝-session-机制)
- [七、统一错误处理规范](#七统一错误处理规范)
- [八、开发规约与避坑指南](#八开发规约与避坑指南)
- [九、部署与运行](#九部署与运行)
- [十、数据兼容性与双端切换验收](#十数据兼容性与双端切换验收)
- [十一、生产级安全基线](#十一生产级安全基线)
- [十二、版本契约与平滑升级规范](#十二版本契约与平滑升级规范)
- [十三、日志、request_id 与可观测性](#十三日志request_id-与可观测性)
- [十四、总结](#十四总结)
- [附录 A:错误码总表](#附录-a错误码总表)
- [附录 B:FH V1.5.0 数据结构实测清单](#附录-bfh-v150-数据结构实测清单)
- [附录 C:上线前检查清单](#附录-c上线前检查清单)
- [附录 D:与原草案的差异说明](#附录-d与原草案的差异说明)
- [附录 E:术语表](#附录-e术语表)
阅读指引
| 你是 | 先读 | 再读 |
|:--|:--|:--|
| 后端开发 | 一、二、五 | 六、七、八 |
| 插件开发者 | 三 | 附录 B、附录 C |
| 运维 / 站长 | 九、十二 | 十、十三 |
| 安全评审 | 十一 | 十、十二 |一、核心理念与永久原则
1.1 为什么要用 Go 重写
FH 的 PHP 实现已经优化到相当程度:SplitDB 分片、正文外置、游客页静态缓存、请求内零同步外呼,单机在 2 核 2G 上跑得动。但物理瓶颈不在代码,在运行时形态:
| 维度 | PHP-FPM(FH 现状) | Go(SoBBS) |
|:--|:--|:--|
| 进程模型 | 每请求复用 worker,请求结束即释放上下文 | 常驻进程,上下文长期驻留 |
| 冷启动开销 | 每请求重新加载 autoloader、配置、插件索引 | 启动时一次性完成,请求期零重复加载 |
| 内存基线 | 每个 worker 一份 PHP 堆,worker 数 × 基线上浮 | 单进程共享堆,连接与缓存天然共享 |
| 并发模型 | worker 数即并发上限,超出排队 | goroutine,万级并发以 KB 计 |
| 部署物 | 数百个 PHP 文件 + 扩展依赖 | 单个二进制文件 |
| 热路径 | 每请求都要过一遍框架初始化链 | 初始化一次,路由直命中 |结论不是"PHP 慢",而是"每请求重建世界"这件事本身有成本。SoBBS 把这件事从"每请求一次"改成"每进程一次"。
1.2 绝对自研原则
- 严禁引入 Gin、Echo、Chi、Fiber 等第三方 Web 框架。
- 严禁引入 GORM、Ent、sqlx 等 ORM / 查询构建器。
- 允许的第三方依赖,仅限底层驱动与密码学原语,且必须满足:纯 Go(无 CGO)、可交叉编译、许可证宽松、体量可控。当前白名单只有一项:
modernc.org/sqlite。 - 标准库为主:
net/http、database/sql、html/template、log/slog、crypto/*、encoding/json。
自研的边界:不重造 SQLite,不重造 HTTP,不重造 TLS。除此之外的每一层(路由、模板、会话、缓存、钩子、日志、迁移)都自己写,保证任何一行行为都能在仓库里找到出处,杜绝"黑魔法"。
1.3 数据 100% 兼容原则
- SoBBS 不重做数据结构,直接读写 FH V1.5.0 现有的 SQLite 数据目录。
- 保证 PHP ⇄ Go 双后端随时切换、互为兜底:切换时不迁移数据、不改变文件布局、不要求用户重新登录。
- 兼容是双向的:Go 写入的数据,PHP 版必须能正常读出。
这条原则的落地细则与验收方法见 [第十章](#十数据兼容性与双端切换验收),它是本白皮书里唯一"不可协商"的承诺。
1.4 六条永久原则
以下六条是架构评审的一票否决项,任何后续迭代不得以"性能 / 工期 / 方便"为由突破:
| 编号 | 原则 | 判定方式 |
|:--|:--|:--|
| P1 | 单二进制部署,除数据与主题外无运行时依赖 | 空目录放入二进制 + 数据即可启动 |
| P2 | 数据零迁移,PHP 与 Go 可互读互写 | 双端切换验收脚本全绿 |
| P3 | 零第三方 Web 框架与 ORM |go.mod依赖清单评审 |
| P4 | 写操作串行化,读操作不排队 | 压测中读延迟不随写压力上升 |
| P5 | 升级不删数据、不破坏回滚 | 升级后回滚演练通过 |
| P6 | 所有对外错误不外泄内部细节 | 错误响应体审计 |二、核心架构:动静分离与 MVC
SoBBS 采用严格的"外置 MVC":主程序只做纯数据引擎,视图与资源全部落在磁盘上。
2.1 生产目录布局
/var/www/sobbs/ ├── sobbs # 主程序(单文件二进制,约 18MB) ├── config.json # 站点配置(极简,见 2.5) ├── data/ # 【数据层】与 FH V1.5.0 完全同构,Go 直接接管 │ ├── meta/ │ │ ├── business.sqlite # 主库:用户/分类/设置/通知/日志等 │ │ ├── main_index.sqlite # 主题与回复索引(topic_index / reply_index) │ │ ├── sessions.sqlite # 会话库(双端共用的唯一会话真相源) │ │ └── global_id.sqlite # 全局 ID 发生器(id_generator) │ ├── bucket/ │ │ ├── active/<YYYYQn>/<0..31>.sqlite # 当季分片,路由 = id % 32 │ │ └── archive/<YYYYQn>/<0..31>.sqlite # 归档冷数据(只读) │ ├── extern/<年>/Q<季>/<桶>/t|r<id>.txt # 正文外置(长文不占索引库) │ └── runtime/pages/ # 游客列表页静态缓存 ├── themes/ # 【外置】模板视图层(Go 版新增) │ ├── default/ # 默认主题,复刻 FH 三栏布局 │ └── mobile/ # 移动端自适应模板 ├── plugins/ # 【外置】插件视图与静态资源 │ └── blog/ │ ├── views/ # 插件视图模板 │ └── assets/ # 插件 CSS/JS └── assets/ # 【外置】全局静态资源(CSS/JS/图标/上传)三点必须说清:
data/是 FH 的原生布局,不是为新程序设计的。 目录名、库文件名、桶编号规则、正文文件命名(t<id>.txt/r<id>.txt)全部沿用,SoBBS 只是换了个读者。- FH V1.5.0 的真实库在
data/meta/下。 站点根目录里的data/business.sqlite、data/main_index.sqlite是 0 字节占位文件。部署迁移时必须搬data/meta/整目录,连同-wal/-shm一起;只拷.sqlite会得到一个"能打开但全空"的库,并且不报错。 themes/是 Go 版新增的一层。 FH 的模板在app/Views/(PHP 混编),无法直接给 Go 用;因此 Go 版把 FH 的 HTML 结构转写为html/template语法后放入themes/default/。这是"结构复刻",不是"文件复制"。
2.2 视图层:极简外置
- 彻底抛弃
embed。 模板 HTML 不编译进二进制,全部外置在themes/。 - 改皮肤即改文件。 站长改 HTML/CSS 后刷新浏览器即生效,无需重启、无需重新编译。模板加载器按 mtime 做缓存失效:文件没变就用内存里的已编译模板,变了就重编译。
- 静态资源指纹刷新。 外置 CSS/JS 变更后为避免浏览器强缓存,模板输出时附带文件修改时间戳:
style.css?v=1717401600。这与 FH 现有的?v=filemtime做法一致,站长心智不变。
模板引擎需提供的最小能力集:
| 能力 | 说明 |
|:--|:--|
|{{ }}变量输出 | 默认 HTML 转义(html/template原生行为) |
|raw函数 | 返回template.HTML,仅用于已净化的富文本正文 |
| 布局继承 |layout.html+{{define "content"}}分块 |
| 局部模板 |{{template "partial/header.html" .}}|
| 分页助手 |pagination(total, page, size)生成页码结构 |
| 资源助手 |asset("css/modern.css")自动追加?v=mtime|
| URL 助手 |url("/thread/12")自动拼BASE_PATH,兼容子目录部署 |转义红线:
raw只能包裹经过服务端净化的正文 HTML。任何用户可控字段(标题、签名、短消息)一律走默认转义。FH 的 CSP 中img-src允许https:但不含blob:,Go 版沿用同一策略。2.3 业务层:纯 API 引擎
后端不再负责拼装页面逻辑,核心只做四件事:路由分发、权限校验、数据 CRUD、事件派发。页面由模板层消费同一套数据渲染,因此同一份业务代码天然同时服务 Web、H5、App、小程序。
统一响应结构:
{ "code": 200, "msg": "ok", "data": { } }| 字段 | 类型 | 约定 |
|:--|:--|:--|
|code| int | 业务码,与 HTTP 状态码解耦(详见第七章) |
|msg| string | 面向用户的提示语,可直接展示 |
|data| object / array / null | 无数据时为null,不为{}|时间字段统一为秒级 Unix 时间戳(int64),字段名统一
create_time/update_time,与 FH 现有库字段完全一致,避免双端切换时出现单位错乱。2.4 请求生命周期
为确保行为对齐,SoBBS 的生命周期刻意复刻 FH
index.php的执行顺序:① 读取 config.json(失败即退出并给出可读原因) ② 初始化 slog + 生成 request_id ③ 打开数据库连接池(business / main_index / sessions / global_id + 分片懒加载) ④ 会话解析:读 Cookie → 查 sessions 表 → 还原用户身份 ⑤ 构建 Context(UserID / Role / IP / Lang / RequestID) ⑥ 全局中间件链:Recovery → RequestID → CORS → Auth → RateLimit ⑦ 路由匹配:静态资源 → 前台页面 → /api → 插件注册路由 ⑧ 业务处理 → 模板渲染 或 JSON 输出 ⑨ 收尾:批量写回计数(浏览数 / 曝光)、刷新缓存、释放连接与 FH 的对应关系:
| FH 阶段 | SoBBS 阶段 | 备注 |
|:--|:--|:--|
|display_errors=0置于最前 | ① 配置加载即设定日志级别 | 目的相同:绝不让警告污染 JSON |
| 游客前置静态缓存 | ③ 之后、④ 之前做静态命中 | 无 Cookie 或未登录 → 直读data/runtime/pages/|
|Plugin::init()+hook('init_after')| ⑥ 之后派发init_after| 钩子名保持一致,便于迁移 |
|register_shutdown_function刷计数 | ⑨ 收尾批量写回 | 读时只累加内存计数,响应后一次 UPDATE |FH 的
route_before_dispatch存在"派发两次"的历史行为。Go 版只派发一次,此差异已在附录 D 记录,迁移插件时需留意。2.5 配置文件
config.json只放进程级配置;站点业务配置(站点名、备案号、开关项)仍在business.sqlite的settings表里,与 FH 共用,站长在后台改完两端都生效。{ "listen": "127.0.0.1:8080", "base_path": "", "data_dir": "./data", "theme": "default", "session_cookie": "PHPSESSID", "session_ttl": 1800, "log_level": "info", "log_dir": "./logs", "trusted_proxies": ["127.0.0.1"], "read_timeout": 15, "write_timeout": 30 }session_cookie默认值必须是PHPSESSID,而不是自定义名。原因见 [6.2](#62-cookie-名与会话固定防护)。三、插件体系底层设计(核心灵魂)
SoBBS 的插件体系不依赖任何第三方框架,完全基于 Go 的"接口 + 注册制 + 编译期注入"三件套。
3.1 目录结构
plugins/ └── blog/ # 插件目录(ID 小写,与注册名严格一致) ├── plugin.go # 【必需】后端逻辑与注册入口(编译期参与构建) ├── plugin.json # 【必需】元信息清单(运行期可读,供后台展示与签名校验) ├── views/ # 【外置】前端模板 │ ├── index.html │ └── detail.html └── assets/ # 【外置】静态资源 └── style.cssplugin.json严格沿用 FH 的字段语义与取值形态,保证同一份清单两端都能读。插件 ID 由目录名决定,name是展示名——这一点与 FH 一致,勿混用:{ "name": "博客", "version": "1.0.0", "author": "SoBBS", "description": "图文博客,支持分类、标签与评论。", "activated": false, "category": "content", "icon": "book", "admin_url": "/admin/blog", "min_core": "1.0.0", "hooks": { "init_after": "hook/init_after.go", "route_register": "hook/route_register.go" }, "permissions": ["route:admin"] }注意
hooks是「事件名 → 处理文件」的映射,不是数组;category取值须落在核心分类枚举内,icon须存在于核心图标映射表,否则后台列表显示为空图标。3.2 接口驱动与编译期注入
所有插件必须实现统一接口:
type Plugin interface { Name() string // 插件唯一标识,须与目录名一致 Version() string // 版本号 Init(ctx *Context) error // 初始化:建表、读配置、注册钩子 RegisterRoutes(router *Router) // 注册自己的路由 }自我注册(
plugins/blog/plugin.go):package blog import "sobbs/lib/plugin" func init() { plugin.Register(&BlogPlugin{}) } type BlogPlugin struct{} func (p *BlogPlugin) Name() string { return "blog" } func (p *BlogPlugin) Version() string { return "1.0.0" } func (p *BlogPlugin) Init(ctx *Context) error { if err := p.ensureSchema(ctx); err != nil { // 建表必须幂等 return err } hook.Listen("topic_created", p.onTopicCreated) return nil } func (p *BlogPlugin) RegisterRoutes(r *Router) { r.Get("/blog", p.index) r.Get("/blog/{id}", p.detail) }主程序盲注(
main.go):import ( _ "sobbs/plugins/blog" // 编译期注入,仅触发 init() 注册 _ "sobbs/plugins/checkin" )为什么用空白导入:Go 的
init()在包被导入时自动执行,注册表在main()运行前就已填充完毕。主程序不需要知道任何插件的名字,新增插件只改这一行 import。3.3 钩子机制(Hook System)
基于原生"事件总线 + 函数切片",无反射、无插件间直接依赖:
- 发布事件:
hook.Dispatch("topic_created", map[string]any{"topic": newTopic})- 订阅事件:
hook.Listen("topic_created", func(data map[string]any) { // 插件定制逻辑,如同步文章索引 })工程约束:
| 约束 | 说明 |
|:--|:--|
| 顺序确定 | 按插件注册顺序 + 显式priority稳定排序,绝不依赖 map 遍历顺序 |
| 异常隔离 | 单个监听器的 panic 由recover兜住并记日志,不中断主流程、不影响其他监听器 |
| 同步执行 | 钩子默认同步,禁止在钩子内做网络外呼(与 FH 的"请求内禁同步外呼"同一条红线) |
| 命名对齐 | 钩子名沿用 FH 现有命名(init_after、route_register、topic_created等),降低迁移成本 |
| 幂等要求 | 同一事件可能因重试被派发多次,监听器须自行去重(用event_id) |3.4 一键安装与热更新体验
- 后台操作:站长点击"安装博客插件"。
- 极简拉取:程序向官方源请求清单,下载包含
views/与assets/的轻量 ZIP(仅几十 KB)。 - 校验:校验 ZIP 的 Ed25519 签名与 SHA-256 摘要,校验失败直接拒绝(详见 [11.4](#114-插件商店与可信插件模型))。
- 安全解压:逐个 entry 校验路径,拒绝绝对路径、
..、符号链接,解压后写入plugins/blog/。 - 状态激活:写入启用标记,主程序挂载该插件的路由,前端刷新即出现入口。
⚠️ 设计取舍(必须写进文档的一句话)
插件后端逻辑为编译期静态注入,不支持运行时动态加载 Go 代码;可动态安装 / 更新的只有模板、静态资源、配置。原因有三:其一,Go 是静态编译语言,没有
include一个.go文件就生效的机制;其二,plugin标准库在 Linux 上对 Go 版本、依赖版本极度敏感,且不支持 Windows,与"跨平台单文件"目标冲突;其三,允许运行时加载任意二进制等于把服务器完全交给插件作者,与 [11.4](#114-插件商店与可信插件模型) 的可信边界直接矛盾。因此插件分两类:静态插件(含后端逻辑,随主程序编译,
plugin.json仅做展示与开关)与动态资源包(纯视图 / 资源,可随时安装更新)。这解释了"为什么不能像 PHP 一样后台上传就生效",也决定了插件商店的审核策略。四、复刻 FH 实施路线图
阶段一:底层基座(The Core)
lib/router:基于net/http的极简路由,支持/thread/{id}命名参数与/thread/{id}.html伪静态。
- FH 实际路由是
/thread/{id}形式,.html后缀由 Nginxtry_files转发,Go 侧需同时兼容带后缀与不带后缀两种入参。
lib/db:database/sql+modernc.org/sqlite,封装连接池与基础查询。
- DSN 必须携带
_pragma=busy_timeout(5000)与_pragma=journal_mode(WAL),这是并发不锁表的前提。
lib/template:themes/目录加载 + 编译缓存 + mtime 失效;注册raw、asset、url、pagination等 FuncMap。lib/cache:常驻内存 KV。
- 必须支持 TTL 过期 与 写操作主动失效:发帖、删帖、改分类、改设置后立即清除相关键,不能只靠自然过期。
lib/session:读写sessions表,字段与 FH 完全一致(id/data/expires)。
阶段二:核心业务平移(The Logic)
- 用户注册、登录、鉴权跑通;会话与 FH 共用一张表。
- 平移 FH 的发帖、回帖、版块列表 API,字段与 SQLite 表结构逐一对齐。
- 前端套用 FH 默认三栏模板(转写为
html/template),Web 端无缝平移。 - 验收点:同一份
data/目录下,PHP 与 Go 交替启动,同一账号、同一帖子读写结果一致。
阶段三:插件与生态预埋(The Soul)
- 全局 Hook 事件总线设计与验证(含 panic 隔离、顺序确定性)。
- 将 FH 剥离出的"博客插件"Go 逻辑预埋进主程序,验证后台一键开关。
- 迁移签到、AI 日报等高频插件。
阶段四:自研 SplitDB 与高阶特性(The Beast)
- 并发写队列:用 Go Channel 实现单写者模型,所有
INSERT / UPDATE / DELETE进队列串行落盘,彻底消除database is locked。 - 多分片并行查询:
sync.WaitGroup+errgroup跨分片并发读取。 - 静默热升级:
syscall.Exec替换进程映像,毫秒级完成。
关于 SplitDB 的定位(定心丸):分片是高阶可选能力,基础版 SoBBS 默认单库 SQLite 运行,保证 2 核 2G 小机器开箱即用。分片开关关闭时,程序走单库路径,功能完全一致,仅数据量上限不同。绝不让分片成为强依赖。
关于读写分离的边界:写队列只接管
INSERT / UPDATE / DELETE,读请求直接走连接池,绝不让读请求排队等写锁。WAL 模式下读不阻塞写、写不阻塞读,这是该设计成立的前提。关于
syscall.Exec的短板:该方式会替换当前进程映像,正在处理的请求会随旧进程一起终止,无法做到绝对无损(没有连接 draining)。它适合后台低峰期升级,且必须在响应头Connection: close配合下使用。若后续需要真正零中断,可引入fork + exec优雅接管方案:父进程停止接收新连接 → 等待存量请求排空(带超时)→ 子进程接管监听 socket → 父进程退出。阶段验收节奏
| 阶段 | 可独立验证的判据 | 可否单独上线 |
|:--|:--|:--|
| 一 | 路由、模板、会话、缓存四个库的单测全绿 | 否(无业务) |
| 二 | 与 PHP 交替启动,双端读写一致 | 可灰度(只读流量) |
| 三 | 插件开关、钩子派发、签名校验通过 | 可 |
| 四 | 压测下无锁表、无 5xx、热升级可回滚 | 可 |五、全局状态与中间件设计
在没有 Gin / Echo 的情况下,SoBBS 用标准库实现"够用且可测"的上下文与中间件链。
5.1 自定义请求上下文(
lib/context)每个请求包装一个统一 Context,贯穿用户身份、IP、语言、
request_id,并提供统一的 JSON 出口:package context import ( "encoding/json" "net/http" ) type Context struct { W http.ResponseWriter R *http.Request UserID int64 Role string Lang string ClientIP string RequestID string started time.Time } // 统一成功响应 func (c *Context) JSON(code int, msg string, data any) { c.W.Header().Set("Content-Type", "application/json; charset=utf-8") c.W.Header().Set("X-Request-ID", c.RequestID) c.W.WriteHeader(http.StatusOK) // 业务码走 body,HTTP 层恒 200 _ = json.NewEncoder(c.W).Encode(map[string]any{ "code": code, "msg": msg, "data": data, }) }设计要点:
| 要点 | 理由 |
|:--|:--|
| HTTP 恒 200,业务码在 body | 与 FH 前端既有code判定逻辑完全兼容,切换后端不用改一行前端 JS |
| 例外:真正的传输层错误 | 401 / 403 / 429 / 500 等仍用真实状态码,便于 Nginx、CDN、监控识别 |
|X-Request-ID回写响应头 | 用户截图即可定位日志,无需前端上报 |
| 不把RequestID塞进 body | 保持三段式结构稳定,客户端解析逻辑不变 |5.2 中间件链
中间件采用标准高阶函数
func(http.Handler) http.Handler,链式组合、顺序显式:handler := Recovery( RequestID( Logger( CORS( Auth(router), ), ), ), )顺序不可随意调换,理由:
| 顺序 | 中间件 | 为什么必须在这里 |
|:--|:--|:--|
| 1 |Recovery| 必须最外层,才能兜住后面所有中间件与业务的 panic |
| 2 |RequestID| 越早生成越好,后续日志才有 ID 可打 |
| 3 |Logger| 依赖 RequestID,且要记录完整耗时 |
| 4 |CORS| 在鉴权前处理预检OPTIONS,避免预检被 401 拦掉 |
| 5 |Auth| 需要已确定的 RequestID 与 IP,用于会话与风控 |Recovery示例:func Recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { stack := debug.Stack() slog.Error("panic recovered", "request_id", RequestIDFrom(r), "path", r.URL.Path, "err", err, "stack", string(stack), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"code":500,"msg":"服务暂时不可用,请稍后重试","data":null}`)) } }() next.ServeHTTP(w, r) }) }红线:
Recovery只负责"不让进程死",不负责"掩盖 bug"。panic 必须带完整堆栈进日志,且触发告警计数。若某个接口持续 panic,监控上必须能看到,而不是被 500 静默吞掉。5.3 客户端 IP 与可信代理
FH 部署在宝塔 + Nginx 之后,真实 IP 在
X-Forwarded-For里。Go 版必须:- 只在
trusted_proxies名单内的对端才采信X-Forwarded-For/X-Real-IP。 - 取
X-Forwarded-For时从右往左取第一个非可信地址,防止客户端伪造。 - 采信失败则退回
RemoteAddr。
这条直接决定限流、风控、登录日志的准确性,不能省。
六、双端无缝 Session 机制
SoBBS 承诺与 FH 数据 100% 兼容且支持随时双向切换,Session 互通是其中最硬的一环:它决定了切换瞬间会不会把全站用户踢下线。
6.1 唯一会话真相源
FH 的会话最终落在 SQLite,表结构极简:
CREATE TABLE sessions ( id TEXT PRIMARY KEY, data TEXT, expires INTEGER NOT NULL );对应 FH 的实现要点(已实测):
| 事实 | 值 | Go 版必须对齐 |
|:--|:--|:--|
| 存储位置 |data/meta/sessions.sqlite| 同路径、同表名、同字段 |
| 有效期 | 1800 秒,写入时expires = now + 1800| 完全一致 |
| 过期判定 |expires > now才算有效 | 完全一致 |
| 续期时机 |write()与updateTimestamp()都要续期 | 必须续期,否则会话硬过期 |
| 严格模式 | 开启use_strict_mode,且处理器实现了validateId| Go 版必须校验 SID 是否真实存在 |
| 登录后 |session_regenerate_id(true)换新 SID | 换 SID,且旧行删除 |⚠️ 更新
updateTimestamp的坑:PHP 在session.lazy_write=1(默认)且会话数据未变化时,只调用updateTimestamp而不调用write。如果只在此处刷新内存不刷expires,会话会在 30 分钟后无故失效。Go 版实现续期时同理:不能只更新缓存里的过期时间。6.2 Cookie 名与会话固定防护
FH 没有自定义 session 名,用的是 PHP 默认的
PHPSESSID。这一点必须写死:config.json的session_cookie默认值 =PHPSESSID。- 属性对齐 FH:
HttpOnly=true、SameSite=Lax、Secure随 HTTPS 自动开、Path=/(子目录部署时为BASE_PATH)。 - 不要改成
sobbs_session之类的名字。一旦改名,切换瞬间全站用户全部掉线,这正好破坏了本白皮书的核心承诺。
会话固定(Session Fixation)防护在 Go 侧需完整复刻:
- 校验客户端提交的 SID 是否在库中存在且未过期,不存在则丢弃并重新生成。
- 登录成功后立即生成新 SID,删除旧行。
- SID 用
crypto/rand生成 32 位十六进制字符,字符集落在 PHP 允许范围内。
6.3 PHP 会话序列化格式(关键兼容点)
sessions.data里存的不是 JSON,而是 PHP 的 session 序列化格式。Go 必须能读能写这个格式,否则"数据兼容"在会话层直接断裂。格式为「键名 +
|+ 序列化值」的连续拼接:user_id|i:296;username|s:5:"admin";role|s:5:"admin";csrf|s:32:"....";需要支持的类型:
| 标记 | 含义 | 示例 |
|:--|:--|:--|
|s| 字符串 |s:5:"admin";|
|i| 整数 |i:296;|
|b| 布尔 |b:1;|
|d| 浮点 |d:1.5;|
|N| null |N;|
|a| 数组 |a:2:{s:1:"a";i:1;s:1:"b";i:2;}|实现要求:
- 写一个
phpsess编解码包,双向可逆,并附带往返测试(解析 → 重编码 → 逐字对比)。 - 只做"读改写":Go 修改会话时,必须保留它不认识的键,不能整表重写成 JSON。这是双端共存期的铁律。
- 字符串长度按字节计,不是按字符计。中文用户名会踩这个坑。
这一条是整个白皮书里最容易低估的地方。若 Go 版把
data写成 JSON,PHP 版读出来会得到一个空会话——用户表现为"切回 PHP 就掉登录",且日志无任何异常。6.4 持久登录(Remember Me)的共存策略
FH 除会话外还有持久登录 Cookie:
remember_token与remember_user_id,且 token 会轮换。双端共存期建议:
| 阶段 | 策略 |
|:--|:--|
| 灰度共存 | Go 版只校验、不轮换remember_token,避免两端互相把对方的 token 轮换掉导致反复掉线 |
| 完全切换后 | Go 版启用轮换,规则与 FH 一致(一次性 token,用后即换) |同理,
site_theme、flinthub_lang这两个 Cookie 的键名与取值语义必须沿用,否则用户切换后端会看到主题 / 语言跳回默认值——虽不致命,但体感像"换了个站"。6.5 Cookie 兼容清单
| Cookie | 用途 | Go 版处理 |
|:--|:--|:--|
|PHPSESSID| 会话 ID | 原样沿用,不得改名 |
|remember_token| 持久登录令牌 | 共存期只读校验 |
|remember_user_id| 持久登录用户 ID | 原样沿用 |
|site_theme| 用户偏好主题 | 原样沿用 |
|flinthub_lang| 语言偏好(zh / en / zh_tw) | 原样沿用 |七、统一错误处理规范
Web 开发中,绝不能把底层原生错误(
sql: no rows in result set、绝对路径、SQL 片段)直接吐给前端。7.1 分层:内部日志 vs 对外提示
| 层 | 内容 | 去处 |
|:--|:--|:--|
| 内部 | 原始 error、堆栈、SQL、参数(脱敏后)、request_id | 结构化日志 |
| 对外 | 稳定业务码 + 人话提示 | JSON 响应 |示例映射:
| 场景 | 内部记录 | 对外响应 |
|:--|:--|:--|
| 查询无结果 |sql.ErrNoRows+ 表名 + 条件 |{"code":404,"msg":"内容不存在或已被删除","data":null}|
| 密码错误 | 失败计数 + IP + 账号(脱敏) |{"code":400,"msg":"账号或密码错误","data":null}|
| 唯一键冲突 | 约束名 + 冲突值 |{"code":409,"msg":"该名称已被占用","data":null}|
| 数据库不可用 | 驱动错误 + 文件路径 |{"code":503,"msg":"服务暂时不可用,请稍后重试","data":null}|
| 未登录 | 目标路径 |{"code":401,"msg":"请先登录","data":null}|
| 无权限 | 用户 ID + 资源 + 所需权限 |{"code":403,"msg":"没有操作权限","data":null}|7.2 用哨兵错误做判定,不用字符串比较
var ( ErrNotFound = errors.New("not found") ErrForbidden = errors.New("forbidden") ErrInvalidCred = errors.New("invalid credential") ) // 业务层 if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } // 出口统一翻译 func (c *Context) Fail(err error) { switch { case errors.Is(err, ErrNotFound): c.JSON(404, "内容不存在或已被删除", nil) case errors.Is(err, ErrForbidden): c.JSON(403, "没有操作权限", nil) default: slog.Error("unhandled error", "request_id", c.RequestID, "path", c.R.URL.Path, "err", err, ) c.JSON(500, "服务暂时不可用,请稍后重试", nil) } }三条硬性约定:
- 禁止
err.Error()直出前端。 它几乎必然包含内部细节。 - 禁止
fmt.Errorf吞掉原始错误。 用%w包装,保留errors.Is/As链。 default分支必须打日志。 未归类的错误是最高优先级的技术债,不能静默返回 500 了事。
7.3 早期返回(Early Return)
业务代码统一采用"校验 → 失败即返回"的线性写法,避免深层嵌套:
func (h *ThreadHandler) Create(c *Context) { if c.UserID == 0 { c.Fail(ErrUnauthorized); return } in, err := bindCreateThread(c.R) if err != nil { c.Fail(ErrBadRequest); return } t, err := h.svc.Create(c.UserID, in) if err != nil { c.Fail(err); return } c.JSON(200, "ok", t) }7.4 错误码分段
| 段位 | 归属 | 示例 |
|:--|:--|:--|
|0/200| 成功 | 200 |
|4xx| 客户端问题(参数、权限、不存在) | 400 / 401 / 403 / 404 / 409 / 429 |
|5xx| 服务端问题 | 500 / 503 |
|1xxx| 业务域:用户 | 1001 账号冻结 |
|2xxx| 业务域:内容 | 2001 帖子已归档不可回复 |
|3xxx| 业务域:插件 | 3001 插件未启用 |完整对照见 [附录 A](#附录-a错误码总表)。
八、开发规约与避坑指南
8.1 语言与依赖
- SQLite 驱动:坚决弃用
mattn/go-sqlite3(需 CGO,交叉编译地狱),统一用纯 Go 的modernc.org/sqlite。 - 禁止
embed模板:模板必须外置,站长才能改皮肤。 - 禁止反射式插件加载:不做
plugin.Open,理由见 [3.4](#34-一键安装与热更新体验)。
8.2 交叉编译
Windows 开发、Linux 部署,务必显式指定目标:
SET GOOS=linux SET GOARCH=amd64 SET CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=1.0.0" -o sobbs main.goCGO_ENABLED=0是单文件零依赖的前提。-trimpath去掉本机绝对路径,避免产物泄露开发目录结构。-s -w去符号表,二进制可从 ~25MB 压到 ~18MB。
8.3 从 FH 继承的真实坑(按踩中概率排序)
这些不是理论风险,是 FH 生产环境已经发生过的问题,Go 版必须提前规避:
| # | 坑 | 后果 | Go 版对策 |
|:--|:--|:--|:--|
| 1 | 只拷.sqlite不拷-wal/-shm| 读到空库且不报错,表现为"数据全没了" | 备份/迁移脚本强制整目录拷贝,并校验关键表行数 |
| 2 | 站点根data/business.sqlite是 0 字节占位 | 连错库,症状同上 | 启动时校验库文件大小与关键表存在性,缺失即拒绝启动并给出明确提示 |
| 3 | 误把threads/posts当正文真相源 | FH 中这两张表已退役(0 行) | Go 版直接不映射这两张表,读正文只走topic_index+ 分片 + 外置文件 |
| 4 | 覆盖sessions.sqlite| 踢全站用户下线 | 升级流程把该库列入"只读不动"清单 |
| 5 | Linux 大小写敏感 | 本机全绿、线上 500 | 包名、目录名、模板文件名统一小写;CI 加大小写检查 |
| 6 | 静态资源缓存不失效 | 站长改了 CSS 看不到变化 | 所有资源 URL 强制带?v=mtime|
| 7 | 路径穿越 | 任意文件读取 | 模板名、主题名、插件名一律白名单字符集校验(见 11.2) |
| 8 | 富文本过度转义 | 正文显示成 HTML 源码 | 仅对已净化正文使用raw|
| 9 | 三语键不一致 | 切换语言出现空白或英文兜底 | 启动自检:三套语言包键集合与键序零差异 |
| 10 | 时间戳单位混用 | 排序错乱、显示 1970 | 全库统一秒级 int64,禁止毫秒 |8.4 API 规范
- 字段命名统一
snake_case,与 FH 现有 API 完全一致。 - 时间统一秒级
int64,字段名create_time/update_time。 - 分页参数统一
page(从 1 开始)+page_size(默认 20,上限 100)。 - 列表响应统一
{"list": [...], "total": n, "page": p, "page_size": s}。 - 布尔字段用
0/1而非true/false,与 SQLite 存储一致,避免双端转换歧义。
8.5 并发与资源
- 所有数据库写操作走写队列,不在 handler 里直连写。
- 每个请求的数据库操作数设上限(建议 ≤ 30),超限打日志告警——这是 FH"单插件每请求查询 ≤ 5"经验的延伸。
- 禁止在请求内做同步外呼(HTTP、邮件、对象存储)。必须外呼的一律入队异步。
- goroutine 必须有退出路径,禁止无缓冲 channel 上的永久阻塞。
九、部署与运行
9.1 环境要求
| 项 | 最低 | 建议 |
|:--|:--|:--|
| 操作系统 | Linux x86_64(内核 3.10+) | Debian 12 / Ubuntu 22.04 |
| CPU / 内存 | 1 核 1G | 2 核 2G |
| 磁盘 | 1G(不含数据) | 20G SSD |
| 运行时依赖 | 无 | 无 |
| 反向代理 | 可选(裸跑也可) | Nginx + 宝塔 |GlIBC 版本需 ≥ 2.17。若目标机器更老,编译时改用
CGO_ENABLED=0的静态链接产物即可,无需装任何运行库。9.2 部署流程
- 下载:获取对应平台的
sobbs二进制(约 18MB)。 - 放置:上传至
/var/www/sobbs/,与data/、themes/、assets/同级。 - 授权:
chmod +x sobbs。 - 初始化:
- 新站:直接运行,首次启动检测到无
config.json则进入安装引导,创建数据库骨架。 - 存量站:把 FH 的
data/整目录(含-wal/-shm)搬过来,程序直接接管,不做任何数据迁移。
- 启动:
./sobbs,或交给 systemd(见 9.4)。 - 扩展:主题包解压至
themes/;插件在后台点选激活。
9.3 与 Nginx 的配合
Go 版自带 HTTP 服务,Nginx 只做反向代理与静态资源直出。要点:
server { listen 80; server_name sobbs.cn; root /var/www/sobbs; index index.html; # 静态资源直出,命中不到再交给 Go location ^~ /assets/ { try_files $uri @sobbs; expires 30d; add_header Cache-Control "public, immutable"; } location ^~ /themes/ { try_files $uri @sobbs; expires 7d; } location ^~ /plugins/ { try_files $uri @sobbs; } # 其余全部走 Go location / { try_files $uri @sobbs; } location @sobbs { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; } }三条注意:
data/必须被 Nginx 显式拒绝。虽然 Go 不会把它暴露出去,但静态直出规则若写宽了(比如location /直接try_files命中磁盘),.sqlite会被下载。必须加一条location ^~ /data/ { deny all; },或干脆把data/放到 Web 根目录之外。X-Forwarded-For必须由 Nginx 覆盖,不能透传客户端自带的,否则 IP 可伪造。- 不要用
rewrite ... last转发到/index.php/xxx—— 那是 PHP 时代的伪静态写法,Go 版不需要,且会引入PATH_INFO歧义。
9.4 systemd 进程守护
[Unit] Description=SoBBS Forum Server After=network.target [Service] Type=simple User=www Group=www WorkingDirectory=/var/www/sobbs ExecStart=/var/www/sobbs/sobbs Restart=always RestartSec=3 # 优雅停止:先发 SIGTERM,给存量请求 10 秒排空 KillSignal=SIGTERM TimeoutStopSec=10 # 资源与安全加固 LimitNOFILE=65535 NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ReadWritePaths=/var/www/sobbs/data /var/www/sobbs/logs [Install] WantedBy=multi-user.targetReadWritePaths必须显式包含data/与logs/,否则ProtectSystem=full会让程序无法写库。这是最容易漏的一行。9.5 首次启动自检
程序启动时按顺序校验,任一项失败拒绝启动并打印可读原因(而不是启动后各种 500):
| # | 检查项 | 失败提示 |
|:--|:--|:--|
| 1 |data/meta/business.sqlite存在且 > 0 字节 | 数据库文件缺失或为空占位,请检查是否误用了站点根目录的 0 字节文件 |
| 2 | 关键表存在(users/settings/categories) | 数据库结构不完整,请确认版本为 FH V1.5.0 及以上 |
| 3 |sessions.sqlite可读写 | 会话库不可写,请检查目录权限 |
| 4 |themes/<当前主题>/存在且含layout.html| 主题目录缺失 |
| 5 | 三套语言包键集合一致 | 语言包键不匹配,请检查 zh / en / zh_tw |
| 6 | 监听端口可绑定 | 端口被占用 |
| 7 |logs/可写 | 日志目录不可写 |十、数据兼容性与双端切换验收
这一章把"100% 兼容"从口号变成可执行的验收,是全文最需要"能跑脚本"的部分。
10.1 兼容性契约
| 层次 | 契约 | 违约后果 |
|:--|:--|:--|
| 文件布局 | 目录名、库文件名、桶编号、正文文件命名完全一致 | 数据找不到 |
| 表结构 | 表名、字段名、字段类型、默认值一致 | 读写报错或静默错位 |
| 字符编码 | 全部 UTF-8,无 BOM | 中文乱码 |
| 时间单位 | 秒级 int64 | 排序错乱 |
| 布尔表示 |0/1整数 | 条件判断失效 |
| 会话格式 | PHP session 序列化格式 | 切换即掉登录 |
| 正文存储 | 长文外置到extern/,索引库只存摘要 | 列表页拉全文字段,性能塌陷 |10.2 FH V1.5.0 数据布局实测
以下为实测结论,是 Go 版必须对齐的基准(详见 [附录 B](#附录-bfh-v150-数据结构实测清单)):
| 路径 / 对象 | 实际内容 | Go 版动作 |
|:--|:--|:--|
|data/meta/business.sqlite| 主库,23 张表 | 直接读写 |
|data/meta/main_index.sqlite|topic_index/reply_index| 直接读写,WAL 模式 |
|data/meta/sessions.sqlite|sessions(id, data, expires)| 直接读写,格式见 6.3 |
|data/meta/global_id.sqlite|id_generator(table_name, last_id)| 发号必须走这里,不能用 SQLite 自增 |
|data/bucket/active/<YYYYQn>/<0..31>.sqlite| 当季分片,实测 32 个桶 | 路由id % 32|
|data/bucket/archive/<YYYYQn>/…| 归档,只读 | 禁写,禁回复 |
|data/extern/<年>/Q<季>/<桶>/t<id>.txt| 主题正文外置 | 按 ID 前缀区分 t / r |
|data/extern/<年>/Q<季>/<桶>/r<id>.txt| 回复正文外置 | 同上 |
|data/business.sqlite(站点根) | 0 字节占位 | 忽略,禁止指向它 |
|data/main_index.sqlite(站点根) | 0 字节占位 | 忽略 |
|threads/posts表 | 已退役,0 行 | 不映射,不作真相源 |发号器必须独立:FH 用
global_id.sqlite的id_generator统一发号,正是为了保证跨分片 ID 全局唯一、单调递增。Go 版若图省事用 SQLite 的AUTOINCREMENT,会在分片切换时产生 ID 冲突,且破坏id % 32的路由规则——这是不可逆的数据事故。10.3 双端切换验收清单
按顺序执行,全绿才算通过:
| # | 用例 | 期望 |
|:--|:--|:--|
| 1 | PHP 启动,注册用户 A 并登录 | 成功,sessions有新行 |
| 2 | 停 PHP,启 Go,携带同一 Cookie 访问 | 仍为登录态,无需重新登录 |
| 3 | Go 下发帖 T1,含中文标题与富文本正文 | 落库成功,正文进extern/|
| 4 | 停 Go,启 PHP,查看 T1 | 标题、正文、时间、作者全部正确,无乱码 |
| 5 | PHP 回复 T1,Go 读取该回复 | 回复内容与楼层顺序正确 |
| 6 | Go 修改站点设置,PHP 读取 | 设置一致 |
| 7 | 同一账号在两端交替登录 | 不出现"一端登录另一端被踢"以外的异常 |
| 8 | Go 删除帖子,PHP 查看 | 正确显示已删除,无残留 |
| 9 | 归档帖在 Go 侧尝试回复 | 被拒绝,且提示与 PHP 一致 |
| 10 | 分页、排序、置顶、高亮 | 两端列表顺序完全一致 |10.4 兼容性回归脚本要求
必须脚本化、可重复执行,而不是手工点:
- 双向用例:每个用例都要有正向(应成功)与反向(应被拒绝)两条。
- 极端样本:超长标题、纯 emoji、含
"与'与</script>、中文用户名、空正文、超大分页页码。 - 字节级校验:对同一 ID 的正文,比对
extern/文件内容与两端 API 输出是否逐字节一致。 - 编码合法性:读库后校验每个值确实是合法 UTF-8,而不是"能显示就行"。
- 前后状态对比:切换前后对关键表做行数与校验和快照,证明无隐性改写。
只测"请求返回 200"是没有意义的。中文链路的真实验收标准是:库内字节合法 + 前端渲染与原文一致,两者都要看。
10.5 兼容性红线
以下行为一旦发生,就破坏了"随时可回滚"的承诺,属于发布阻断项:
- 给现有表加必填字段(PHP 版不认识,插入即失败)。
- 改变现有字段的语义或单位(如把秒改毫秒)。
- 把
sessions.data改写成 JSON。 - 在
extern/之外新建正文存储位置。 - 用
AUTOINCREMENT替代id_generator。 - 重命名或移动
data/meta/下的任何库文件。
允许的变更只有一种:新增可选字段或新表,且必须有默认值、PHP 版可忽略。任何时候新增结构,都必须在同一版本内提供回滚脚本(见第十二章)。
十一、生产级安全基线
FH 已完成全量白盒审计,但 Go 版的安全机制必须重新定义——语言换了,攻击面也换了。本章是上线前的强制门槛。
11.1 威胁模型
| 威胁 | 入口 | 影响 |
|:--|:--|:--|
| 路径穿越 | 模板名、主题名、插件名、附件名 | 任意文件读取 / 覆盖 |
| SQL 注入 | 动态拼接的排序、筛选、分片路径 | 数据泄露 / 篡改 |
| XSS | 富文本正文、标题、签名 | 会话劫持 |
| CSRF | 状态变更接口 | 越权操作 |
| 会话固定 | 未校验 SID | 账号接管 |
| 恶意插件 | 插件包投毒 | 服务器沦陷 |
| 暴力破解 | 登录、注册、找回 | 撞库、短信轰炸 |
| 资源耗尽 | 超大请求体、慢连接 | 拒绝服务 |
| 信息泄露 | 错误响应、目录列表、.sqlite被下载 | 数据外泄 |11.2 路径穿越防护(最高优先级)
Go 版涉及大量"按名字找文件"的场景:模板、主题、插件视图、静态资源、附件、外置正文。每一个都是穿越入口。
统一防护函数:
// SafeJoin 把用户可控的相对路径拼到 base 下,任何越界一律拒绝 func SafeJoin(base, rel string) (string, error) { if rel == "" || strings.ContainsRune(rel, 0) { return "", ErrBadPath } // 统一分隔符,拒绝绝对路径与盘符 rel = strings.ReplaceAll(rel, "\\", "/") if strings.HasPrefix(rel, "/") || strings.Contains(rel, ":") { return "", ErrBadPath } // 白名单字符集:字母数字、下划线、连字符、点、斜杠 if !pathRe.MatchString(rel) { return "", ErrBadPath } // 拒绝 .. 段(含编码变形) for _, seg := range strings.Split(rel, "/") { if seg == ".." || seg == "." { return "", ErrBadPath } } full := filepath.Join(base, filepath.FromSlash(rel)) // 最终以 realpath 兜底确认仍在 base 内 absBase, err := filepath.Abs(base) if err != nil { return "", err } absFull, err := filepath.Abs(full) if err != nil { return "", err } if absFull != absBase && !strings.HasPrefix(absFull, absBase+string(filepath.Separator)) { return "", ErrBadPath } return absFull, nil }配套规则:
| 对象 | 规则 |
|:--|:--|
| 主题名 | 仅[a-z0-9_-],长度 ≤ 32,且必须存在于白名单目录列表 |
| 插件 ID | 仅[a-z0-9_],必须与已注册插件名精确匹配(大小写敏感) |
| 模板名 | 仅[a-z0-9_/.-],拒绝以/或.开头 |
| 附件名 | 上传时重命名为random_bytes生成的随机名,保留白名单后缀;下载时用filepath.Base|
| 外置正文 | 路径由 ID 计算得出,不接受任何用户输入 |大小写陷阱:Linux 上
Plugin.Blog与plugin.blog是两个目录。插件名、主题名的匹配必须大小写敏感,且与磁盘真实名逐字节比对。11.3 注入与 XSS 防护
SQL:全参数化,禁止任何字符串拼接进 SQL。分片库路径由
id % 32与季度计算,不来自请求参数。row := db.QueryRowContext(ctx, `SELECT id, title FROM topic_index WHERE id = ? AND status = ?`, id, statusNormal)动态排序必须走白名单映射,不能直拼字段名:
var orderByMap = map[string]string{ "new": "create_time DESC", "hot": "reply_count DESC", "views": "view_count DESC", }XSS:
- 默认转义(
html/template天然行为)。 raw仅用于服务端已净化的正文,净化器必须基于白名单标签(p / br / strong / em / a / img / code / pre / blockquote / ul / ol / li),并强制a[href]仅http/https、img[src]仅http/https。- 禁止内联
<script>、on*事件属性、javascript:协议。 - CSP 沿用 FH 策略,
img-src 'self' data: https:(不含blob:),新增来源须评审。
CSRF:所有状态变更接口(POST/PUT/DELETE)校验 token,token 与会话绑定、一次性或短时效。JSON 接口用
X-CSRF-Token头,表单用隐藏字段。11.4 插件商店与可信插件模型
这是整个安全体系里唯一的信任边界,必须说透:
官方源(唯一可信) → 签名清单(Ed25519) → 插件包(SHA-256 + 签名) → 本地校验 → 安装| 环节 | 要求 |
|:--|:--|
| 传输 | 强制 HTTPS,禁止降级 |
| 清单 | 由官方私钥签名,客户端内置公钥验签 |
| 插件包 | 清单中登记 SHA-256;下载后先算摘要再验签,两者都过才解压 |
| 版本绑定 | 清单记录min_core,当前核心版本不满足则拒绝安装 |
| 来源限定 | 默认只信任官方源;第三方源需站长显式开启并承担风险提示 |
| 离线安装 | 允许上传 ZIP,但仍须签名校验;无签名的包只能进"开发者模式"且启动时告警 |解压安全(ZIP Slip):
- 逐 entry 校验:拒绝绝对路径、
..、符号链接、硬链接、设备文件。 - 单文件与总解压体积设上限(防 ZIP 炸弹)。
- 先解压到临时目录,全部校验通过后再原子移动到
plugins/<id>/。 - 安装后校验目录内不含可执行二进制——动态资源包只允许
html/css/js/json/png/jpg/svg/woff2等静态类型。
信任边界的表述必须绝对清晰:一旦允许加载未签名的第三方 Go 代码,等于把 root 权限交出去。因此 [3.4](#34-一键安装与热更新体验) 的"编译期静态注入"不只是技术限制,更是安全设计。
11.5 认证与账号安全
| 项 | 要求 |
|:--|:--|
| 密码哈希 | 沿用 FH 现有算法以保证双端可验证;新用户统一用强哈希(Argon2id 或 bcrypt cost ≥ 12) |
| 登录限流 | 单 IP + 单账号双维度,滑动窗口;连续失败递增延迟 |
| 会话 |HttpOnly+SameSite=Lax+ HTTPS 下Secure;登录后换 SID |
| 敏感操作 | 改密、换绑、删除账号要求二次验证(沿用 FH 的 30 分钟二次验证语义) |
| 注册 | 频率限制 + 可选验证码;邮件/短信发送必须异步且限速 |
| 密码重置 | token 一次性、短时效、用后即焚;重置成功后强制下线所有会话 |共存期注意:若 Go 版改用不同的密码哈希算法,PHP 版将无法验证这些用户的密码,切换回去就登不上。因此共存期必须"只写 PHP 认识的格式",或实现双格式校验(读时按前缀识别算法,登录成功后按需重哈希升级)。
11.6 传输与响应头
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Referrer-Policy: strict-origin-when-cross-origin Strict-Transport-Security: max-age=31536000; includeSubDomains # 仅 HTTPS Permissions-Policy: geolocation=(), microphone=(), camera=()11.7 资源耗尽防护
| 项 | 阈值 |
|:--|:--|
| 请求体上限 | 普通接口 2MB;上传接口按类型单独设定 |
| 请求头上限 | 默认 1MB(MaxHeaderBytes) |
| 超时 |ReadHeaderTimeout5s、ReadTimeout15s、WriteTimeout30s、IdleTimeout60s |
| 并发连接 | 按内存设定上限,超出快速失败而非排队 |
| 上传文件 | 白名单后缀 + MIME 嗅探 + 大小上限 |
| 分页 |page_size上限 100,超出截断 |11.8 安全红线(发布阻断)
- 任何
err.Error()出现在响应体。 - 任何用户可控字符串直接拼进文件路径或 SQL。
data/、protected/、logs/可被 Web 直接访问。- 未验签的插件包可安装。
- 会话 Cookie 缺少
HttpOnly。 - 生产环境开启调试模式或
pprof公网暴露。 - 日志中记录明文密码、token、完整 Cookie。
十二、版本契约与平滑升级规范
站长敢不敢用"单文件替换"升级,取决于这一章写得多硬。本章定义的是法律级约束,不是建议。
12.1 版本号语义
采用
MAJOR.MINOR.PATCH:| 位 | 变更含义 | 数据影响 | 可回滚性 |
|:--|:--|:--|:--|
| MAJOR | 破坏性架构变更 | 可能有不可逆迁移 | 需专门迁移方案,不承诺回滚 |
| MINOR | 新增功能、新增可选字段/表 | 只增不改 | 同 MAJOR 内必须可回滚 |
| PATCH | 修 bug、优化、无结构变更 | 无 | 任意可回滚 |
核心承诺:同主版本内,数据可无缝回滚。 即
1.3.x的任何版本之间可以任意升降级,不需要改数据。12.2 数据库变更四条铁律
- 只加不删。 新增字段必须有默认值或允许 NULL;新增表必须独立。任何情况下不得删表、不得删字段、不得改字段类型。
- 只用增量脚本。 每个版本一个迁移文件,命名
migrations/0007_add_thread_excerpt.sql,按序号单调递增执行。 - 迁移必须幂等。 重复执行结果一致。判断"是否已执行"不靠记忆,靠结构探测(如查
PRAGMA table_info看字段是否存在),而不是"跑过就跳过"。 - 迁移必须可中断恢复。 每条迁移在事务内执行;失败整体回滚,不留半成品状态。
幂等迁移示例:
-- 0007_add_thread_excerpt.sql (幂等:存在则跳过) -- 由 Go 侧先探测 PRAGMA table_info(topic_index) 决定是否执行 ALTER TABLE topic_index ADD COLUMN excerpt TEXT DEFAULT '';Go 侧执行器:
type Migration struct { Version int Name string Up string Down string // 仅 MAJOR 内可选提供;PATCH/MINOR 通常为 nil } func (m *Migrator) Apply(db *sql.DB) error { applied, err := m.appliedVersions(db) // 读 schema_migrations 表 if err != nil { return err } for _, mg := range m.list { if applied[mg.Version] { continue } tx, err := db.Begin() if err != nil { return err } if _, err := tx.Exec(mg.Up); err != nil { _ = tx.Rollback() return fmt.Errorf("migration %d (%s) failed: %w", mg.Version, mg.Name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, mg.Version, time.Now().Unix()); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return err } } return nil }schema_migrations表本身也是"只加不删",且必须允许 PHP 版忽略它的存在——这正是"只加"原则的价值。12.3 升级流程(单文件替换)
① 预检:校验当前版本 → 目标版本是否同 MAJOR;跨 MAJOR 需显式 --force ② 备份:自动打包 data/ 整目录(含 -wal / -shm)到 data/backup/<时间戳>/ ③ 校验:确认无残留升级锁(data/lock/ 下旧锁文件需先判定是否陈旧) ④ 停服:systemctl stop sobbs(或向进程发 SIGTERM 等待排空) ⑤ 替换:新二进制写入临时名 → 原子 rename 覆盖 ⑥ 迁移:启动时自动执行未应用的迁移 ⑦ 自检:跑 9.5 的启动自检 ⑧ 起服:systemctl start sobbs ⑨ 验证:健康检查 + 抽样接口失败即回滚:③~⑦ 任一步失败,用 ② 的备份覆盖
data/,放回旧二进制,重启。关于 ③ 的残留锁:
data/lock/下可能存在上次异常中断留下的锁文件。锁文件必须记录持有者标识与获取时间戳,判定规则只有四条:| 情形 | 判定 | 动作 |
|:--|:--|:--|
| 锁年龄超过 TTL | 陈旧锁,持有者大概率已异常退出 | 可接管,接管时记一条 WARN 日志 |
| 锁未过期,且持有者非当前进程 | 活跃锁,另一个升级进程正在跑 | 中止并返回当前持有者信息,不强杀 |
| 锁未过期,且持有者就是当前进程 | 同一次升级重复获取 | 视为续期,不算冲突,刷新时间戳即可 |
| 无锁文件 | — | 正常获取 |第三条尤其重要:升级流程内部可能多处调用获取锁(预检、备份、迁移),若把"自己已持锁"判成冲突,升级会在自身重入时死锁。同一持有者重复获取 = 续期,这是幂等的必要条件。
12.4 回滚规范
| 场景 | 操作 | 数据是否回滚 |
|:--|:--|:--|
| 同 MAJOR 内换回旧二进制 | 直接替换二进制,不碰 data/ | 否(新字段旧版忽略,安全) |
| 升级中迁移失败 | 用备份覆盖 data/ + 放回旧二进制 | 是 |
| 跨 MAJOR 回滚 | 需专门的下行迁移脚本 | 是(按脚本) |关键设计:同主版本内之所以能"只换二进制不回滚数据",正是因为 12.2 的"只加不删"。新增字段旧版不读、新增表旧版不看,天然兼容。这是整个升级体系能成立的根本原因。
12.5 灰度与双端切换
升级不是唯一风险,切换后端也是。推荐节奏:
| 阶段 | 流量 | 观察指标 | 回退动作 |
|:--|:--|:--|:--|
| 0 | 仅内网 / 本机 | 接口正确性 | — |
| 1 | 5% 读流量 | 5xx 率、延迟 P99 | 切回 PHP |
| 2 | 50% 读写 | 数据一致性抽检 | 切回 PHP |
| 3 | 100% | 全量指标 | 切回 PHP |切换动作本身只是改 Nginx upstream,秒级完成,不涉及数据迁移——这正是"数据 100% 兼容"带来的最大运维红利。
12.6 升级红线
- 升级过程中删除任何数据文件。
- 迁移脚本带
DROP TABLE/DROP COLUMN。 - 迁移脚本不可重复执行。
- 升级前不做备份。
- 跨 MAJOR 升级不读变更说明直接上。
- 用
rsync --delete同步data/。
十三、日志、request_id 与可观测性
接口报错时,如果没有
request_id贯穿全链路,排查就是大海捞针。这是工业级项目的最低门槛。13.1 request_id 的生成与贯穿
func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-ID") if id == "" || !validRequestID(id) { // 只接受合法格式,防注入 b := make([]byte, 16) _, _ = rand.Read(b) id = hex.EncodeToString(b) } ctx := context.WithValue(r.Context(), ctxKeyRequestID, id) w.Header().Set("X-Request-ID", id) next.ServeHTTP(w, r.WithContext(ctx)) }) }贯穿要求:
| 环节 | 是否带 request_id |
|:--|:--|
| 访问日志 | 必须 |
| 错误日志 | 必须 |
| SQL 慢查询日志 | 必须 |
| 钩子执行日志 | 必须 |
| 异步任务日志 | 必须(从入队时携带) |
| 响应头 | 必须(用户在页面上能拿到) |13.2 日志规范
用
log/slog输出 JSON,一行一条,便于采集:{"time":"2026-11-03T10:12:33.412Z","level":"ERROR","msg":"db query failed", "request_id":"9f2c...","uid":296,"ip":"1.2.3.4","path":"/api/thread/12", "method":"GET","latency_ms":37,"err":"no such table: topic_index"}字段约定:
| 字段 | 说明 |
|:--|:--|
|time| RFC3339,带毫秒 |
|level| DEBUG / INFO / WARN / ERROR |
|msg| 稳定短句,不要拼接变量(变量进独立字段) |
|request_id| 贯穿标识 |
|uid| 用户 ID,未登录为 0 |
|path/method| 请求标识 |
|latency_ms| 耗时 |
|err| 错误详情,仅日志内出现 |日志脱敏红线:密码、token、Cookie 全值、身份证、手机号中段,一律不得明文落盘。手机号写
138****8000,token 只留前后 4 位。13.3 日志分级与落盘
| 级别 | 用途 | 生产默认 |
|:--|:--|:--|
| DEBUG | 开发调试、SQL 明细 | 关闭 |
| INFO | 启动、配置加载、升级、插件启停 | 开 |
| WARN | 限流触发、降级、重试 | 开 |
| ERROR | 请求失败、panic、DB 异常 | 开 |落盘策略:
- 按天切分:
logs/app-2026-11-03.log。 - 单文件上限 100MB,超出即切分。
- 保留 30 天,自动清理。
- 异步写入(带缓冲 channel),不允许日志 IO 阻塞请求。
- 磁盘写满时降级为丢弃 + 计数告警,绝不因日志把服务拖死。
13.4 关键指标
| 指标 | 采集点 | 告警阈值建议 |
|:--|:--|:--|
| 请求量 / QPS | 访问日志聚合 | — |
| 5xx 率 | 访问日志 | > 1% 持续 5 分钟 |
| 延迟 P95 / P99 | 访问日志 | P99 > 500ms |
| panic 次数 | Recovery 中间件 | > 0 立即告警 |
| DB 慢查询 | 查询包装层 | > 200ms 记录 |
| 写队列积压 | 队列长度 | > 1000 持续 1 分钟 |
| SQLite 锁等待 |busy_timeout触发计数 | 持续增长 |
| 会话表行数 | 定时统计 | 异常暴涨(可能是攻击) |
| 磁盘使用率 | 系统 | > 85% |13.5 健康检查端点
GET /healthz → 200 {"status":"ok"} # 存活探针,不查库 GET /readyz → 200/503 # 就绪探针,含 DB 可读性 GET /metrics → 仅内网 / 需鉴权/healthz绝不能查数据库——否则数据库抖动会导致探针失败,触发无意义的重启风暴。/readyz才查。13.6 可观测性红线
- 请求链路无
request_id。 - 日志中出现明文凭据。
- panic 不上报、不计数。
/metrics与/debug/pprof公网可达。- 日志同步写盘阻塞主流程。
十四、总结
SoBBS 不是对 FlintHub 的抛弃,而是一次降维进化。
它继承了三样东西:
- 数据:
data/目录原封不动,PHP 与 Go 互为兜底,切换只是改一行 upstream。 - 业务语义:表结构、字段单位、会话格式、钩子命名、Cookie 键名全部对齐,迁移成本趋近于零。
- 克制:不用框架、不用 ORM、不用
embed、不做运行时插件加载——每一条"不做"背后都是对可维护性的保护。
它改变了三样东西:
- 运行时:从"每请求重建世界"变成"每进程一次",内存与并发不再受 FPM 模型约束。
- 部署形态:从数百个 PHP 文件变成一个 18MB 的二进制,升级是替换一个文件。
- 工程纪律:把"兼容""回滚""可观测"从口头承诺,变成可脚本验证的门槛。
三块拼图补齐之后,这套架构才算完整:
| 拼图 | 解决的问题 | 所在章节 |
|:--|:--|:--|
| 编译期注入的取舍说明 | "为什么不能像 PHP 一样传插件就生效" | [3.4](#34-一键安装与热更新体验) |
| 生产级安全基线 | 路径穿越、注入、插件信任边界 | [十一](#十一生产级安全基线) |
| 版本契约与升级规范 | "敢不敢升级、能不能回滚" | [十二](#十二版本契约与平滑升级规范) |
| 日志与可观测 | "出问题能不能查到" | [十三](#十三日志request_id-与可观测性) |
| 数据兼容验收 | "切换会不会丢数据" | [十](#十数据兼容性与双端切换验收) |跑起来之后,你拥有的将是一个比 PHP 版更轻、更快、且依然支持数据无缝回滚的社区系统。
附录 A:错误码总表
A.1 通用
| code | HTTP | msg | 场景 |
|:--|:--|:--|:--|
| 200 | 200 | ok | 成功 |
| 400 | 200 | 请求参数有误 | 参数校验失败 |
| 401 | 200 | 请先登录 | 未登录访问需鉴权接口 |
| 403 | 200 | 没有操作权限 | 权限不足 |
| 404 | 200 | 内容不存在或已被删除 | 资源不存在 |
| 409 | 200 | 该名称已被占用 | 唯一键冲突 |
| 429 | 429 | 操作过于频繁,请稍后再试 | 限流 |
| 500 | 500 | 服务暂时不可用,请稍后重试 | 未预期异常 |
| 503 | 503 | 服务暂时不可用,请稍后重试 | 数据库/依赖不可用 |HTTP 状态码策略:业务错误统一 200 + body 业务码(兼容 FH 前端);限流与 5xx 使用真实状态码(便于网关与监控识别)。
A.2 业务域
| code | msg | 场景 |
|:--|:--|:--|
| 1001 | 账号已被冻结 | 用户状态异常 |
| 1002 | 账号或密码错误 | 登录失败 |
| 1003 | 该邮箱已注册 | 注册冲突 |
| 1004 | 验证码已失效 | token 过期 |
| 2001 | 该主题已归档,无法回复 | 归档帖写操作 |
| 2002 | 内容包含不允许的格式 | 富文本净化失败 |
| 2003 | 附件类型不支持 | 上传白名单拦截 |
| 2004 | 附件超出大小限制 | 上传体积超限 |
| 3001 | 插件未启用 | 访问未激活插件路由 |
| 3002 | 插件签名校验失败 | 包被篡改 |
| 3003 | 插件与当前核心版本不兼容 |min_core不满足 |附录 B:FH V1.5.0 数据结构实测清单
取证方式:直接读取
data/meta/*.sqlite与data/bucket/,非文档推断。B.1 库文件
| 文件 | 表 | 说明 |
|:--|:--|:--|
|data/meta/business.sqlite| 23 张 | 主库 |
|data/meta/main_index.sqlite|topic_index、reply_index| 索引库,WAL |
|data/meta/sessions.sqlite|sessions| 会话 |
|data/meta/global_id.sqlite|id_generator| 全局发号 |B.2
business.sqlite表清单(23 张)attachments audit_logs blog_categories blog_comments blogs categories category_permissions level_config messages notifications online_users password_resets points_log post_votes posts settings tags thread_tags thread_votes threads user_groups users viewed_repliesthreads与posts为退役表(实测 0 行)。正文真相源为main_index+ 分片 +extern。blogs/blog_categories/blog_comments存在,说明博客功能在原站是核心表而非纯插件表,Go 版需按此对齐。B.3
main_index.sqlite结构CREATE TABLE topic_index ( id INTEGER PRIMARY KEY, uid INTEGER, title TEXT, create_time INTEGER, -- 秒级 last_reply_time INTEGER, -- 秒级 view_count INTEGER DEFAULT 0, reply_count INTEGER DEFAULT 0, status INTEGER DEFAULT 0, category_id INTEGER DEFAULT 0, is_pinned INTEGER DEFAULT 0, is_highlighted INTEGER DEFAULT 0, color TEXT DEFAULT '', reply_to_view INTEGER DEFAULT 0, deleted_at INTEGER, bucket_path TEXT, -- 指向分片库 is_archived INTEGER DEFAULT 0, excerpt TEXT DEFAULT '', excerpt_images TEXT DEFAULT '' ); CREATE TABLE reply_index ( id INTEGER PRIMARY KEY, pid INTEGER, -- 所属主题 uid INTEGER, create_time INTEGER, update_time INTEGER, status INTEGER DEFAULT 0, bucket_path TEXT );Go 版对齐要点:
bucket_path是分片定位的唯一依据,不可自行用id % 32反推(归档数据可能不符合当前规则)。excerpt/excerpt_images用于列表页,避免为摘要去读extern文件。is_archived与deleted_at是两个独立语义,不可合并判断。
B.4
sessions.sqliteCREATE TABLE sessions ( id TEXT PRIMARY KEY, data TEXT, expires INTEGER NOT NULL -- 秒级时间戳 );B.5
global_id.sqliteCREATE TABLE id_generator ( table_name TEXT PRIMARY KEY, last_id INTEGER DEFAULT 0 );B.6 分片与正文
| 项 | 实测值 |
|:--|:--|
| 分片桶数 | 32(0.sqlite~31.sqlite) |
| 季度目录 |2026Q3、2026Q4等 |
| 归档目录 |bucket/archive/<YYYYQn>/|
| 主题正文 |extern/<年>/Q<季>/<桶>/t<id>.txt|
| 回复正文 |extern/<年>/Q<季>/<桶>/r<id>.txt|
| 正文隔离 | 靠t/r前缀,因主题与回复 ID 可能重叠 |附录 C:上线前检查清单
C.1 功能
- [ ] 注册 / 登录 / 登出 全通
- [ ] 发帖 / 回帖 / 编辑 / 删除
- [ ] 分页 / 排序 / 置顶 / 高亮
- [ ] 归档帖禁写
- [ ] 搜索(若有)
- [ ] 通知 / 私信
- [ ] 附件上传与下载
- [ ] 三语切换无空白
- [ ] 主题切换即时生效
C.2 兼容
- [ ] PHP ⇄ Go 双向切换不掉登录
- [ ] 10.3 的十条用例全绿
- [ ] 中文正文库内字节合法 + 前端渲染一致
- [ ] 会话
data字段往返可逆 - [ ] 未用
AUTOINCREMENT替代发号器
C.3 安全
- [ ] 路径穿越用例全部被拒
- [ ] SQL 全参数化(静态扫描无拼接)
- [ ] 富文本净化白名单生效
- [ ] CSRF token 校验
- [ ] 登录限流生效
- [ ] 插件包验签生效
- [ ]
data/不可 HTTP 直接访问 - [ ] 错误响应无内部细节
- [ ] 安全响应头齐全
C.4 运维
- [ ] 启动自检 7 项通过
- [ ] systemd
ReadWritePaths含data/与logs/ - [ ] 日志按天切分、保留 30 天
- [ ]
request_id全链路可见 - [ ] 健康检查端点可用且不查库(
/healthz) - [ ] 升级备份脚本可执行、可恢复
- [ ] 回滚演练通过
C.5 性能
- [ ] 2 核 2G 下单接口 P99 < 200ms
- [ ] 并发 500 无 5xx
- [ ] 写压力下读延迟不上升(验证读写分离)
- [ ] 无 SQLite 锁等待堆积
- [ ] 内存常驻稳定,无持续增长
附录 D:与原草案的差异说明
定稿过程中对原方案做了以下事实修正与补充,均以 FH V1.5.0 代码与数据实测为准:
| # | 原草案 | 定稿 | 原因 |
|:--|:--|:--|:--|
| 1 | 目录只有business.sqlite在根 | 补充data/meta/完整布局 | 实测真实库在data/meta/,根目录是 0 字节占位 |
| 2 | 未提global_id.sqlite| 明确发号必须走该库 | 跨分片 ID 唯一性的前提,缺失会导致 ID 冲突 |
| 3 | 未提main_index.sqlite| 明确topic_index/reply_index为正文索引真相源 |threads/posts已退役 |
| 4 | 示例 Cookie 名fh_session| 改为PHPSESSID| FH 未自定义 session 名,改名会导致切换时全站掉线 |
| 5 | 未说明 sessiondata格式 | 新增 6.3 PHP 序列化兼容 | 若写成 JSON,PHP 读到空会话 |
| 6 | 未提 FH 的remember_token轮换 | 新增 6.4 共存期只读策略 | 两端互相轮换会导致反复掉线 |
| 7 |plugin.json示例字段形态有误 | 按实测改为name为展示名、hooks为映射 | 与 FH 清单结构一致,避免插件无法识别 |
| 8 | 未提route_before_dispatch派发两次 | 记录差异,Go 版只派发一次 | FH 历史行为,避免照抄 |
| 9 | 路由写作/thread/{id}.html| 更正为/thread/{id}+.html由 Nginx 转发 | 实测 FH 路由不带后缀 |同时新增了原草案没有的四块内容:数据兼容验收(第十章)、生产级安全基线(第十一章)、版本契约与升级规范(第十二章)、日志与可观测性(第十三章),以及原第二档建议中的四处补丁(
syscall.Exec短板、SplitDB 单机默认、读写分离边界、缓存失效策略)。附录 E:术语表
| 术语 | 含义 |
|:--|:--|
| FH | FlintHub,PHP 版社区系统,本项目的数据与业务基线 |
| SplitDB | FH 的分片存储方案:季度分区 + 哈希分桶 + 正文外置 |
| 分片 / 桶 |data/bucket/active/<YYYYQn>/<0..31>.sqlite,按id % 32路由 |
| 正文外置 | 长正文存为extern/下的独立 txt,索引库只留摘要 |
| 钩子 / Hook | 事件总线的订阅点,插件通过它介入主流程 |
| 编译期注入 | 通过import _触发插件init()注册,非运行时加载 |
| 写队列 | 单写者模型,所有写操作串行落盘以规避 SQLite 锁 |
| request_id | 单次请求的唯一标识,贯穿日志与响应头 |
| 幂等迁移 | 重复执行结果一致的数据库变更脚本 |
| 双端切换 | PHP 与 Go 两个后端在同一份数据上交替运行 |本文档为 SoBBS 项目架构定稿,随代码演进同步更新。文中所有 FH 数据结构描述均基于 V1.5.0 实测,引用前请以代码与库结构复核。
主楼