Files
sub-store/docs/review-resolutions.md
T

652 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 不实现,分享已移除 | `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.go``type ProxyNode map[string]any`
- 深拷贝:`json.Marshal``json.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
- `theme``appearanceSetting`:浅合并(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
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.go``NormalizeTarget(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.go``buildDownloadLink`
---
### 🟡 实现细节/陷阱(#12-#23
#### #12 中文拼音排序 → golang.org/x/text/collate
**决议**:引入 `golang.org/x/text/collate` + `golang.org/x/text/language`,使用 `collate.New(language.SimplifiedChinese)` 做中文拼音排序。
**落地**`filter/sort.go``sortProxies`。在 `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 hostname**`u.Hostname()` 自动去掉方括号
- **Query().Get() 返回 ""**:原项目 `URLSearchParams.get()` 返回 `null`Go 返回 `""`。所有 `!= 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 header`ws-opts.headers.Host`)是否为纯 ASCII
任一非 ASCII → 标记为 useless。
**落地**`filter/quick.go`
#### #21 applyState 字符串状态值 → 兼容解析
**决议**`applyState` 接受以下值并归一化为 bool
| 输入 | 输出 |
|------|------|
| `true` / `"ENABLED"` / `"enabled"` | `true` |
| `false` / `"DISABLED"` / `"disabled"` | `false` |
**落地**`filter/quick.go``parseState(v any) bool`
#### #22 snell/ssh/h2-connect → 仅客户端配置行解析
**决议**:这三种类型仅在 Surge/Loon 客户端配置行解析中出现,不出现在 URI 协议中。
- `snell`Surge 配置行 `snell = name, server, port, psk, ...`
- `ssh`Surge 配置行 `ssh = name, server, port, ...`
- `h2-connect`Surge 配置行
**落地**`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.go``normalizeClientProxyKind(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.go` 的 `IsTokenValid(input, secret string) bool` 和 `IsGrantTokenValid(input string, storedHash string) bool`。
#### #25 CRLF 注入防护 → 校验 + 清洗
**决议**
1. **setResponseHeader**:设置任何 header 值前校验 `!strings.ContainsAny(value, "\r\n")`,不合法则跳过设置并记录警告日志
2. **safeContentDisposition**:清洗文件名——移除 `\r\n` 和控制字符,对特殊字符做引号转义
**落地**`middleware/security.go` 或 `handler/download.go` 中的工具函数。
#### #26 getBearerToken 三种提取 → 完整实现
**决议**:token 从三处提取,优先级从高到低:
1. `Authorization: Bearer <token>` header
2. `?token=<token>` query parameter
3. `x-sub-store-token` header
**落地**`middleware/auth.go` 的 `extractToken(c *fiber.Ctx) string`。
#### #27 CSP 安全头 → 收紧
**决议**Go 版前端不含 evalCSP 收紧为:
```
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` 打开连接后立即执行:
```sql
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.go` 的 `InitDB(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.go` 的 `ArchiveAndDelete(tx, resourceType, resourceID, snapshot, maxEntries)`。
#### #30 importStorage 导入顺序 → 强制顺序
**决议**:导入顺序固定为:
1. `settings` — 无依赖
2. `sources` — 无依赖
3. `templates` — 无依赖(但 collections 引用 templates
4. `collections` — 引用 sources 和 templates
每步独立事务,失败则中止并返回错误。
**落地**`handler/storage.go` 的 `importStorage`。
#### #31 exportStorage 排除内置模板 → 过滤
**决议**:导出时过滤掉 ID 属于 `BuiltinTemplateIDs` 集合的模板。
```go
var exportedTemplates []Template
for _, t := range allTemplates {
if !builtinTemplateIDs[t.ID] {
exportedTemplates = append(exportedTemplates, t)
}
}
```
**落地**`handler/storage.go` 的 `exportStorage`。
#### #32 source_cache TTL 清理 → 后台 goroutine
**决议**:启动一个后台 goroutine,每隔 `cache_ttl` 间隔执行一次清理:
```go
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.go` 的 `StartCacheCleaner`。
#### #33 goose 迁移嵌入 → //go:embed + SetBaseFS
**决议**:迁移 SQL 文件嵌入二进制:
```go
//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` 管理生命周期。
```go
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.go` 或 `database/cache_repo.go`。
#### #35 缓存操作错误吞咽 → recover + 日志
**决议**`safeCacheMatch` 和 `safeCachePut` 包装缓存操作:
```go
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 延迟 → 保留参数
**决议**`RunWithConcurrency` 和 `RunSettledWithConcurrency` 的签名保留 `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 → 不实现
**决议**:分享功能已移除,不再创建或恢复 `share` 类型回收站记录。
**落地**`handler/recycle.go` 的 restore 逻辑不包含 share 分支。
#### #39 FRONTEND_VERSION 常量
**决议**:定义 `const FrontendVersion = "1.0.0"`,用于 `GET /api/env` 响应。
**落地**`internal/config/config.go` 或 `handler/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.go` 的 `DefaultProxyGroups()`。
#### #42 sing-box 完整结构 → 全实现
**决议**:sing-box 输出包含完整顶层配置:
```json
{
"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 和替代方案。