Files
sub-store/docs/review-resolutions.md
T
rogee 264b84a738 refactor: remove standalone migrate command, auto-migrate on startup
Migrations already run idempotently during RunServer startup via goose
(which tracks applied versions in goose_db_version). The separate
 command is redundant — removed it and consolidated
serveCmd/configFile/init into server.go.
2026-07-27 14:43:22 +08:00

24 KiB
Raw Blame History

Review 决议记录

来源:2026-07-27 对 backend-dev-plan.md 的 review,共 45 条问题。 本文档对每条问题给出明确决议,开发时以此为准,不得偏离。 如需变更决议,先修改本文档再改代码。


决议速查

# 问题 决议 落地位置
1 ProxyNode 类型 map[string]any model/types.go
2 script 过滤器迁移 不保留 script 类型 filter/custom.go
3 process→FilterRule 转换 不实现,前端直发 FilterRule[]
4 defaultSettings 合并 实现 mergeSettings + 特殊浅合并 handler/settings.go
5 envPayload feature flags 定义并返回,buildTimeScripts=false handler/env.go
6 Flow info URL hash 解析 实现完整解析 handler/flow.go
7 normalizeTarget UA 推断 实现完整关键词映射 model/target.go
8 临时源覆盖 实现仅覆盖第一个匹配源 handler/download.go
9 parseJsonOrText 实现 JSON 优先 + 纯文本回退 handler/ 工具函数
10 SW 清理端点 不实现(无 CF 迁移)
11 getPublicBaseUrl 实现 PUBLIC_DOWNLOAD_HOSTS 配置 handler/link.go
12 中文拼音排序 golang.org/x/text/collate + chinese filter/sort.go
13 splitHostPort 自写 lastIndexOf(":") 版本 util/
14 JS URL vs Go net/url net/url 为基 + 逐协议测试 + 边缘处理 proxy/uri_parser.go
15 URL-safe Base64 区分 StdEncoding / RawURLEncoding util/base64.go
16 Unicode flag 正则 RE2 \p{} 支持,实测验证 util/flag.go
17 secureRandomInt crypto/rand.Int(天然无偏) filter/sort.go
18 formatDuplicateNumber 自定义数字字符 实现完整功能 filter/dedupe.go
19 normalizeTaiwanFlag 三模式 实现完整 filter/flag.go
20 isUsefulProxy ASCII 校验 实现完整 filter/quick.go
21 applyState 字符串状态值 实现兼容解析 filter/quick.go
22 snell/ssh/h2-connect 仅 client_parser 解析 proxy/client_parser.go
23 normalizeClientProxyKind 别名 实现别名映射 proxy/client_parser.go
24 isTokenValid 双哈希 先 SHA-256 再 ConstantTimeCompare util/token.go
25 CRLF 注入防护 校验 + 清洗所有 header 值 middleware/security.go
26 getBearerToken 三种提取 实现 Bearer / query / x-sub-store-token middleware/auth.go
27 CSP 安全头 收紧为 script-src 'self'(无 unsafe-eval middleware/security.go
28 SQLite PRAGMA WAL + busy_timeout + foreign_keys + MaxOpenConns=1 database/db.go
29 archiveAndDeleteResource 事务 BeginTx 包裹三步 handler/ 删除逻辑
30 importStorage 导入顺序 settings→sources→templates→collections handler/storage.go
31 exportStorage 排除内置模板 过滤 BuiltinTemplateIDs handler/storage.go
32 source_cache TTL 清理 后台 goroutine 定时清理 database/cache_repo.go
33 goose 迁移嵌入 //go:embed + SetBaseFS database/migrations.go
34 goroutine 关闭安全 sync.WaitGroup + context service/subscription.go
35 缓存操作错误吞咽 recover + 日志,不传播 database/cache_repo.go
36 并发 wait 延迟 保留 waitMs 参数 service/concurrency.go
37 MAX_SCRIPT_ACTIONS custom 规则上限 32 条 filter/custom.go
38 restoreDownloadGrantSnapshot 恢复时写回 tokenHash handler/recycle.go
39 FRONTEND_VERSION 常量 定义 = "1.0.0" config/
40 TEST_URL 常量 定义 generate_204 render/
41 defaultProxyGroups 无模板时生成 3 默认组 template/builtin.go
42 sing-box 完整结构 log+inbounds+route+outbounds 全实现 render/singbox.go
43 profile-update-interval 默认值 默认 "6" handler/download.go
44 cache-control: no-store 下载响应设此头 handler/download.go
45 modernc.org/sqlite 性能 接受 tradeoff,保留 swap 注释 database/db.go

逐条详述

🔴 架构级决策(#1-#3)— 已由 AGENTS.md 约束豁免

#1 ProxyNode 类型 → map[string]any

决议ProxyNode 定义为 type ProxyNode map[string]any

理由

  • 13 种协议各有不同可选字段(reality-opts, ws-opts, plugin-opts, auth_str, congestion-controller...),struct 需定义全部字段且无法处理未知字段
  • 过滤器通过 getByPath(proxy, "ws-opts.headers.Host") 做点号路径访问,map 天然支持
  • JSON/YAML 反序列化到 map 保留所有字段;struct 会丢失未知字段
  • stripUndefined(删除 nil 和 "")在 map 上语义清晰
  • 类型安全损失可接受——节点字段本质是动态的

实现要点

  • model/types.gotype ProxyNode map[string]any
  • 深拷贝:json.Marshaljson.Unmarshal(简单可靠,性能可接受)
  • 类型安全访问:在 util/path.go 提供 GetByPath(node, "a.b.c") / SetByPath / GetString / GetInt / GetBool 等辅助函数

#2 script 过滤器类型 → 不保留

决议Go 版不实现 script 类型过滤器。用户自定义逻辑通过 custom 声明式规则链实现。

理由:全新项目无存量 script 过滤器,无需兼容。

影响

  • GET /api/scripts 返回空数组 [](端点保留,前端兼容)
  • applyFilters 中无 script 分支
  • validateScriptActions 不需要

#3 process→FilterRule 转换 → 不实现

决议Go API 直接接受 FilterRule[] 格式,不实现前端 process 数组到 FilterRule[] 的转换。

理由:前端尚未实现,将适配 Go API 设计。前端直接发送 FilterRule[] 格式。


🟠 关键功能遗漏(#4-#11

#4 defaultSettings 合并 → 实现完整合并

决议GET /api/settings 返回 mergeSettings(defaultSettings(), storedSettings)

合并规则

  • 顶层 keystored 覆盖 default
  • themeappearanceSetting:浅合并(stored 的子 key 覆盖 default 的对应子 key,而非整体替换)
  • 其他嵌套对象:深合并

落地handler/settings.go + config/defaults.go(定义 DefaultSettings() 函数)。

#5 envPayload feature flags → 定义并返回

决议GET /api/env 返回 feature 对象,各 flag 取值如下:

buildTimeScripts: false   // 无 JS 引擎
proxyConversion: true     // POST /api/proxy/parse
ruleConversion: true      // POST /api/rule/parse
scopedShares: true        // 分享令牌
recycleBin: true          // 回收站
nodeInfo: true            // POST /api/utils/node-info
surgeMac: true            // surge-mac 渲染

落地handler/env.go

#6 Flow info URL hash 解析 → 完整实现

决议:实现 parseFlowRequest,从 URL # fragment 中解析参数。

支持两种格式

  1. JSON 格式:#{"flowUrl":"...","noFlow":true,"flowUserAgent":"...","flowHeaders":{}}
  2. Query-string 格式:#flowUrl=...&noFlow=true&flowUserAgent=...

额外fetchFlowHeaders 从响应体中提取 upload= / download= / total= / expire= 流量信息(subscription-userinfo header 优先,body 次之)。

落地handler/flow.go

#7 normalizeTarget UA 推断 → 完整关键词映射

决议:实现完整 UA 关键词→目标格式映射,按优先级匹配。

映射表(按检查顺序):

UA 关键词 目标格式
sing-box singbox
v2ray / v2rayng v2ray
surge (含 mac) surge-mac
surge (不含 mac) surge
loon loon
egern egern
shadowrocket shadowrocket
quantumult qx
stash stash

落地model/target.goNormalizeTarget(target, ua string) string

#8 临时源覆盖 → 仅覆盖第一个匹配源

决议:实现 applyTemporarySourceOverride,通过 ?url= / ?content= / ?ua= query 参数临时覆盖。

关键行为:只覆盖集合中第一个匹配的源(按 sourceIds 顺序),不是全部覆盖。与原项目行为一致。

落地handler/download.go

#9 parseJsonOrText → JSON 优先 + 纯文本回退

决议:实现 parseJsonOrText(body []byte) (map[string]any, error)

逻辑

  1. 尝试 json.Unmarshal → 成功则返回
  2. 失败则将 body 作为纯文本,返回 {"content": string(body)}

应用端点template 创建/更新、storage 导入。

落地handler/ 下的工具函数。

#10 Service Worker 清理端点 → 不实现

决议:不实现 /sw.js/registerSW.js 端点。

理由:全新项目,无 Cloudflare 版 Service Worker 残留需要清理。

#11 getPublicBaseUrl → 支持 PUBLIC_DOWNLOAD_HOSTS

决议:实现 getPublicBaseUrl(r *http.Request) string

逻辑

  1. 读取环境变量 / 配置 SUB_STORE_PUBLIC_DOWNLOAD_HOSTS(逗号分隔域名列表)
  2. 若非空,返回第一个域名作为 base URL
  3. 否则返回 r.Host(请求自身的 origin

落地handler/link.gobuildDownloadLink


🟡 实现细节/陷阱(#12-#23

#12 中文拼音排序 → golang.org/x/text/collate

决议:引入 golang.org/x/text/collate + golang.org/x/text/language,使用 collate.New(language.SimplifiedChinese) 做中文拼音排序。

落地filter/sort.gosortProxies。在 go.mod 添加依赖。

#13 splitHostPort → 自写版本

决议:不使用 net.SplitHostPort,自写 splitHostPort(s string) (host, port string)

行为:用 strings.LastIndex(s, ":") 切分,与原项目一致。能正确处理 IPv6(如 [::1]:443 → host=[::1], port=443)和裸 IPv6(无端口时返回 host=s, port="")。

落地util/proxy/uri_parser.go 内部函数。

#14 JS URL vs Go net/url → net/url 为基 + 逐协议测试

决议:以 net/url.Parse 为基础解析器,针对已知差异做适配:

  • Fragment 处理Go 的 url.Fragment 不会自动 URL-decode,需要手动 url.QueryUnescape(fragment) 获取节点名
  • Userinfo 提取:用 u.User.Username()u.User.Password(),不手动切分
  • IPv6 hostnameu.Hostname() 自动去掉方括号
  • Query().Get() 返回 "":原项目 URLSearchParams.get() 返回 nullGo 返回 ""。所有 != null 判断改为 != ""

落地proxy/uri_parser.go。为每种协议编写表驱动测试,覆盖正常/异常/边界 case。

#15 URL-safe Base64 → 明确区分

决议:在 util/base64.go 中提供三组函数:

函数 编码 用途
DecodeBase64Std base64.StdEncoding vmess JSON
DecodeBase64URL base64.URLEncoding
DecodeBase64RawURL base64.RawURLEncoding (无填充) SSR
EncodeBase64URL base64.URLEncoding
EncodeBase64RawURL base64.RawURLEncoding

同时提供 DecodeBase64Auto(s string) — 自动尝试 Std 和 RawURL 两种编码(先 Std,失败再 RawURL),因为订阅内容编码不一定规范。

落地util/base64.go

#16 Unicode flag 正则 → RE2 支持,实测验证

决议Go RE2 支持 \p{Regional_Indicator}\uFE0F / \u200D

验证清单

  • detectFlag:用真实国旗 emoji🇭🇰 🇯🇵 🇺🇸 🇹🇼)测试
  • removeFlag:测试含 ZWJ (\u200D) 和 variation selector (\uFE0F) 的复合 emoji
  • 编写单元测试覆盖所有 Unicode flag 场景

落地util/flag.go

#17 secureRandomInt → crypto/rand.Int

决议:直接使用 crypto/rand.Int(rand.Reader, big.NewInt(int64(max)))

理由Go 的 crypto/rand.Int 内部已实现无偏随机(rejection sampling),无需手动实现。比原项目的 JS 版更简洁。

落地filter/sort.go 的 shuffle 逻辑。

#18 formatDuplicateNumber 自定义数字字符 → 完整实现

决议:实现 formatDuplicateNumber(index int, template string) string

功能template 可包含自定义数字字符集,用空格分隔。如 "一 二 三 四 五" → index=2 输出 "二"。默认为阿拉伯数字 "1 2 3 4 5..."

落地filter/dedupe.go

#19 normalizeTaiwanFlag 三模式 → 完整实现

决议flag 过滤器的 tw 参数支持三种模式:

参数值 输出
ws 🇼🇸 (萨摩亚旗)
tw 🇹🇼 (台湾旗)
默认/其他 🇨🇳 (中国旗)

落地filter/flag.go

#20 isUsefulProxy ASCII 校验 → 完整实现

决议isUsefulProxy 不仅检查节点名关键词,还校验:

  • cipher 值是否为纯 ASCII
  • password 值是否为纯 ASCII
  • WS Host headerws-opts.headers.Host)是否为纯 ASCII

任一非 ASCII → 标记为 useless。

落地filter/quick.go

#21 applyState 字符串状态值 → 兼容解析

决议applyState 接受以下值并归一化为 bool

输入 输出
true / "ENABLED" / "enabled" true
false / "DISABLED" / "disabled" false

落地filter/quick.goparseState(v any) bool

#22 snell/ssh/h2-connect → 仅客户端配置行解析

决议:这三种类型仅在 Surge/Loon 客户端配置行解析中出现,不出现在 URI 协议中。

  • snellSurge 配置行 snell = name, server, port, psk, ...
  • sshSurge 配置行 ssh = name, server, port, ...
  • h2-connectSurge 配置行

落地proxy/client_parser.go,不放入 uri_parser.go

#23 normalizeClientProxyKind 别名 → 实现映射

决议:客户端配置行的协议类型别名归一化:

shadowsocks → ss
socks5-tls  → socks5
https       → http
hysteria 2  → hysteria2
tuic-v5     → tuic

落地proxy/client_parser.gonormalizeClientProxyKind(kind string) string


🔒 安全相关(#24-#27

#24 isTokenValid 双哈希 → 先 SHA-256 再 ConstantTimeCompare

决议:所有 token 校验先做 SHA-256 哈希,再用 subtle.ConstantTimeCompare 比较哈希值,避免长度泄露。

两种场景

  1. admin_token / download_token(配置文件中的明文 secret):

    inputHash = sha256Hex(input)
    secretHash = sha256Hex(secret)
    return subtle.ConstantTimeCompare([]byte(inputHash), []byte(secretHash)) == 1
    
  2. scoped grant(数据库存储 sha256Hex(token)):

    inputHash = sha256Hex(input)
    return subtle.ConstantTimeCompare([]byte(inputHash), []byte(storedHash)) == 1
    

落地util/token.goIsTokenValid(input, secret string) boolIsGrantTokenValid(input string, storedHash string) bool

#25 CRLF 注入防护 → 校验 + 清洗

决议

  1. setResponseHeader:设置任何 header 值前校验 !strings.ContainsAny(value, "\r\n"),不合法则跳过设置并记录警告日志
  2. safeContentDisposition:清洗文件名——移除 \r\n 和控制字符,对特殊字符做引号转义

落地middleware/security.gohandler/download.go 中的工具函数。

#26 getBearerToken 三种提取 → 完整实现

决议:token 从三处提取,优先级从高到低:

  1. Authorization: Bearer <token> header
  2. ?token=<token> query parameter
  3. x-sub-store-token header

落地middleware/auth.goextractToken(c *fiber.Ctx) string

#27 CSP 安全头 → 收紧

决议Go 版前端不含 eval,CSP 收紧为:

default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
connect-src 'self';
font-src 'self' data:;

与原项目差异:移除 'unsafe-eval'(原项目前端有 eval,Go 版不需要)。

落地middleware/security.go


🗄️ 数据库相关(#28-#33

#28 SQLite PRAGMA → 完整配置

决议:在 database/db.go 打开连接后立即执行:

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL;

连接池

  • db.SetMaxOpenConns(1) — modernc.org/sqlite 在高并发写时会出现 database is locked,单连接 + WAL 足以应对 sub-store 的个人工具场景
  • 读写都在同一连接上,WAL 模式下读不阻塞写

落地database/db.goInitDB(path string) (*sqlx.DB, error)

#29 archiveAndDeleteResource 事务 → BeginTx 包裹

决议:删除资源时在一个事务中执行三步:

BEGIN TRANSACTION;
  INSERT INTO recycle_bin (id, resource_type, resource_id, snapshot_json, deleted_at) VALUES (...);
  DELETE FROM {sources|collections|templates} WHERE id = ?;
  DELETE FROM recycle_bin WHERE id NOT IN (
    SELECT id FROM recycle_bin ORDER BY deleted_at DESC LIMIT ?
  );
COMMIT;

落地handler/ 删除逻辑 + database/recycle_repo.goArchiveAndDelete(tx, resourceType, resourceID, snapshot, maxEntries)

#30 importStorage 导入顺序 → 强制顺序

决议:导入顺序固定为:

  1. settings — 无依赖
  2. sources — 无依赖
  3. templates — 无依赖(但 collections 引用 templates
  4. collections — 引用 sources 和 templates

每步独立事务,失败则中止并返回错误。

落地handler/storage.goimportStorage

#31 exportStorage 排除内置模板 → 过滤

决议:导出时过滤掉 ID 属于 BuiltinTemplateIDs 集合的模板。

var exportedTemplates []Template
for _, t := range allTemplates {
    if !builtinTemplateIDs[t.ID] {
        exportedTemplates = append(exportedTemplates, t)
    }
}

落地handler/storage.goexportStorage

#32 source_cache TTL 清理 → 后台 goroutine

决议:启动一个后台 goroutine,每隔 cache_ttl 间隔执行一次清理:

func StartCacheCleaner(ctx context.Context, db *sqlx.DB, interval time.Duration) {
    ticker := time.NewTicker(interval)
    go func() {
        for {
            select {
            case <-ticker.C:
                db.ExecContext(ctx, "DELETE FROM source_cache WHERE cached_at + ttl < ?", time.Now().Unix())
            case <-ctx.Done():
                ticker.Stop()
                return
            }
        }
    }()
}

在 graceful shutdown 时通过 context 取消。间隔取 cache_ttl 配置值(默认 300s)。

落地database/cache_repo.goStartCacheCleaner

#33 goose 迁移嵌入 → //go:embed + SetBaseFS

决议:迁移 SQL 文件嵌入二进制:

//go:embed migrations/*.sql
var embedMigrations embed.FS

func RunMigrations(db *sqlx.DB) error {
    goose.SetBaseFS(embedMigrations)
    return goose.Up(db.DB, "migrations")
}

落地database/migrations.go + database/migrations/*.sql。启动时自动执行,幂等(goose 跟踪 goose_db_version)。


🔄 并发/异步(#34-#36

#34 goroutine 关闭安全 → WaitGroup + context

决议:异步缓存写入通过 sync.WaitGroup 管理生命周期。

type AsyncWriter struct {
    wg     sync.WaitGroup
    ctx    context.Context
    cancel context.CancelFunc
}

func (aw *AsyncWriter) Write(fn func()) {
    aw.wg.Add(1)
    go func() {
        defer aw.wg.Done()
        select {
        case <-aw.ctx.Done():
            return
        default:
            fn()
        }
    }()
}

func (aw *AsyncWriter) Wait() {
    aw.cancel()
    aw.wg.Wait()
}

在 graceful shutdown 时调用 Wait(),带 5 秒超时。

落地service/subscription.godatabase/cache_repo.go

#35 缓存操作错误吞咽 → recover + 日志

决议safeCacheMatchsafeCachePut 包装缓存操作:

func safeCacheGet(repo *CacheRepo, key string) (string, bool) {
    defer func() {
        if r := recover(); r != nil {
            logrus.Warnf("cache get panicked: %v", r)
        }
    }()
    return repo.Get(key)
}

缓存操作失败(SQL 错误、panic)只记录日志,不传播给订阅生成管线。

落地database/cache_repo.go 的 safe wrapper。

#36 并发 wait 延迟 → 保留参数

决议RunWithConcurrencyRunSettledWithConcurrency 的签名保留 wait time.Duration 参数。

行为:每个任务开始前(第一个除外)time.Sleep(wait)。用于避免同时请求同一 CDN 被限流。对应配置 fetcher.concurrency_wait

落地service/concurrency.go


📋 其他遗漏(#37-#45

#37 MAX_SCRIPT_ACTIONS → custom 规则上限 32 条

决议custom 过滤器的规则链上限 MAX_CUSTOM_RULES = 32。不保留原项目的 MAX_SCRIPT_ACTIONS = 2(那是 JS 脚本的限制,Go 版无 JS 引擎)。

落地filter/custom.go 的验证逻辑。

#38 restoreDownloadGrantSnapshot → 恢复时写回 tokenHash

决议:从回收站恢复 share 类型时,快照中的 tokenHash 需写回 download_grants 表。这是 share 恢复的特殊逻辑——其他资源类型(source/collection/template)恢复时不需要处理 tokenHash。

落地handler/recycle.go 的 restore 逻辑。

#39 FRONTEND_VERSION 常量

决议:定义 const FrontendVersion = "1.0.0",用于 GET /api/env 响应。

落地internal/config/config.gohandler/env.go

#40 TEST_URL 常量

决议:定义 const TestURL = "https://www.gstatic.com/generate_204",用于 mihomo/sing-box 渲染中 url-test proxy group 的 test URL。

落地render/mihomo.go / render/singbox.go

#41 defaultProxyGroups → 无模板时生成 3 默认组

决议:当 collection 未配置模板或模板无 proxy-groups 时,生成 3 个默认代理组:

组名 类型 行为
🚀 节点选择 select 手动选择,含所有节点 + ♻️ 自动选择
♻️ 自动选择 url-test 自动测速,含所有节点
🚀 手动切换 select 手动选择,仅含所有节点

落地template/builtin.goDefaultProxyGroups()

#42 sing-box 完整结构 → 全实现

决议:sing-box 输出包含完整顶层配置:

{
  "log": { "level": "info" },
  "inbounds": [{
    "type": "mixed",
    "tag": "mixed-in",
    "listen": "0.0.0.0",
    "listen_port": 7890,
    "sniff": true
  }],
  "outbounds": [
    { "tag": "PROXY", "type": "selector", "outbounds": [...], "interrupt_exist_connections": false },
    { "tag": "AUTO", "type": "urltest", "outbounds": [...], "url": "https://www.gstatic.com/generate_204", "interrupt_exist_connections": false },
    { "tag": "DIRECT", "type": "direct" },
    { "tag": "REJECT", "type": "block" },
    ...proxy outbounds...
  ],
  "route": {
    "auto_detect_interface": true,
    "final": "PROXY"
  }
}

落地render/singbox.go

#43 profile-update-interval 默认值

决议:下载响应设置 profile-update-interval header,值为 metadata.profileUpdateInterval,若为空则默认 "6"

落地handler/download.go

#44 cache-control: no-store

决议:下载响应设置 Cache-Control: no-store,防止 CDN/浏览器缓存订阅内容。

落地handler/download.go

#45 modernc.org/sqlite 性能 → 接受 tradeoff

决议:使用 modernc.org/sqlite(纯 Go),接受写性能低于 CGO 版的 tradeoff。

理由

  • sub-store 是个人工具,写频率低(主要是缓存写入),性能不是瓶颈
  • 纯 Go 支持 CGO_ENABLED=0 静态编译,跨平台部署简单
  • 如未来性能不足,可切换到 mattn/go-sqlite3(接口不变,仅换驱动 + 启用 CGO)

落地database/db.go 添加注释说明 tradeoff 和替代方案。