SoBBS 架构白皮书

项目代号:SoBBS | 底层语言:纯血 Go (Golang) | 项目域名:sobbs.cn
技术定位:极致轻量 · 单文件部署 · 超强 API 后端 · 复刻 FlintHub 微内核生态
基线版本:FlintHub PHP V1.5.0(剥离博客后的纯净版架构)

| 项 | 内容 |
|:--|:--|
| 文档名 | ARCHITECTURE.md(项目库根级) |
| 文档版本 | V1.0 · 定稿 |
| 定稿日期 | 2026-11-03 |
| 状态 | 立项定稿,可据以开工 |
| 事实基线 | FlintHub PHP V1.5.0 代码实测,非纯方案推演 |
| 适用读者 | 后端开发、插件开发者、运维、站长 |


修订记录

| 版本 | 日期 | 变更 |
|:--|:--|:--|
| V1.0 | 2026-11-03 | 合并原第九、十、十一章;新增「生产级安全基线」「版本契约与平滑升级」「日志与可观测性」三章;补入「数据兼容性与双端切换验收」;对原草案中 9 处与 FH 实际实现不符的描述做了事实修正(逐条见 [附录 D](#附录-d与原草案的差异说明)) |


摘要

  1. 一次降维进化,不是重写。 SoBBS 继承 FlintHub(下称 FH)的全部业务语义与数据资产,只替换运行时:用常驻内存 + 协程并发,替掉 FPM 短进程模型。
  2. 数据零迁移。 不重做表结构、不重做分片布局、不重做正文外置格式,Go 直接读写 FH 现有的 SplitDB 数据目录,PHP ⇄ Go 双后端可随时切换、互为兜底。
  3. 零第三方框架。 仅用 net/http、database/sql、html/template 与一个纯 Go SQLite 驱动,路由、模板、缓存、会话、日志全部自研。
  4. 插件即契约。 接口 + 注册制 + 编译期注入,配 Ed25519 签名校验;动态可换的只有视图、静态资源、配置。
  5. 可运营。 单二进制替换升级、幂等增量迁移、同主版本内可回滚、全链路 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/图标/上传)

三点必须说清:

  1. data/ 是 FH 的原生布局,不是为新程序设计的。 目录名、库文件名、桶编号规则、正文文件命名(t<id>.txt / r<id>.txt)全部沿用,SoBBS 只是换了个读者。
  2. FH V1.5.0 的真实库在 data/meta/ 下。 站点根目录里的 data/business.sqlite、data/main_index.sqlite 是 0 字节占位文件。部署迁移时必须搬 data/meta/ 整目录,连同 -wal / -shm 一起;只拷 .sqlite 会得到一个"能打开但全空"的库,并且不报错。
  3. 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.css

plugin.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)

  1. lib/router:基于 net/http 的极简路由,支持 /thread/{id} 命名参数与 /thread/{id}.html 伪静态。
  • FH 实际路由是 /thread/{id} 形式,.html 后缀由 Nginx try_files 转发,Go 侧需同时兼容带后缀与不带后缀两种入参。
  1. lib/db:database/sql + modernc.org/sqlite,封装连接池与基础查询。
  • DSN 必须携带 _pragma=busy_timeout(5000) 与 _pragma=journal_mode(WAL),这是并发不锁表的前提。
  1. lib/template:themes/ 目录加载 + 编译缓存 + mtime 失效;注册 raw、asset、url、pagination 等 FuncMap。
  2. lib/cache:常驻内存 KV。
  • 必须支持 TTL 过期 与 写操作主动失效:发帖、删帖、改分类、改设置后立即清除相关键,不能只靠自然过期。
  1. lib/session:读写 sessions 表,字段与 FH 完全一致(id / data / expires)。

阶段二:核心业务平移(The Logic)

  1. 用户注册、登录、鉴权跑通;会话与 FH 共用一张表。
  2. 平移 FH 的发帖、回帖、版块列表 API,字段与 SQLite 表结构逐一对齐。
  3. 前端套用 FH 默认三栏模板(转写为 html/template),Web 端无缝平移。
  4. 验收点:同一份 data/ 目录下,PHP 与 Go 交替启动,同一账号、同一帖子读写结果一致。

阶段三:插件与生态预埋(The Soul)

  1. 全局 Hook 事件总线设计与验证(含 panic 隔离、顺序确定性)。
  2. 将 FH 剥离出的"博客插件"Go 逻辑预埋进主程序,验证后台一键开关。
  3. 迁移签到、AI 日报等高频插件。

阶段四:自研 SplitDB 与高阶特性(The Beast)

  1. 并发写队列:用 Go Channel 实现单写者模型,所有 INSERT / UPDATE / DELETE 进队列串行落盘,彻底消除 database is locked。
  2. 多分片并行查询:sync.WaitGroup + errgroup 跨分片并发读取。
  3. 静默热升级: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 侧需完整复刻:

  1. 校验客户端提交的 SID 是否在库中存在且未过期,不存在则丢弃并重新生成。
  2. 登录成功后立即生成新 SID,删除旧行。
  3. 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)
    }
}

三条硬性约定:

  1. 禁止 err.Error() 直出前端。 它几乎必然包含内部细节。
  2. 禁止 fmt.Errorf 吞掉原始错误。 用 %w 包装,保留 errors.Is/As 链。
  3. 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 语言与依赖

  1. SQLite 驱动:坚决弃用 mattn/go-sqlite3(需 CGO,交叉编译地狱),统一用纯 Go 的 modernc.org/sqlite。
  2. 禁止 embed 模板:模板必须外置,站长才能改皮肤。
  3. 禁止反射式插件加载:不做 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.go
  • CGO_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 部署流程

  1. 下载:获取对应平台的 sobbs 二进制(约 18MB)。
  2. 放置:上传至 /var/www/sobbs/,与 data/、themes/、assets/ 同级。
  3. 授权:chmod +x sobbs。
  4. 初始化:
  • 新站:直接运行,首次启动检测到无 config.json 则进入安装引导,创建数据库骨架。
  • 存量站:把 FH 的 data/ 整目录(含 -wal / -shm)搬过来,程序直接接管,不做任何数据迁移。
  1. 启动:./sobbs,或交给 systemd(见 9.4)。
  2. 扩展:主题包解压至 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;
    }
}

三条注意:

  1. data/ 必须被 Nginx 显式拒绝。虽然 Go 不会把它暴露出去,但静态直出规则若写宽了(比如 location / 直接 try_files 命中磁盘),.sqlite 会被下载。必须加一条 location ^~ /data/ { deny all; },或干脆把 data/ 放到 Web 根目录之外。
  2. X-Forwarded-For 必须由 Nginx 覆盖,不能透传客户端自带的,否则 IP 可伪造。
  3. 不要用 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.target

ReadWritePaths 必须显式包含 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 兼容性红线

以下行为一旦发生,就破坏了"随时可回滚"的承诺,属于发布阻断项:

  1. 给现有表加必填字段(PHP 版不认识,插入即失败)。
  2. 改变现有字段的语义或单位(如把秒改毫秒)。
  3. 把 sessions.data 改写成 JSON。
  4. 在 extern/ 之外新建正文存储位置。
  5. 用 AUTOINCREMENT 替代 id_generator。
  6. 重命名或移动 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):

  1. 逐 entry 校验:拒绝绝对路径、..、符号链接、硬链接、设备文件。
  2. 单文件与总解压体积设上限(防 ZIP 炸弹)。
  3. 先解压到临时目录,全部校验通过后再原子移动到 plugins/<id>/。
  4. 安装后校验目录内不含可执行二进制——动态资源包只允许 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) |
| 超时 | ReadHeaderTimeout 5s、ReadTimeout 15s、WriteTimeout 30s、IdleTimeout 60s |
| 并发连接 | 按内存设定上限,超出快速失败而非排队 |
| 上传文件 | 白名单后缀 + MIME 嗅探 + 大小上限 |
| 分页 | page_size 上限 100,超出截断 |

11.8 安全红线(发布阻断)

  1. 任何 err.Error() 出现在响应体。
  2. 任何用户可控字符串直接拼进文件路径或 SQL。
  3. data/、protected/、logs/ 可被 Web 直接访问。
  4. 未验签的插件包可安装。
  5. 会话 Cookie 缺少 HttpOnly。
  6. 生产环境开启调试模式或 pprof 公网暴露。
  7. 日志中记录明文密码、token、完整 Cookie。

十二、版本契约与平滑升级规范

站长敢不敢用"单文件替换"升级,取决于这一章写得多硬。本章定义的是法律级约束,不是建议。

12.1 版本号语义

采用 MAJOR.MINOR.PATCH:

| 位 | 变更含义 | 数据影响 | 可回滚性 |
|:--|:--|:--|:--|
| MAJOR | 破坏性架构变更 | 可能有不可逆迁移 | 需专门迁移方案,不承诺回滚 |
| MINOR | 新增功能、新增可选字段/表 | 只增不改 | 同 MAJOR 内必须可回滚 |
| PATCH | 修 bug、优化、无结构变更 | 无 | 任

意可回滚 |

核心承诺:同主版本内,数据可无缝回滚。 即 1.3.x 的任何版本之间可以任意升降级,不需要改数据。

12.2 数据库变更四条铁律

  1. 只加不删。 新增字段必须有默认值或允许 NULL;新增表必须独立。任何情况下不得删表、不得删字段、不得改字段类型。
  2. 只用增量脚本。 每个版本一个迁移文件,命名 migrations/0007_add_thread_excerpt.sql,按序号单调递增执行。
  3. 迁移必须幂等。 重复执行结果一致。判断"是否已执行"不靠记忆,靠结构探测(如查 PRAGMA table_info 看字段是否存在),而不是"跑过就跳过"。
  4. 迁移必须可中断恢复。 每条迁移在事务内执行;失败整体回滚,不留半成品状态。

幂等迁移示例:

-- 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 升级红线

  1. 升级过程中删除任何数据文件。
  2. 迁移脚本带 DROP TABLE / DROP COLUMN。
  3. 迁移脚本不可重复执行。
  4. 升级前不做备份。
  5. 跨 MAJOR 升级不读变更说明直接上。
  6. 用 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 可观测性红线

  1. 请求链路无 request_id。
  2. 日志中出现明文凭据。
  3. panic 不上报、不计数。
  4. /metrics 与 /debug/pprof 公网可达。
  5. 日志同步写盘阻塞主流程。

十四、总结

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_replies

threads 与 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.sqlite

CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    data TEXT,
    expires INTEGER NOT NULL      -- 秒级时间戳
);

B.5 global_id.sqlite

CREATE 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 | 未说明 session data 格式 | 新增 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 实测,引用前请以代码与库结构复核。

主楼