# GoFrame 框架使用规范 本文档记录项目中 GoFrame v2 的使用规范、正确模式和已修复的框架使用错误。开发时按需查阅,发现新的框架使用 bug 修复后同步更新本文档。 > **提示**:遇到不确定的 GoFrame 用法时,可使用 `/goframe-v2` skill 查询框架最新规范和最佳实践。 ## 时间处理 ### 规则 - **ORM 写入**:使用 `gtime.Now()`,与 GoFrame ORM 的时间类型自然兼容 - **纯计算/计时**:使用标准库 `time.Now()`(如 `time.Since(start)` 计算耗时、`time.Now().Unix()` 生成时间戳) - **time → gtime 转换**:使用 `gtime.NewFromTime(t)` 包装后再写入 ORM ### 正确示例 ```go // ORM 写入场景 — 用 gtime dao.XxxTable.Ctx(ctx).Data(g.Map{ "updated_at": gtime.Now(), }).Update() // 耗时计算 — 用标准 time start := time.Now() doSomething() elapsed := time.Since(start) // time.Time 转 gtime 写入 ORM nextRetry := time.Now().Add(delay) dao.XxxTable.Ctx(ctx).Data(g.Map{ "next_retry_at": gtime.NewFromTime(nextRetry), }).Update() ``` ### 常见错误 - 在 ORM `.Data()` 中直接传 `time.Now()` — 应使用 `gtime.Now()` - 在 `time.Since()` 中使用 `gtime.Now()` — 应使用 `time.Now()` ## 错误处理 ### 规则 - 业务错误使用 `gerror.NewCode(gcode.New(code, msg, nil), msg)` 创建预定义错误常量 - 预定义错误常量放在 `internal/consts/consts.go` - 包装底层错误时使用 `gerror.Wrapf(err, "context message")` - 临时业务错误使用 `gerror.Newf("中文提示 %s", param)` - Controller 层不处理错误,直接 `return nil, err` 交给中间件统一处理 ### 错误码分级 | 码段 | 用途 | 示例 | |------|------|------| | 400-499 | 标准 HTTP 客户端错误 | 401 未认证、403 无权限、404 不存在 | | 500 | 服务器内部错误 | 未预期的异常 | | >= 10000 | 业务规则错误(HTTP 422) | 10001 余额不足、10002 额度用完 | ### 正确示例 ```go // consts.go — 预定义业务错误 var ErrInsufficientBalance = gerror.NewCode( gcode.New(CodeInsufficientBalance, MsgInsufficientBalance, nil), MsgInsufficientBalance, ) // logic 层 — 返回预定义错误 if balance < amount { return nil, consts.ErrInsufficientBalance } // logic 层 — 包装底层错误 result, err := dao.XxxTable.Ctx(ctx).Insert(data) if err != nil { return nil, gerror.Wrapf(err, "insert xxx") } // logic 层 — 临时业务错误 return nil, gerror.Newf("兑换码状态为%s", redemption.Status) ``` ### 错误安全规则 `internal/response` 包自动执行错误脱敏: - 业务错误(>= 10000)和 HTTP 4xx:原始消息透传给客户端 - 其他错误(数据库、网络等):替换为 `"服务器内部错误"`,原始错误记日志 - 禁止在错误消息中暴露 SQL、堆栈、内部路径等技术细节 ## 日志记录 ### 规则 - 统一使用 `g.Log()` 全局日志,不要创建自定义 Logger 实例 - **必须传 ctx**:`g.Log().Errorf(ctx, "message: %v", err)`,确保日志携带 request_id - 日志级别:`Error`(需要修复的异常)、`Warning`(降级处理、已脱敏的错误)、`Info`(关键业务事件) - 后台 goroutine 中如果没有请求 ctx,使用 `context.TODO()` ### 正确示例 ```go g.Log().Errorf(ctx, "update task %d to failed: %v", taskID, err) g.Log().Warningf(ctx, "webhook: config %d auto-disabled after %d failures", configID, count) g.Log().Infof(ctx, "API key %d disabled, cache invalidation for prefix %s", keyID, prefix) ``` ### 常见错误 - `g.Log().Error("message")` — 缺少 ctx,日志中不会有 request_id - 在可恢复的业务场景使用 Error 级别 — 应使用 Warning ## ORM 操作 ### DAO 模式(优先) ```go // 查询 var user entity.TntUsers err := dao.TntUsers.Ctx(ctx).Where("id", id).Where("tenant_id", tenantID).Scan(&user) // 插入(使用 DO 结构体) id, err := dao.ChnChannels.Ctx(ctx).InsertAndGetId(do.ChnChannels{ Name: req.Name, Provider: req.Provider, }) // 更新(使用 DO 结构体) _, err := dao.TntUsers.Ctx(ctx).Where("id", id).Data(do.TntUsers{ Status: "disabled", }).Update() // 删除 _, err := dao.ApiKeys.Ctx(ctx).Where("id", id).Where("tenant_id", tenantID).Delete() // 分页 m := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID) total, _ := m.Count() var list []entity.TntUsers err := m.Page(page, pageSize).OrderDesc("id").Scan(&list) ``` ### 原生 SQL(仅用于复杂查询和批量操作) ```go // 聚合查询、分析报表等无法用 ORM 表达的场景 result, err := g.DB().Ctx(ctx).Raw(` SELECT date_trunc('hour', created_at) AS hour, count(*) FROM aud_request_logs WHERE ... `, args...).All() // 原子更新(如余额操作) _, err = g.DB().Exec(ctx, `UPDATE bil_wallets SET balance = balance - $1 WHERE id = $2 AND balance >= $1`, amount, walletID, ) ``` ### 事务 ```go err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // tx.Model() 替代 dao.Xxx.Ctx(),确保操作在同一事务内 _, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{...}).Insert() if err != nil { return err // 自动回滚 } _, err = tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{...}).Insert() return err }) ``` ### g.Map vs DO 结构体 - **新增/全量更新**:优先使用 `do.XxxTable{}` 结构体,类型安全 - **部分更新**:可使用 `g.Map{"field": value}`,灵活但需注意字段名拼写 - **混用场景**:changelog 等直接操作表名的场景可用 `g.DB().Model("table").Data(g.Map{...})` ## 配置读取 ```go // 使用 g.Cfg() 读取 manifest/config/ 下的配置 secret := g.Cfg().MustGet(ctx, "jwt.secret").String() maxSessions := g.Cfg().MustGet(ctx, "jwt.adminMaxSessions").Int() hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String() ``` ## 参数校验 API 结构体使用 `v` tag 声明校验规则,GoFrame 自动执行校验: ```go type CreateAdminUserReq struct { g.Meta `path:"/admin-users" method:"post" tags:"管理员" summary:"创建管理员"` Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位"` Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位"` Status string `json:"status" v:"required|in:active,disabled#请选择状态|状态值无效"` } ``` - `#` 后面是自定义中文错误消息,多个规则的消息用 `|` 分隔 - 校验失败自动返回 400 错误,无需在 logic 层重复校验 ## 类型转换 使用 `gconv` 包进行安全类型转换: ```go name := gconv.String(record["name"]) ids := gconv.Int64s(record["user_ids"]) rate := gconv.Float64(record["threshold"]) methods := gconv.Strings(record["notification_methods"]) ``` ## 缓存 项目使用 `gcache` 作为 L1 内存缓存,封装在 `internal/logic/common/cache.go`: ```go // 通过 common 包的缓存封装使用,不要直接调用 gcache common.CacheSet(ctx, "key", value, ttl) val, err := common.CacheGet(ctx, "key") common.CacheRemove(ctx, "key") ``` ## Context 传递 ```go // 从 Context 读取请求级变量(中间件注入) requestID := r.GetCtxVar("RequestId").String() scope := r.GetCtxVar("ApiKeyScope").String() // 获取当前请求对象 r := g.RequestFromCtx(ctx) ``` --- ## 已修复的框架使用错误记录 > 每次修复 GoFrame 框架使用相关的 bug 后,在此处记录修复内容、原因和正确做法,防止同类问题再次出现。 ### 2026-04-16:GoFrame gcode 非标准码用作 HTTP 状态码导致 panic **问题**:`response.Error()` 中直接将 `gerror.Code().Code()` 作为 HTTP 状态码传给 `WriteHeader()`,但 GoFrame 内置的 `gcode`(如 `CodeNotModified=68`)不是合法 HTTP 状态码,导致 `WriteHeader` panic。 **修复**:在 `response.go` 中增加范围检查,非 100-599 的 code 回退为 500。 ### 2026-04-16:MiddlewareHandlerResponse 未过滤文件下载响应 **问题**:导出 CSV/Excel 时,控制器直接写入 `r.Response.Writer`,但中间件仍然追加 `{"code":0,...}` JSON 到响应体末尾,导致文件损坏。 **修复**:在 `handler_response.go` 中检查 `Content-Type`,匹配 `text/csv`、`application/vnd.openxmlformats` 等下载类型时跳过 JSON 包装。 ### 2026-04-16:isSystemError 日志污染 **问题**:所有错误(包括 400 参数校验、401 认证失败等正常业务流)都以 Warning 级别记录日志,大量无意义日志淹没真正的异常。 **修复**:增加 `isSystemError()` 函数,4xx 和 >= 10000 的业务错误不记日志,只有 5xx 和未知错误才记录。 ### 2026-05-14:Scan(&struct) 查询无结果返回 sql: no rows in result set **问题**:GoFrame v2 中 `dao.Xxx.Ctx(ctx).Where(...).Scan(&structValue)` 当查询无匹配行时,返回 `sql: no rows in result set` 错误(Go 标准库 `sql.ErrNoRows`)。后续通过 `structField == 0` 判断"无数据"的代码永远不会执行,因为 `err != nil` 会先返回。导致 playground chat、定价查询、钱包查询、渠道调度等链路在数据库无对应记录时暴露原始 SQL 错误给用户。 **原因**:GoFrame v2 的 `Scan` 对 struct 值类型和指针类型行为不同: - `Scan(&structValue)` — 无行时返回 `sql.ErrNoRows` - `Scan(&pointerValue)` — 无行时返回 `nil`,指针设为 `nil` **修复**:将所有"期望零或一行"的 `Scan` 调用从值类型改为指针类型,用 `if x == nil` 替代 `if x.Field == 0` 判断。涉及文件: - `internal/logic/tenant/playground.go`(findActiveApiKey) - `internal/logic/relay/provider.go`(CheckTenantModelAccess、tryAffinityChannel、GetModelDetail、getChannelKey) - `internal/logic/billing/pricing.go`(GetModelPrice 的三次 Scan、EstimatePreDeductAmount) - `internal/logic/billing/wallet.go`(GetWallet、syncWalletToRedis、preDeductDB、preDeductSyncDB、unfreezeDB、recordTransaction) **正确做法**:查询单行记录时,始终使用指针类型接收 Scan 结果,通过 `nil` 检查判断无数据。 ### 2026-05-23:gf gen ctrl withService:true 对新方法生成桩代码(Not Implemented) **问题**:按正确顺序执行 `logic → gf gen service → gf gen ctrl` 后,`gf gen ctrl` 生成的控制器文件中方法体是 `return nil, gerror.NewCode(gcode.CodeNotImplemented)`,调用 API 返回 501 Not Implemented。 **原因**:`gf gen ctrl` 的 `withService: true` 自动接线机制对新方法不可靠。即使 service 接口文件已包含正确的方法签名,ctrl 生成器仍可能无法匹配并生成桩代码。且一旦首次生成桩代码,后续重跑 `gf gen ctrl` 会跳过已存在的文件,不会覆盖修复。 **修复**:手动将控制器文件改为 `return service.Admin().MethodName(ctx, req)` 调用。 **正确做法**:每次执行 `gf gen ctrl` 后,必须检查生成的控制器文件是否包含 `CodeNotImplemented`: ```bash grep -r "CodeNotImplemented" internal/controller/ ``` 如果有匹配,手动将桩代码替换为 `return service.Admin().MethodName(ctx, req)`。或者先删除对应控制器文件再重新执行 `gf gen ctrl`。