652 lines
23 KiB
Markdown
652 lines
23 KiB
Markdown
# 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)`。
|
||
|
||
**合并规则**:
|
||
- 顶层 key:stored 覆盖 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 版前端不含 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` 打开连接后立即执行:
|
||
|
||
```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 和替代方案。
|