# Fonu 改进路线实施方案(P0-P3) > 制定日期:2026-09-27 > 适用版本:v1.2.19 之后的迭代 > 依据文档:`docs/project-analysis-2026-09-27.md` > 文档性质:实施方案,不代表本轮已经修改代码 > 目标:把改进清单拆成可评审、可实施、可验证、可回滚的工程任务 --- ## 0. 执行摘要 ### 0.1 优先级与目标 | 优先级 | 目标 | 建议周期 | 预计工作量 | 产出 | | --- | --- | --- | ---: | --- | | P0 | 修复明确问题与安全边界 | 1 个短迭代 | 3-6 人日 | v1.2.x 修复版 | | P1 | 降低 FRP、Agent、前端巨型模块风险 | 2-4 个迭代 | 15-25 人日 | 架构重构版 | | P2 | 工程质量收口与测试补强 | 与 P1 并行,后续收尾 | 10-18 人日 | 稳定维护版 | | P3 | 能力增强 | 独立产品迭代 | 40-70 人日 | v1.3/v1.4 功能版 | 预计工作量为熟悉本项目的一名工程师估算,不包含需求反复、真机环境等待和外部依赖调整。 ### 0.2 推荐实施顺序 ```text M1:P0 修复与安全收口 → M2:P1-4 测试基线与 P1-2 文件拆分 → M3:P1-1 共享 Nginx 模板 → M4:P1-3 前端拆分 + P1-5 轮询统一 → M5:P2 工程收口 → M6:P3 能力按单项排期 ``` 不推荐一次性同时推进 P1、P2、P3。FRP / Agent、前端大组件、数据库性能、迁移包和 2FA 都属于高回归风险区域,必须分批交付。 ### 0.3 总体原则 1. **每一步都保持可运行**:先补测试和特征化断言,再做重构。 2. **优先移动而不是重写**:拆分大文件时先保持函数体不变,只改变文件归属。 3. **Agent 行为变更必须递增 `AgentVersion`**:并同步重编 amd64、arm64 内嵌二进制。 4. **数据库变更只向前迁移**:新增迁移从 `028` 开始,不修改已经发布的迁移。 5. **敏感数据默认不回传**:新增接口必须遵守现有的掩码与“只显示一次”约定。 6. **新增接口必须有测试**:至少覆盖无鉴权、正常路径、失败路径和兼容路径。 7. **重构不得顺手改业务语义**:同一 PR 只做一类事情,便于审查和回滚。 8. **删除前先确认引用**:P2 清理旧代码前,先用 `rg` 证明无运行时引用;删除归档内容需单独确认。 --- ## 1. 基线与约束 ### 1.1 当前代码基线 | 项目 | 当前值 | | --- | --- | | 版本 | `1.2.19` | | Agent 版本 | `0.12.7` | | Go 模块 | `github.com/fonu/fonu` | | Go 声明版本 | 1.23 | | Go 文件 | 235 个,其中 90 个测试文件 | | 内部包 | 29 个 | | 数据库迁移 | 27 个 | | 受保护 API | 约 113 条 | | Agent API | 20 条 `/api/v1/*` | | 前端源文件 | 52 个 TypeScript / Vue 文件 | | 前端测试 | 2 个 spec、13 条测试 | ### 1.2 当前验证门禁 每次合并前至少执行: ```bash go test ./... -count=1 go vet ./... cd web npm run typecheck npm test npm run build ``` 涉及 Agent、Docker、Nginx 或跨平台路径时,还需要补: ```bash go build ./... ``` 必要时使用 `scripts/build-linux.ps1` 验证 Linux 安装包。 ### 1.3 当前已知工程状态 | 项目 | 当前状态 | 影响 | | --- | --- | --- | | `gofmt -l internal cmd` | 12 个文件未格式化 | 无法立即开启格式化 CI 卡口 | | 前端硬编码颜色 | 约 287 处匹配 `#hex`、`rgb()`、`rgba()` | 主题一致性与维护成本 | | FRP 主服务 | `internal/frp/service.go` 约 1805 行 | 修改风险集中 | | Agent 主服务 | `internal/agent/server.go` 约 1551 行 | HTTP、Nginx、frps、配置写盘耦合 | | 前端大页面 | `ProxyView.vue` 约 3150 行 | 组件复用、测试和样式隔离困难 | | 工作区 | 有未提交的删除、二进制与品牌图片修改 | 开始迭代前需确认基线提交 | ### 1.4 开始实施前的仓库准备 1. 确认 `Fonu-1.0.2` 内当前未提交改动是否要保留。 2. 将品牌图片、内嵌 Agent 二进制更新拆成独立提交,避免和功能改动混在一起。 3. 把 `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 的删除单独提交,不与其他重构同批。 4. 从最新 `main` 建分支,建议前缀 `codex/`。 5. 所有方案项都从当前 `origin/main` 的已提交状态开始,不基于未确认的工作区改动。 --- ## 2. 全局完成定义 ### 2.1 单个任务的完成定义 一个任务只有同时满足以下条件才算完成: - 代码已实现,并保持现有接口兼容; - 新增或更新了测试; - `go test ./... -count=1` 通过; - `go vet ./...` 通过; - 前端改动执行 `npm run typecheck`、`npm test`、`npm run build`; - Agent 行为变化已递增 `AgentVersion` 并重编双架构二进制; - 数据库变化已新增迁移且验证旧库升级; - 文档与 `CHANGELOG.md` 已更新; - 明确记录回滚方式; - 未把无关格式化、品牌资源、二进制替换混入同一提交。 ### 2.2 单个 PR 的边界 | PR 类型 | 允许包含 | 不允许包含 | | --- | --- | --- | | P0 修复 | 单一缺陷、对应测试、必要文档 | 大范围重构、格式化全仓 | | 拆分重构 | 文件移动、符号迁移、必要的包内适配 | 行为变化、顺手改接口 | | 模板收敛 | 共享纯函数、两组调用方改造、golden 测试 | 顺手改变 Nginx 输出语义 | | 前端拆分 | 组件搬迁、props/emits、样式同步搬迁 | 同时重做视觉样式 | | 性能优化 | 基准、索引、确认后的连接策略 | 未量化就改 SQLite 配置 | | P3 功能 | 迁移、接口、前端、测试、文档 | 多个 P3 功能混在一个 PR | --- ## 3. P0:先处理明确问题 P0 的目标是修复已经确认的缺陷与安全边界,不进行架构大改。 --- ## 3.1 P0-1:修正 DDNS 页旧 `/api/frp` 调用 > 实施状态:2026-09-27 已完成。 ### 3.1.1 现状 - `web/src/views/DdnsView.vue` 的 `init()` 调用 `api.getFRP()`。 - `web/src/api/client.ts` 仍保留旧方法: - `getFRP` - `saveFRP` - `getFRPStatus` - `syncFRPDomains` - `getFRPLogs` - 后端 `internal/api/router.go` 当前不注册 `/api/frp`,只注册 `/api/frpmulti/*`。 - `api.getFRP()` 因此失败,进入 `catch`,`frpEnabled.value = false`。 - DDNS 页顶部“FRP 内网穿透已启用”提示不会正确出现。 ### 3.1.2 目标 1. DDNS 页使用当前 FRP 多服务端接口。 2. 提示只在确实存在可用穿透配置时显示。 3. 不再依赖已退役的旧 API。 ### 3.1.3 实施步骤 1. 将 `DdnsView.vue` 的调用改为: ```ts const status = await api.getFrpStatus() frpEnabled.value = status.configured && status.enabled_proxies > 0 ``` 2. 如产品语义希望“配置了服务端就提示”,可调整为: ```ts frpEnabled.value = status.configured ``` 推荐使用 `configured && enabled_proxies > 0`,避免用户刚建服务端但没有任何穿透规则时看到误导提示。 3. 将 `DdnsView.vue` 中 `api.getFRP` 的导入或调用彻底替换。 4. 暂时保留旧方法不做删除,删除工作纳入 P2-2,避免 P0 同时扩大改动范围。 ### 3.1.4 测试 - 前端单元测试: - 新增 `DdnsView` 的辅助纯函数测试,或抽出 `shouldShowFrpHint(status)`。 - 覆盖 `configured=false`、`enabled_proxies=0`、`configured=true && enabled_proxies>0`。 - 手工验证: - 无 FRP 服务端时无提示; - 有服务端但无启用规则时无提示; - 有启用规则时出现提示; - 接口失败时不阻塞 DDNS 页面。 ### 3.1.5 验收标准 - `rg -n "api\\.getFRP" web/src/views/DdnsView.vue` 无结果。 - 页面不再向 `/api/frp` 发请求。 - DDNS 主要功能在接口异常时仍可加载。 ### 3.1.6 风险 | 风险 | 处理 | | --- | --- | | `getFrpStatus` 返回状态与旧字段不同 | 使用明确映射函数,禁止直接把旧 `enabled` 字段改名为新字段 | | 状态接口超时影响页面初始化 | 提示查询与主数据加载分离,失败只影响提示 | ### 3.1.7 实施记录 - `web/src/views/DdnsView.vue` 已改用 `api.getFrpStatus()`。 - 新增 `web/src/utils/frp.ts`,由 `shouldShowFrpHint` 统一判断“已配置且有启用规则”。 - 新增 `web/src/utils/frp.spec.ts`,覆盖配置、未配置、无启用规则和空状态。 - 旧 `/api/frp` 客户端方法尚未删除,按要求留到 P2-2 统一清理。 - 验证通过:`npm test` 17 条、`npm run typecheck`、`npm run build`。 --- ## 3.2 P0-2:初始管理员密码不再写入持久应用日志 ### 3.2.1 现状 - `internal/auth/auth.go` 的 `EnsureDefaultAdmin` 使用 `slog` 记录用户名和密码。 - `internal/app/app.go` 的日志同时写入 stdout 和 `/logs/app.log`。 - `internal/logstore/display.go` 的 `appendSystemAttrs` 专门把初始密码渲染进系统日志。 - `internal/api/logs_handler.go` 的 `/api/logs/system` 会读取 `app.log`。 - `scripts/fonu-install.sh` 当前从 `app.log` 中提取初始密码。 这意味着“初始密码只出现一次”的现实边界并不成立,密码会长期留在数据目录日志中。 ### 3.2.2 目标 1. 初始密码只在首次启动时输出一次。 2. 默认输出到进程 stdout/stderr,不写入 `app.log`。 3. Docker 用户可通过 `docker logs fonu` 获取。 4. systemd 用户可通过 `journalctl -u fonu` 获取。 5. 安装脚本改为从 journal 或服务日志读取,不再读取 `app.log`。 6. 日志中心不再能读到初始密码。 ### 3.2.3 推荐设计 修改 `auth.Service.EnsureDefaultAdmin`: ```go type BootstrapAdmin struct { Username string Password string Created bool } func (s *Service) EnsureDefaultAdmin(ctx context.Context) (BootstrapAdmin, error) ``` - `Created=false` 时不返回密码。 - 不在 `auth` 包内部写密码日志。 - `app.New` 收到 `Created=true` 后,使用独立的 `os.Stderr` 或标准输出直接打印,不经过 `slog` 的 `MultiWriter`。 示例输出: ```text Fonu 首次启动已创建管理员账户 用户名:admin 初始密码:<一次性密码> 请登录后立即修改密码。 ``` ### 3.2.4 实施步骤 1. 重构 `EnsureDefaultAdmin` 返回值。 2. `app.New` 调整调用与一次性输出。 3. 删除 `logstore.appendSystemAttrs` 中初始密码的特殊拼接分支。 4. 更新 `logstore/display_test.go`,断言日志中出现初始密码构建消息时不再拼接 `password`。 5. 更新 `scripts/fonu-install.sh`: - 删除从 `app.log` grep 密码的逻辑; - 改为读取 `journalctl -u fonu` 最近日志; - 如果服务尚未写入日志,则提示用户执行 `journalctl` 或 `docker logs`。 6. 更新 Docker、原生安装和 FAQ 文档。 ### 3.2.5 测试 - `internal/auth/auth_test.go` - 首次创建返回 `Created=true` 且有密码; - 第二次调用返回 `Created=false` 且无密码。 - `internal/app` 测试或日志测试: - `app.log` 不包含初始密码; - 标准输出可以包含一次初始密码。 - `internal/logstore/display_test.go` - 系统日志渲染不再输出 `password`。 - shell 验证: - 原生安装后 `journalctl -u fonu` 能读到初始密码; - `app.log` 中 grep 不到初始密码。 ### 3.2.6 验收标准 - `/logs/app.log` 中不出现初始密码。 - `/api/logs/system` 响应中不出现初始密码。 - 安装脚本仍能提示首次密码。 - 已初始化实例不受影响。 ### 3.2.7 兼容与回滚 - 已存在的日志不会被自动清理;可增加“安全清理旧日志”的手工建议,不在代码中自动删除。 - 回滚仅恢复日志输出方式,不影响管理员数据。 - 该修复不要求 Agent 升级。 --- ## 3.3 P0-3:Agent Token 使用常量时间比较 > 实施状态:2026-09-27 已完成。 ### 3.3.1 现状 `internal/agent/server.go` 的 `auth` 方法使用普通字符串比较: ```go if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token { ``` 这不是直接可利用漏洞,但不符合安全敏感的 Token 比较约定。 ### 3.3.2 目标 将 Bearer Token 解析成独立函数,并使用 `crypto/subtle.ConstantTimeCompare`。 ### 3.3.3 推荐实现 ```go func bearerToken(r *http.Request) string { header := strings.TrimSpace(r.Header.Get("Authorization")) const prefix = "Bearer " if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { return "" } return strings.TrimSpace(header[len(prefix):]) } func secureEqual(got, want string) bool { return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 } ``` `auth` 方法: ```go token := bearerToken(r) if token == "" || !secureEqual(token, s.cfg.Token) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } ``` ### 3.3.4 测试 新增 `internal/agent/auth_test.go`: - 正确 Token 返回 200; - 错误 Token 返回 401; - 缺失 Authorization 返回 401; - `bearer` 大小写兼容; - 多空格处理; - 长度不同时不 panic。 ### 3.3.5 验收标准 - 所有 Agent 接口仍使用相同 Bearer Token。 - 正确 Token 通过,错误 Token 拒绝。 - Agent 不依赖应用侧 API 中间件代码。 ### 3.3.6 实施记录 - `internal/agent/server.go` 新增 `bearerToken` 与 `secureTokenEqual`,认证路径改用 `crypto/subtle.ConstantTimeCompare`。 - `AgentVersion` 已由 `0.12.7` 提升到 `0.12.8`。 - 新增 `internal/agent/auth_test.go`,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization 和错误认证方案。 - 内嵌 amd64 / arm64 二进制已重编;VPS 需要执行一次「升级 Agent」。 - 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`(17 条)。 --- ## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态 > 实施状态:2026-09-28 已完成。 ### 3.4.1 现状 `internal/agent/server.go` 的 `readLimitState`: ```go _ = json.Unmarshal(data, &state) return state ``` 如果 `route-limits.json` 损坏: 1. 返回空 map; 2. `applyLimitState` 只写入当前域名; 3. `writeLimitState` 用不完整状态覆盖原文件; 4. 其他域名的限流配置丢失。 ### 3.4.2 目标 - 文件不存在:返回空状态,不报错。 - 文件存在但读取失败或 JSON 损坏:返回错误,不继续写。 - 调用方把错误回传到 HTTP 502/500,保留原文件和现有 Nginx zone。 ### 3.4.3 推荐接口 ```go func readLimitState() (map[string]routeLimit, error) func (s *Server) applyLimitState(req routeRequest) error func (s *Server) clearLimitState(domain string) error ``` 建议错误文案: ```text 读取限流状态失败: /var/lib/fonu-agent/route-limits.json: <原因> ``` ### 3.4.4 实施步骤 1. 修改 `readLimitState` 返回 `(map, error)`。 2. `os.IsNotExist` 返回空 map 和 nil。 3. `os.ReadFile` 其他错误返回错误。 4. `json.Unmarshal` 错误返回错误。 5. `applyLimitState`、`clearLimitState` 直接传播错误。 6. 新增测试: - 文件不存在; - 空对象; - 正常对象; - 截断 JSON; - 类型错误; - 解析失败时原文件内容不变。 7. 可选:在损坏时先生成 `.corrupt-` 快照,但不得自动删除原文件。 ### 3.4.5 验收标准 - 损坏状态不会导致其他域名限流配置被清空。 - 错误能显示在“应用配置”或路由保存响应中。 - `nginx -t` 仍以真实生成结果为准。 ### 3.4.6 实施记录 - `readLimitState` 改为返回 `(map[string]routeLimit, error)`,新增可测试的 `readLimitStateFile`。 - 文件不存在返回空状态;读取失败、空文件、`null` 或 JSON 损坏返回错误。 - `applyLimitState` / `clearLimitState` 传播错误并保留原文件,不再用空状态覆盖。 - 新增 `internal/agent/limits_test.go`,覆盖缺失、正常、空文件、截断 JSON、null 和原文件不变。 - `AgentVersion` 提升到 `0.12.9`,双架构内嵌二进制已重编。 - 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`。 --- ## 3.5 P0-5:明确 Agent 默认监听策略 ### 3.5.1 现状 - `cmd/fonu-agent/main.go` 默认 `:7700`。 - `internal/agent.NewServer` 空配置时也回落到 `:7700`。 - `scripts/agent-install.sh` 的 systemd 单位只设置环境文件,不显式限制监听地址。 - Agent 当前为明文 HTTP,Bearer Token 在网络中裸传。 - 安装脚本打印 `http://:7700`,鼓励直接公网访问。 ### 3.5.2 策略选择 推荐采用两阶段策略,避免一次性破坏现有部署: #### 阶段 A:先警告与显式标记 适用于下一个修复版本: 1. Agent 状态接口增加: - `listen_addr` - `transport_scheme` - `transport_secure` - `security_warning` 2. 当监听非环回地址且未启用 TLS 时: - 启动日志打印醒目的中文警告; - 前端“公网服务端”页展示警告; - 安装脚本提示必须使用 Nginx HTTPS 或 SSH 隧道; - 不立即阻止启动。 #### 阶段 B:默认仅本机,显式允许远程 适用于一个明确的 minor 版本: 1. 默认监听改为 `127.0.0.1:7700`。 2. 新增 `FONU_AGENT_ALLOW_REMOTE=1` 或 `-allow-remote`: - 没有该显式选项时,拒绝监听非环回地址。 3. 新增可选直接 TLS: - `FONU_AGENT_TLS_CERT` - `FONU_AGENT_TLS_KEY` 4. 非环回监听满足以下任一条件才允许: - TLS 证书和私钥已配置; - 明确使用 `--insecure-allow-http` 并打印高危警告。 5. 安装脚本默认写入: ```dotenv FONU_AGENT_LISTEN=127.0.0.1:7700 ``` 6. 如用户选择远程模式,安装脚本引导配置 Nginx HTTPS 反向代理,或写入显式不安全开关。 ### 3.5.3 中央 Fonu 的连接方式 推荐远程连接路径: ```text Fonu 控制面 → https://agent.example.com → VPS Nginx 443 → 127.0.0.1:7700 fonu-agent ``` 备选路径: ```text Fonu 控制面 → SSH 本地转发或 WireGuard → 127.0.0.1:7700 fonu-agent ``` 不推荐: ```text Fonu 控制面 → http://公网IP:7700 ``` ### 3.5.4 前端与接口变化 1. `AgentView.vue` 状态卡增加“传输安全”字段。 2. 当 `transport_secure=false` 且监听地址非本机时,显示橙色警告。 3. 安装脚本输出改为: ```text Agent 默认仅监听 127.0.0.1:7700。 如需远程连接,请先配置 HTTPS 反向代理,再设置 FONU_AGENT_ALLOW_REMOTE=1。 ``` ### 3.5.5 兼容迁移 升级已有 Agent 时: 1. 保留 `/etc/fonu-agent/agent.env`。 2. 旧配置未设置 `FONU_AGENT_LISTEN` 时: - 阶段 A 继续使用旧默认值; - 阶段 B 给出迁移提示,并在一个过渡版本内保持旧行为。 3. 新安装默认 loopback。 4. 如果旧部署使用公网 `:7700`,升级说明必须明确如何加 Nginx HTTPS。 5. `agent_url` 当前是 `http://` 时,前端显示不安全提示。 ### 3.5.6 测试 - 默认启动监听 loopback。 - 非环回监听无显式允许时拒绝启动。 - `--insecure-allow-http` 时启动并输出警告。 - 配置 TLS 时启动 HTTPS。 - 安装脚本生成的环境文件符合所选模式。 - 旧环境文件升级不丢 Token。 ### 3.5.7 验收标准 - 新安装不会默认暴露明文 Agent 到公网。 - 用户仍可通过明确配置保留远程管理能力。 - 文档给出 Nginx HTTPS 与 SSH 隧道两种方案。 - 安全状态在前端可见。 --- ## 4. P1:降低核心模块风险 P1 的核心是控制复杂度,不改变可见功能。 --- ## 4.1 P1-1:抽共享 Nginx 模板包 ### 4.1.1 现状 本机与公网 Agent 各有一套 Nginx 生成逻辑: | 位置 | 用途 | | --- | --- | | `internal/nginx/generate.go` | 本机反代主配置与规则块 | | `internal/nginx/security.go` | 本机安全、限流、geo、TLS | | `internal/agent/server.go` 的 `nginxRoute` | VPS vhost | | `internal/agent/server.go` 的 `sslDirectives` | VPS TLS | | `internal/agent/server.go` 的 `zoneKey` / `writeLimitState` | VPS 限流 zone | 同一类安全问题需要改两处,容易漂移。 ### 4.1.2 共享边界 新建纯生成包,建议命名: ```text internal/nginxtmpl ``` 该包只负责把结构化参数渲染成字符串,不依赖: - `internal/proxy` - `internal/config` - `internal/frp` - `internal/agent` - 文件系统 - 进程执行 这样 `internal/nginx` 和 `internal/agent` 都可以安全导入,不会循环依赖。 ### 4.1.3 建议 API ```go package nginxtmpl type TLSConfig struct { CertPath string KeyPath string TLS13Only bool } type AccessControl struct { Allow []string Deny []string ChinaOnly bool } type LimitConfig struct { Zone string Rate int Burst int Conn int } type SecurityHeaders struct { HTTPS bool Enabled bool HSTSMaxAgeDays int } func SSLDirectives(cfg TLSConfig) string func ServerSecurityHeaders(cfg SecurityHeaders) string func AccessDirectives(cfg AccessControl, indent string) string func LimitDirectives(cfg LimitConfig, indent string) string func RateLimitZone(zone string, rate int) string func ConnLimitZone(zone string) string func ZoneName(prefix string, parts ...string) string ``` ### 4.1.4 先做特征化测试 在改动前,为两份输出增加或固定以下测试: - HTTPS 证书指令; - TLS 1.3 开关; - HSTS 与安全响应头; - allow / deny 顺序; - 白名单末尾 `deny all`; - WebSocket Upgrade 头; - 限流与连接数指令; - 中国 IP geo 变量; - 错误页 location; - 空参数不输出多余指令。 这些测试先锁定现有语义,再替换为共享函数。 ### 4.1.5 实施步骤 1. 新增 `internal/nginxtmpl`。 2. 先迁出无业务依赖的函数: - TLS 指令; - 安全响应头; - allow / deny; - WebSocket 头; - zone 命名。 3. 本机 `internal/nginx` 改为调用共享函数,保持输出逐字节一致。 4. Agent `nginxRoute` 分阶段改为调用共享函数。 5. 为中国 IP 仅大陆规则保留差异适配层: - 本机使用 `geo $fonu_client_ip` 与规则 ID 变量; - Agent 使用 `$fonu_china_client`; - 两者共享 allow/deny 与返回 403 结构,不强行共享变量名。 6. 删除重复实现,但保留本机与 Agent 各自的上下文拼装函数。 ### 4.1.6 验收标准 - 同一参数在两端的 TLS、安全头、WebSocket、限流输出一致。 - `internal/agent` 不再自行实现通用 TLS 指令。 - 两端测试均可独立覆盖共享模板。 - 本机 Nginx 与 Agent 生成内容没有未解释的语义变化。 ### 4.1.7 风险 | 风险 | 处理 | | --- | --- | | 两端缩进与上下文不同 | 共享函数接受 `indent`,不强制服务块结构 | | Agent 使用域名 zone,本机使用规则 ID | `ZoneName` 只负责合法化,不负责决定命名规则 | | 修改模板导致现有 vhost 重写 | 先做 golden 对比,再在部署时保留版本回滚 | --- ## 4.2 P1-2:拆分 `internal/frp/service.go` 与 `internal/agent/server.go` ### 4.2.1 拆分原则 1. 同一 Go 包内拆文件,不改变外部接口。 2. 只移动函数、类型和常量,不重写逻辑。 3. 每个文件目标 300-500 行以内。 4. 每个函数继续遵守函数长度不超过 50 行的仓库约定。 5. 拆分后先跑全量测试,再做后续优化。 ### 4.2.2 `internal/frp` 目标文件 | 新文件 | 责任 | | --- | --- | | `service.go` | Service 结构、构造、公共装配 | | `service_servers.go` | 服务端 CRUD、配置保存、凭证与 TLS 资产 | | `service_proxies.go` | 穿透规则 CRUD、列表、日志 | | `service_runtime.go` | frpc 启动、停止、重载、状态、自动恢复 | | `service_download.go` | 版本列表、下载任务、二进制激活与清理 | | `service_diagnostics.go` | 服务端五项诊断与延迟记录 | | `service_agent_lifecycle.go` | SSH 安装、升级、卸载、Probe、急救诊断 | | `service_agent_routes.go` | vhost 部署、删除、漂移、版本与证书推送 | | `service_health.go` | 规则健康、可用率、汇总 | | `service_config.go` | frpc.toml / frps.toml 渲染与原子写 | | `service_traffic.go` | frps dashboard 流量适配 | ### 4.2.3 `internal/agent` 目标文件 | 新文件 | 责任 | | --- | --- | | `server.go` | Config、Server、NewServer、路由注册、ListenAndServe | | `auth.go` | Bearer Token 解析与常量时间比较 | | `handlers_status.go` | ping、status、metrics 状态输出 | | `handlers_routes.go` | route CRUD、conf、版本、回滚 | | `handlers_nginx.go` | Nginx 探测、安装、校验、重载、日志 | | `handlers_frps.go` | frps 配置、安装、启停、日志 | | `handlers_certs.go` | 证书写入与列表 | | `handlers_china.go` | 中国 IP 段写入与 geo 文件生成 | | `limits.go` | route-limits.json、zone 文件、限流状态 | | `nginx_template.go` | Agent vhost 模板拼装 | | `path_detect.go` | Nginx 目录、可执行文件与版本探测 | | `basic_auth.go` | htpasswd 生成与复用 | ### 4.2.4 拆分顺序 1. 先拆 `internal/frp` 中低耦合的下载、诊断、健康文件。 2. 再拆服务端 CRUD 与运行管理。 3. 最后拆 Agent 生命周期与路由部署。 4. `internal/agent` 先拆 auth、limits、handlers_nginx,再拆 routes。 5. 每拆一组文件执行: ```bash go test ./internal/frp ./internal/agent -count=1 go test ./... -count=1 ``` ### 4.2.5 验收标准 - 包外 API 没有变化。 - 没有新增循环依赖。 - 测试结果与拆分前一致。 - 文件行数显著下降,主文件不再承担所有职责。 - 行为变更与文件移动没有混在同一 PR。 --- ## 4.3 P1-3:拆分前端巨型页面 ### 4.3.1 拆分原则 1. 组件只接收 props,通过 emits 通知父页面。 2. 网络请求留在页面或 composable 中,避免弹窗组件私自保存。 3. scoped 样式必须随模板一起搬迁。 4. 每次只拆一个页面,先拆纯展示区域,再拆表单。 5. 拆分后视觉应与原页面一致。 ### 4.3.2 `ProxyView.vue` 拆分建议 | 新组件 / composable | 责任 | | --- | --- | | `ProxyRuleFormModal.vue` | 新建/编辑表单、基础/安全/Nginx 三个页签 | | `ProxyRuleDetailModal.vue` | 规则详情、概览与日志 | | `ProxyClientsPanel.vue` | 当前连接与客户端 IP | | `ProxyNginxPanel.vue` | 自动/自定义配置、版本与回滚 | | `useProxyRules.ts` | 列表、保存、删除、排序 | | `useProxyTraffic.ts` | 流量与在线连接轮询 | ### 4.3.3 `SettingsView.vue` 拆分建议 | 新组件 / composable | 责任 | | --- | --- | | `SettingsGeneralPanel.vue` | 主题、时区、日志、DDNS、证书默认值 | | `SettingsNotificationsPanel.vue` | 通知渠道与告警事件 | | `SettingsSecurityPanel.vue` | 登录失败、全局 IP 策略、信任代理 | | `SettingsAdvancedPanel.vue` | 中国 IP、Nginx 主配置、状态页、指标 | | `BackupPanel.vue` | 立即备份、归档、恢复、删除 | | `ApiTokensPanel.vue` | Token 创建、范围、有效期、撤销 | | `useSettingsForm.ts` | 设置加载、脏状态、保存与掩码字段 | ### 4.3.4 `AgentView.vue` 拆分建议 | 新组件 / composable | 责任 | | --- | --- | | `AgentServerFormModal.vue` | 服务端、Agent、SSH、frps 配置表单 | | `AgentServerDetailModal.vue` | 详情、frps 同源配置、控制入口 | | `AgentServerLogsModal.vue` | Agent / frps 日志 | | `AgentDiagnosticsPanel.vue` | 五项诊断与可用率 | | `AgentMetricsPanel.vue` | 资源、延迟、日志和证书 | | `useAgentServers.ts` | 列表、选择、加载、生命周期调用 | | `useAgentPolling.ts` | 状态与诊断轮询 | ### 4.3.5 测试与验证 - 拆分后保留原有交互路径。 - 为 props/emits 增加 `@vue/test-utils` 测试。 - 使用 Playwright 对桌面与移动视口截图: - `/proxies` - `/settings` - `/frp-agent` - 检查弹窗尺寸、滚动区域、按钮位置、移动端文本溢出。 - 检查 `vue-tsc` 与 `vite build`。 ### 4.3.6 验收标准 - 单个页面文件不再超过约 1200 行。 - 新组件职责单一,父页面只负责协调。 - 原页面的交互和视觉不变。 - 拆分过程中未丢失 scoped 样式。 --- ## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试 ### 4.4.1 路由鉴权覆盖测试 目标:防止新增 API 时漏掉 `protect()`。 建议改造 `internal/api/router.go`: ```go type routeSpec struct { Method string Pattern string Handler http.HandlerFunc Public bool TokenDeny bool } ``` - 构造统一路由表; - 注册循环只负责挂载与包中间件; - 测试直接遍历路由表; - 对受保护路由发匿名请求,期望 401; - 对公开路由断言不因鉴权而 401。 测试文件: ```text internal/api/router_auth_test.go ``` Agent 同样增加: ```text internal/agent/router_auth_test.go ``` 遍历 20 个 `/api/v1/*` 路径,无 Bearer Token 必须返回 401。 ### 4.4.2 配置回滚测试 覆盖本机: - `internal/nginx/versions.go` 的版本列表、读取、回滚; - 非法版本名拒绝路径上跳; - 回滚时先校验语法; - 新配置校验失败时不覆盖当前配置; - `fsutil.BackupVersioned` 去重逻辑。 覆盖 Agent: - 写 vhost 前保存版本; - `nginx -t` 失败后恢复上一可用版本; - 首次部署失败时删除新文件而不破坏已有文件; - 回滚接口调用后执行 `nginx -s reload`; - 旧 Agent 无版本接口时返回升级提示。 建议测试文件: ```text internal/nginx/versions_test.go internal/agent/versions_test.go internal/frp/agent_versions_compat_test.go ``` ### 4.4.3 Agent 版本兼容测试 建立最低版本矩阵: | 能力 | 最低 Agent 版本 | | --- | --- | | 基础状态 / 路由 | 现有最低版本 | | metrics / logs / frps-config | 0.11.0 | | route versions / rollback | 0.12.2 | | 新版安全监听策略 | 新版本 | 测试内容: - `AgentClient` 遇到 404 / 405 返回 `ErrAgentEndpointMissing`; - 错误信息包含最低版本和升级提示; - 其他 4xx / 5xx 不误判为版本过低; - 状态接口返回 `agent_version` 后可正确展示; - 新字段缺失时前端降级,不显示空白页。 ### 4.4.4 验收标准 - 新增受保护路由漏鉴权时,测试会失败。 - 配置回滚失败不会覆盖当前可用配置。 - 旧 Agent 与新控制端组合时有明确提示,不崩溃。 --- ## 4.5 P1-5:统一前端轮询组合式函数 ### 4.5.1 现状 已有 `web/src/composables/useVisibilityPolling.ts`,并被: - `ProxyRemoteView.vue` - `FrpView.vue` 使用,但其他页面仍自行维护计时器: - `DashboardView.vue` - `ProxyView.vue` - `LogsView.vue` - `AgentView.vue` - `SettingsView.vue` 的中国 IP 状态轮询 当前 composable 没有 in-flight 互斥,慢请求仍可能堆积。 ### 4.5.2 目标 统一行为: 1. 页面隐藏时暂停; 2. 回到前台立即补一次; 3. 上一轮未结束时跳过下一轮; 4. 支持 `enabled` 开关; 5. 组件卸载时清理; 6. 可选 `immediate` 立即执行; 7. 返回 `busy` 状态,便于 UI 展示。 ### 4.5.3 建议 API ```ts export interface VisibilityPollingOptions { enabled?: Ref immediate?: boolean onError?: (error: unknown) => void } export function useVisibilityPolling( fn: () => void | Promise, intervalMs: number, options: VisibilityPollingOptions = {}, ) { // return { start, stop, busy, lastError } } ``` ### 4.5.4 实施步骤 1. 更新 composable,增加 in-flight 锁。 2. `tick()` 中: ```ts if (busy.value) return busy.value = true Promise.resolve(fn()) .catch(onError) .finally(() => { busy.value = false }) ``` 3. 将页面手动计时器替换为 composable。 4. 页面已有的“首次加载”和“轮询”分开: - 首次加载立即执行; - 轮询只刷新增量或状态。 5. SSE 日志不纳入轮询 composable。 ### 4.5.5 测试 - 隐藏时不执行; - 回到前台执行一次; - 请求未结束时跳过下一轮; - `enabled=false` 不执行; - 卸载后无计时器; - `onError` 被调用且不影响后续轮询。 ### 4.5.6 验收标准 - 各页面不再重复实现 `setInterval + visibilitychange`。 - 慢接口不会产生请求堆积。 - 页面前后台切换行为一致。 --- ## 5. P2:工程质量提升 P2 的重点是减少噪音、建立长期质量门禁,不改变大功能。 --- ## 5.1 P2-1:全仓 `gofmt` 与 CI 卡口 ### 5.1.1 当前结果 执行: ```bash gofmt -l internal cmd ``` 当前仍有未格式化文件,包括: - `internal/acme/job.go` - `internal/api/auth_handler_test.go` - `internal/api/logs_handler_test.go` - `internal/api/version_handler.go` - `internal/ddns/store.go` - `internal/ddns/tencentcloud_test.go` - `internal/frp/diagnostics.go` - `internal/frp/store.go` - `internal/nginx/generate_multi_test.go` - `internal/nginx/generate_seed_mix_test.go` - `internal/nginx/realip.go` - `internal/nginx/security.go` ### 5.1.2 实施步骤 1. 单独创建“格式化”提交。 2. 执行: ```bash gofmt -w ./cmd ./internal ``` 3. 检查 diff,确认只有格式变化。 4. 运行: ```bash go test ./... -count=1 go vet ./... ``` 5. 在 `.github/workflows/ci.yml` 的 `go vet` 前增加: ```yaml - name: gofmt run: test -z "$(gofmt -l ./cmd ./internal)" ``` ### 5.1.3 验收标准 - `gofmt -l internal cmd` 无输出。 - CI 对后续未格式化文件立即失败。 - 格式化 PR 不包含业务逻辑变化。 --- ## 5.2 P2-2:清理旧 FRP 类型、旧 API 方法与未使用样式 ### 5.2.1 旧 API 清理 目标: - `web/src/api/client.ts` 删除: - `getFRP` - `saveFRP` - `getFRPStatus` - `syncFRPDomains` - `getFRPLogs` - `web/src/api/types.ts` 删除不再使用的旧接口: - `FRPConfig` - `FRPResponse` - `FRPSaveResponse` - `FRPSavePayload` - 以及只服务旧 `/api/frp` 的 `FRPStatus` 注意:`FrpStatus` 这个名字与多服务端 `FrpStatus` 有潜在冲突,清理时先确认类型引用。 ### 5.2.2 前端样式清理流程 1. 枚举模板中的 class; 2. 与 `