diff --git a/AGENTS.md b/AGENTS.md index fe829ecc..1304199b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,13 @@ 4. [docs/design/development.md](./docs/design/development.md) 作用:理解当前开发规范、阶段原则、分层约束、数据模型边界、API 约定、Agent 约束、前端规范与测试要求。 -5. [docs/guide/deployment.md](./docs/guide/deployment.md) +5. [docs/guildline/](./docs/guildline/) 下的所有开发准则文件 + 作用:通用代码开发准则与特定项目开发准则(包含通用 Go 设计模式、并发安全、数据库事务约束、参数解析解析响应规范、Utils 与 GORM 彻底隔离、Slice 去重及 JSON 序列化避坑细则)。 + +6. [docs/guide/deployment.md](./docs/guide/deployment.md) 作用:理解当前部署方式、Agent 接入、升级、卸载和联调步骤。 -6. [docs/reference/configuration.md](./docs/reference/configuration.md) +7. [docs/reference/configuration.md](./docs/reference/configuration.md) 作用:理解系统启动时支持的环境变量、命令行参数、运行时配置项和 Agent 配置字段。 如任务涉及用户文档、贡献者入口或排障体验,还应阅读: @@ -33,17 +36,19 @@ * 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。 * 如果实现方式违反 [开发约束](./docs/design/development.md),应优先调整方案,而不是绕过规范。 +* 如果实现方式涉及后端代码逻辑,必须严格遵循 [docs/guildline/](./docs/guildline/) 下的所有开发准则。 * 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/design/development.md) 中的变更准入与验收标准。 * 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/design/development.md) 中的前端规范。 ## 文档维护要求 -当以下内容发生变化时,应同步更新对应 VitePress 页面: +当以下内容发生变化时,应同步更新对应中文文档, 不要同步英文文档: * 产品范围或系统边界变化:更新 `docs/design/index.md` * 系统结构、模块职责变化:更新 `docs/design/architecture.md` * 发布、同步、回滚模型变化:更新 `docs/design/release-model.md` -* 开发约束、代码规范、接口约定、阶段原则、测试基线变化:更新 `docs/design/development.md` +* 业务分层、数据模型边界、接口约定、阶段原则、测试基线变化:更新 `docs/design/development.md` +* 后端开发规范、代码质量要求、重构模式、去重逻辑与避坑指南变化:更新 `docs/guildline/` 下的对应开发准则文件 * 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/guide/deployment.md` 和 `README.md` * 用户操作路径、常见场景变化:更新 `docs/guide/usage.md` * 本地开发、测试、构建方式变化:更新 `docs/guide/development.md` diff --git a/docs/Guidelines.md b/docs/guildline/Guidelines.md similarity index 100% rename from docs/Guidelines.md rename to docs/guildline/Guidelines.md diff --git a/docs/guildline/Project.md b/docs/guildline/Project.md new file mode 100644 index 00000000..eca2a45d --- /dev/null +++ b/docs/guildline/Project.md @@ -0,0 +1,57 @@ +# OpenFlare 特定项目开发准则 (Project Guidelines) + +本文档定义了针对 **OpenFlare** 项目特定的后端开发约束、架构设计模式、GORM 数据库交互规范以及关键的 JSON 序列化避坑指南。所有参与项目后端开发的代码必须严格遵守。 + +--- + +## 1. 统一接口输入与响应处理(Controller 约束) + +为了保证 API 的一致性,并消除控制器层中大量的样板代码,所有 Gin Controller 必须遵守以下规范: + +### 1.1 参数解析与绑定 +- **URL ID 参数解析**:必须调用统一的 `parseIDParam(c)` 辅助函数。严禁手写 `strconv.ParseUint(c.Param("id"), ...)`。 +- **JSON 请求体绑定**:必须调用统一的 `bindJSON(c, &input)` 辅助函数。严禁手动调用 `c.ShouldBindJSON` 或 `json.NewDecoder` 并重复编写错误返回逻辑。 + +### 1.2 标准 API 响应 +- 所有控制器方法的返回必须统一使用 `respondSuccess`、`respondFailure`、`respondBadRequest` 等标准方法。 +- **严禁手写** `c.JSON(http.StatusOK, gin.H{...})`,以确保全局 API 响应字段结构(`success`/`message`/`data`)的百分之百一致。 + +> [!IMPORTANT] +> 接口的入参解析与响应统一规范定义在 [openflare_server/controller/response.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/controller/response.go) 中。 + +--- + +## 2. 纯净工具类与数据库逻辑完全隔离(Utils 约束) + +为了确保代码的可测试性、高内聚和低耦合,`utils/` 目录下的工具包必须保持纯净性: + +### 2.1 无副作用与解耦原则 +- 所有底层客户端与外部服务对接包(如 `utils/acme` 证书操作、邮件发送、DNS 供应商对接等)**必须完全剥离数据库或 GORM 依赖**。 +- 工具包中严禁导入 `openflare/model` 包或直接访问数据库连接。它们应当只接受基础数据类型(如 `string`、`[]byte` 等)或本地无依赖结构体作为输入,并返回纯粹的计算或请求结果。 + +### 2.2 业务服务层(Service)职责 +- 业务服务层 `service/` 负责数据库实体的加载、组装、事务持久化,并将底层的具体网络或加密操作委托给 `utils/` 工具包。 +- 这样不仅保证了底层工具类的百分之百可单元测试性,也维护了清晰的系统分层。 + +--- + +## 3. Go 泛型切片去重与 JSON 序列化陷阱(Slice 约束) + +在进行切片操作和去重时,必须使用泛型辅助函数,并注意 Go Slice 的空/零值在 JSON 序列化中的表现。 + +### 3.1 避免重复编写 map-seen 逻辑 +- 禁止在 `service/` 或 `model/` 中手写临时的 map-seen 去重样板代码。 +- 必须统一调用基于 Go 泛型实现的 [openflare_server/utils/slice.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/utils/slice.go) 中的 `utils.Unique()` 辅助函数。 + +### 3.2 关键的 JSON 序列化规则(Nil vs. Empty Slice) +在 Go 中,未初始化的 `nil` 切片和已初始化的空切片 `[]T{}` 在内存中不同,它们在序列化为 JSON 时也有着决定性的区别: +- **`nil` 切片**:序列化为 JSON `null`。 +- **空切片 (`make([]T, 0)`)**:序列化为 JSON `[]`。 + +> [!CAUTION] +> **开发避坑准则**: +> 1. GORM 数据库的很多 JSON/Array 字段(例如 `domain_cert_ids`、`upstreams` 等)或配置版本变更检测机制(如 `checksum` 计算和 `diff` 检测),要求空数组在 JSON 中必须表示为 `[]` 而非 `null`,否则会触发重复发布或解析失败的 bug。 +> 2. `utils.Unique` 必须具备 **Nil-Preservation(空值保留)** 特性: +> - 如果传入的 Slice 是 `nil`,它必须返回 `nil`,以支持 `omitempty` 或在需要表示“缺失”的场景中输出 `null`。 +> - 如果传入的 Slice 不是 `nil`(即使长度为 0 或去重后长度为 0),它必须返回非 nil 的空切片 `make([]T, 0)`,以确保序列化为 `[]`。 +> 3. 所有类似的切片加工辅助函数都必须遵循此行为。 diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index 3daaf7b3..518e9768 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -11,6 +11,7 @@ import ( "openflare/utils/security" "os" "reflect" + "strings" "sync" ) @@ -313,3 +314,10 @@ func CloseDB() error { err = sqlDB.Close() return err } + +func IsUniqueConstraintError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "unique") +} diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index c83196ce..f95beebe 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -412,7 +412,7 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error) return nil }) if err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("版本号生成冲突,请重试") } return nil, err @@ -1095,6 +1095,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) } powEnabled, _ := getPoWConfigForRoute(route.ID, wafSnapshot) + if route.PoWEnabled { + powEnabled = true + } if !route.EnableHTTPS { builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) continue @@ -1849,6 +1852,12 @@ func renderPowConfigBundle(routes []*model.ProxyRoute, wafSnapshot snapshotWAFDo hasPow := false for _, route := range routes { powEnabled, powConfig := getPoWConfigForRoute(route.ID, wafSnapshot) + if route.PoWEnabled { + powEnabled = true + if decoded, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig); err == nil { + powConfig = decoded + } + } if !powEnabled { continue } diff --git a/openflare_server/service/managed_domain.go b/openflare_server/service/managed_domain.go index 0b4677b5..cd0031cc 100644 --- a/openflare_server/service/managed_domain.go +++ b/openflare_server/service/managed_domain.go @@ -46,7 +46,7 @@ func CreateManagedDomain(input ManagedDomainInput) (*model.ManagedDomain, error) return nil, err } if err = domain.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("域名已存在") } return nil, err @@ -64,7 +64,7 @@ func UpdateManagedDomain(id uint, input ManagedDomainInput) (*model.ManagedDomai return nil, err } if err = domain.Update(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("域名已存在") } return nil, err diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index c3f95b55..e44d27a7 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -83,7 +83,7 @@ func CreateNode(input NodeInput) (*NodeView, error) { applyGeoInfoFromIP(node, node.IP) } if err := node.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("节点标识生成冲突,请重试") } return nil, err @@ -454,7 +454,7 @@ func RegisterNodeWithDiscovery(payload AgentNodePayload) (*AgentRegistrationResp } applyNodeRuntime(node, payload, false) if err = node.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("节点标识生成冲突,请重试") } return nil, err diff --git a/openflare_server/service/origin.go b/openflare_server/service/origin.go index ed95e0e4..59c9392e 100644 --- a/openflare_server/service/origin.go +++ b/openflare_server/service/origin.go @@ -88,7 +88,7 @@ func CreateOrigin(input OriginInput) (*model.Origin, error) { return nil, err } if err = origin.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("源站地址已存在") } return nil, err @@ -108,7 +108,7 @@ func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) { } err = model.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Save(nextOrigin).Error; err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return errors.New("源站地址已存在") } return err @@ -171,7 +171,7 @@ func getOrCreateOriginByAddress(address string) (*model.Origin, error) { Remark: "", } if err := origin.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return model.GetOriginByAddress(normalizedAddress) } return nil, err diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index ee30f2e4..9fab4543 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "openflare/model" + "openflare/utils" "regexp" "strings" "time" @@ -121,7 +122,7 @@ func CreateProxyRoute(input ProxyRouteInput) (*ProxyRouteView, error) { return nil, err } if err = route.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("proxy route identity already exists") } return nil, err @@ -139,7 +140,7 @@ func UpdateProxyRoute(id uint, input ProxyRouteInput) (*ProxyRouteView, error) { return nil, err } if err = route.Update(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("proxy route identity already exists") } return nil, err @@ -438,7 +439,6 @@ func normalizeProxyRouteDomainsInput(route *model.ProxyRoute, rawDomain string, func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) { normalized := make([]string, 0, len(rawDomains)) - seen := make(map[string]struct{}, len(rawDomains)) for _, rawDomain := range rawDomains { domain := normalizeProxyRouteDomainValue(rawDomain) if domain == "" { @@ -447,12 +447,9 @@ func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) { if strings.Contains(domain, "://") || strings.Contains(domain, "/") { return nil, errors.New("domain format is invalid") } - if _, ok := seen[domain]; ok { - continue - } - seen[domain] = struct{}{} normalized = append(normalized, domain) } + normalized = utils.Unique(normalized) if len(normalized) == 0 { return nil, errors.New("at least one domain is required") } @@ -850,15 +847,7 @@ func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) } trimmed = append(trimmed, item) } - unique := make([]string, 0, len(trimmed)) - seen := make(map[string]struct{}, len(trimmed)) - for _, item := range trimmed { - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - unique = append(unique, item) - } + unique := utils.Unique(trimmed) normalized := make([]string, 0, len(unique)) var scheme string multiUpstream := len(unique) > 1 @@ -1091,10 +1080,6 @@ func validateOriginHost(raw string) error { return nil } -func isUniqueConstraintError(err error) bool { - return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") -} - // PoW configuration types and validation type ProxyRoutePoWListConfig struct { diff --git a/openflare_server/service/tls_certificate.go b/openflare_server/service/tls_certificate.go index 94c10cf6..b3a89ef6 100644 --- a/openflare_server/service/tls_certificate.go +++ b/openflare_server/service/tls_certificate.go @@ -105,7 +105,7 @@ func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, err return nil, err } if err = certificate.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("certificate name already exists") } return nil, err @@ -144,7 +144,7 @@ func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertifi return nil, err } if err = certificate.Update(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("certificate name already exists") } return nil, err @@ -219,7 +219,7 @@ func ApplyTLSCertificate(input TLSApplyInput) (*model.TLSCertificate, error) { } if err := cert.Insert(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("certificate name already exists") } return nil, err @@ -261,7 +261,7 @@ func UpdateAcmeCertificate(id uint, input TLSApplyInput) (*model.TLSCertificate, cert.ApplyStatus = "applying" if err := cert.Update(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("certificate name already exists") } return nil, err @@ -308,7 +308,7 @@ func ConvertTLSCertificateToAcme(id uint, input TLSApplyInput) (*model.TLSCertif cert.ApplyMessage = "" if err := cert.Update(); err != nil { - if isUniqueConstraintError(err) { + if model.IsUniqueConstraintError(err) { return nil, errors.New("certificate name already exists") } return nil, err diff --git a/openflare_server/service/waf.go b/openflare_server/service/waf.go index 175b5823..5520623c 100644 --- a/openflare_server/service/waf.go +++ b/openflare_server/service/waf.go @@ -6,6 +6,7 @@ import ( "fmt" "net/netip" "openflare/model" + "openflare/utils" "sort" "strings" "time" @@ -413,7 +414,6 @@ func loadWAFBindings() (map[uint][]uint, error) { func normalizeWAFIPList(items []string) ([]string, error) { normalized := make([]string, 0, len(items)) - seen := make(map[string]struct{}, len(items)) for _, raw := range items { item := strings.TrimSpace(raw) if item == "" { @@ -432,19 +432,15 @@ func normalizeWAFIPList(items []string) ([]string, error) { } item = addr.String() } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} normalized = append(normalized, item) } + normalized = utils.Unique(normalized) sort.Strings(normalized) return normalized, nil } func normalizeWAFCountryList(items []string) ([]string, error) { normalized := make([]string, 0, len(items)) - seen := make(map[string]struct{}, len(items)) for _, raw := range items { item := strings.ToUpper(strings.TrimSpace(raw)) if item == "" { @@ -453,30 +449,23 @@ func normalizeWAFCountryList(items []string) ([]string, error) { if len(item) != 2 || !unicode.IsLetter(rune(item[0])) || !unicode.IsLetter(rune(item[1])) { return nil, fmt.Errorf("%s 不是合法国家代码", item) } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} normalized = append(normalized, item) } + normalized = utils.Unique(normalized) sort.Strings(normalized) return normalized, nil } func normalizeStringList(items []string) []string { normalized := make([]string, 0, len(items)) - seen := make(map[string]struct{}, len(items)) for _, raw := range items { item := strings.TrimSpace(raw) if item == "" { continue } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} normalized = append(normalized, item) } + normalized = utils.Unique(normalized) sort.Strings(normalized) return normalized } @@ -518,18 +507,14 @@ func normalizeWAFRuleGroupIDs(groupIDs []uint) ([]uint, error) { } func uniqueUintIDs(ids []uint) []uint { - seen := make(map[uint]struct{}, len(ids)) normalized := make([]uint, 0, len(ids)) for _, id := range ids { if id == 0 { continue } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} normalized = append(normalized, id) } + normalized = utils.Unique(normalized) sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] }) return normalized } diff --git a/openflare_server/utils/slice.go b/openflare_server/utils/slice.go new file mode 100644 index 00000000..5d2433ed --- /dev/null +++ b/openflare_server/utils/slice.go @@ -0,0 +1,19 @@ +package utils + +// Unique returns a new slice containing only the unique elements of the input slice, +// preserving their original order. +func Unique[T comparable](slice []T) []T { + if slice == nil { + return nil + } + seen := make(map[T]struct{}) + result := make([]T, 0) + for _, item := range slice { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +}