# 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 模式(优先) 使用生成的 `dao.Xxx.Ctx(ctx)` 操作数据库,禁止直接使用 `g.DB().Model("table_name")`: ```go // 查询(指针类型,用 nil 判断无数据) var user *entity.TntUsers err := dao.TntUsers.Ctx(ctx).Where("id", id).Where("tenant_id", tenantID).Scan(&user) if err != nil { return nil, err } if user == nil { return nil, common.NewNotFoundError("用户") } // 插入(使用 DO 结构体) id, err := dao.ChnChannels.Ctx(ctx).InsertAndGetId(do.ChnChannels{ Name: req.Name, Provider: req.Provider, }) // 更新(使用 DO 结构体,框架自动维护 updated_at) _, 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) ``` ### 禁止模式 ```go // 禁止 — 绕过 DAO 直接操作表 g.DB().Model("tnt_users").Ctx(ctx).Where("id", id).Scan(&user) // 正确 — 使用 DAO 对象 dao.TntUsers.Ctx(ctx).Where("id", id).Scan(&user) ``` DAO 优势:链安全、列名映射、Handler 链(自动注入 tenant_id 等)。仅在以下场景允许 `g.DB().Model()`: - 动态表名(如泛型 batch writer) - 表别名 JOIN 查询(如 `Model("mdl_model_groups mg").LeftJoin(...)`) ### 原生 SQL(仅用于复杂查询和批量操作) ```go // 聚合查询、分析报表等无法用 ORM 表达的场景 // 注意:始终使用 ? 占位符,禁止 fmt.Sprintf 拼接值 result, err := g.DB().Ctx(ctx).Raw(` SELECT date_trunc('hour', created_at) AS hour, count(*) FROM aud_request_logs WHERE created_at >= ? `, sinceTime).All() // 原子更新(如余额操作) _, err = g.DB().Exec(ctx, `UPDATE bil_wallets SET balance = balance - ? WHERE id = ? AND balance >= ?`, amount, walletID, amount, ) ``` **SQL 参数化规则**:GoFrame 统一使用 `?` 占位符,驱动层自动转换为 PostgreSQL 的 `$1, $2...`。开发者永远写 `?`,禁止 `fmt.Sprintf` 拼接 SQL 值。 ### Scan 指针类型规则 查询单行记录时,**始终使用指针类型**接收 Scan 结果: ```go // 正确 — nil 指针 var wallet *entity.BilWallets err := dao.BilWallets.Ctx(ctx).Where("tenant_id", tenantID).Scan(&wallet) if err != nil { return nil, err } if wallet == nil { return nil, common.NewNotFoundError("钱包") } // 错误 — 值类型,无行时返回 sql.ErrNoRows var wallet entity.BilWallets err := dao.BilWallets.Ctx(ctx).Where("tenant_id", tenantID).Scan(&wallet) ``` 指针类型 Scan 无行时返回 `nil` 错误 + `nil` 指针,用 `if x == nil` 判断。值类型 Scan 无行时返回 `sql.ErrNoRows`,容易漏处理导致暴露技术细节给用户。 ### 写操作错误处理 所有数据库写操作(Insert/Update/Delete)的错误**必须处理**: ```go // 关键操作 — 必须 return err if _, err := dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{...}); err != nil { return nil, gerror.Wrapf(err, "record transaction") } // 级联操作 — 必须逐一检查 if _, err := dao.MdlPricingTiers.Ctx(ctx).Where("model_id", id).Delete(); err != nil { return nil, gerror.Wrapf(err, "delete pricing") } // 非关键操作 — 至少记录日志 if _, err := dao.OpnWebhookEvents.Ctx(ctx).Where("id", id).Data(do.OpnWebhookEvents{...}).Update(); err != nil { g.Log().Errorf(ctx, "webhook: update event %d failed: %v", id, err) } // 禁止 — 静默丢弃错误 _, _ = dao.ApiKeys.Ctx(ctx).Where("id", id).Delete() ``` ### 自动时间维护 当表包含 `created_at`、`updated_at` 字段时,GoFrame ORM **自动处理**,禁止手动设置: ```go // 正确 — 框架自动写入 updated_at dao.TntUsers.Ctx(ctx).Where("id", id).Data(do.TntUsers{ Status: "disabled", }).Update() // 错误 — 手动设置 updated_at(多余且违反规范) dao.TntUsers.Ctx(ctx).Where("id", id).Data(do.TntUsers{ Status: "disabled", UpdatedAt: gtime.Now(), // 多余!框架自动处理 }).Update() ``` ### 事务 **统一写法:`g.DB().Transaction` 入口 + 闭包内 `dao.Xxx.Ctx(ctx)` 传播式**(GoFrame 官方推荐)。 GoFrame 的 `Transaction` 会把事务对象**注入闭包的 `ctx`**,闭包内任何 `dao.Xxx.Ctx(ctx)` / `g.DB().Ctx(ctx)` 只要用的是这个 `ctx`,就自动挂到当前事务,无需手动持有 `tx` 句柄。 ```go // 正确 — ctx 传播式:闭包内统一用 dao.Xxx.Ctx(ctx),不碰 tx 句柄 err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { _, err := dao.TntTenants.Ctx(ctx).Data(do.TntTenants{...}).Insert() if err != nil { return err // 返回 error 自动回滚;返回 nil 自动提交;panic 也会回滚 } _, err = dao.TntUsers.Ctx(ctx).Data(do.TntUsers{...}).Insert() return err }) ``` **强约束(写事务必须遵守):** 1. **入口统一用 `g.DB().Transaction(ctx, ...)`**,不要用 `dao.Xxx.Transaction(...)`。后者会把事务"挂在某张表"上产生误导(闭包内往往操作的是别的表),且不利于统一。 2. **闭包内一律用 `dao.Xxx.Ctx(ctx)`**(或原生 `g.DB().Ctx(ctx).Exec(...)`),**不使用 `tx` 句柄**。闭包签名 `func(ctx, tx gdb.TX)` 是框架要求,保留不动,但函数体不引用 `tx`(Go 不会因未使用的参数报错)。 3. **`ctx` 必须逐层传递**——这是本写法的**承重纪律**。闭包内调用其它函数时,必须把闭包的 `ctx` 传下去,被调函数内部也必须用 `dao.Xxx.Ctx(ctx)`,事务才会贯穿。 4. **原生 SQL 例外**:钱包自减/`GREATEST` 等 DO 表达不了的算术更新,保留原生 SQL,入口从 `tx.Ctx(ctx).Exec(...)` 换成 `g.DB().Ctx(ctx).Exec(...)`,SQL 与 PostgreSQL 的 `$1/$2` 占位符原样保留。 **⚠️ 头号陷阱——静默脱离事务**:闭包内若漏写 `.Ctx(ctx)`(如 `g.DB().Model("t").Insert()`)或误传了不带事务的 ctx,该语句会**脱离事务被真正提交、无法回滚**,且**没有任何编译/运行时报错**。验证手段:开 SQL 调试日志,事务内每条语句都应带 `[txid:N]`;不带 `txid` 的就是漏挂了。 **嵌套事务/传播行为**:默认传播类型为 `PropagationNested`(用 SavePoint),闭包内再调一个自己开 `Transaction` 的函数会自动成为父事务的嵌套保存点,跟随父事务回滚。若某操作需要"即使主流程回滚也要独立提交"(如审计留痕),必须显式用 `TransactionWithOptions(ctx, gdb.TxOptions{Propagation: gdb.PropagationRequiresNew}, ...)`。 ### g.Map vs DO 结构体 - **禁止使用 `g.Map` 做数据库操作**:所有 `Data()` 调用必须使用 `do.XxxTable{}` 结构体 - DO 结构体字段类型为 `interface{}`,未赋值的字段保持 `nil`,ORM 内置 OmitNil 自动跳过 - 已赋值的字段(包括零值 `0`、`""`)会被正常写入 ```go // 正确 — 使用 DO 对象 dao.Users.Ctx(ctx).Data(do.Users{ Name: req.Name, Password: hash, }).Where("id", id).Update() // 正确 — 条件更新,未赋值字段自动跳过 data := do.Users{} if req.Name != nil { data.Name = *req.Name } if req.Count != nil { data.Count = *req.Count } // 即使 *req.Count == 0 也会写入 dao.Users.Ctx(ctx).Where("id", id).Data(data).Update() // 错误 — 禁止使用 g.Map dao.Users.Ctx(ctx).Data(g.Map{"name": req.Name}).Where("id", id).Update() ``` ## 配置读取 ```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`。 ### 2026-05-28:Data(g.Map{}) 做部分更新导致更新无效 **问题**:租户等级配置的 `UpdateTenantLevelConfig` 使用 `g.Map{}` 收集待更新字段,再调用 `Data(data).Update()`。接口返回成功但数据库数据不变。 **原因**:GoFrame 规范要求数据库操作必须使用 DO 对象,禁止使用 `g.Map`。DO 结构体字段类型为 `interface{}`(`any`),未赋值的字段保持 `nil`,ORM 内置 OmitNil 行为会自动跳过;而已赋值的字段(包括零值 `0`、`""`)会被正常写入。使用 `g.Map` 则可能遇到:键名与列名不匹配被静默忽略、框架行为不一致等问题。 **修复**:将 `g.Map{}` 替换为 `do.TntTenantLevelConfigs{}`,通过指针 nil 检查决定是否赋值 DO 字段,用 `hasUpdate` 标记是否有更新。 **正确做法**: ```go // 正确 — 使用 DO 对象做部分更新 data := do.XxxTable{} hasUpdate := false if req.Name != nil { data.Name = *req.Name hasUpdate = true } if req.Count != nil { data.Count = *req.Count // 即使 *req.Count == 0 也会写入 hasUpdate = true } if !hasUpdate { return res, nil } dao.XxxTable.Ctx(ctx).Where("id", req.Id).Data(data).Update() // 错误 — 使用 g.Map data := g.Map{} if req.Name != nil { data["name"] = *req.Name } dao.XxxTable.Ctx(ctx).Where("id", req.Id).Data(data).Update() ``` ### 2026-05-30:全项目数据库查询代码批量修复(130+ 处) **修复内容**:6 类问题,规则已整合到上方 ORM 操作章节: 1. SQL 注入 — `fmt.Sprintf` 拼接 SQL → `?` 参数化(见"SQL 参数化规则") 2. `Data(g.Map{})` → `Data(do.Xxx{})`(见"g.Map vs DO 结构体") 3. 写操作错误静默丢弃 `_, _ =`(见"写操作错误处理") 4. Scan 值类型 → 指针类型(见"Scan 指针类型规则") 5. `g.DB().Model("table")` → `dao.Xxx.Ctx(ctx)`(见"DAO 模式") 6. 冗余 `updated_at` 手动设置(见"自动时间维护") 额外修复:16 处 Scan 改指针后遗漏 `nil` 检查导致 panic(member.go、organization.go、order.go、notification.go、permission.go、channel.go、help_center.go、feature_flag.go)。 ### 2026-05-30:MiddlewareHandlerResponse 追加标准响应到文件导出 **问题描述**:模型导出功能下载的 JSON 文件末尾被追加了 `{"code":0,"message":"ok","data":null}`,导致重新导入时报错 `invalid character '{' after top-level value`。 **原因**:Go 的 nil interface 陷阱。`ExportModelsJson` 返回 `(nil, nil)`,但 Controller 将 `(*ModelExportJsonRes)(nil)` 存入 `interface{}` 后,GoFrame 的 `r.GetHandlerResponse()` 判断 `res != nil` 为 true(typed nil pointer ≠ nil),中间件误调用 `response.Success()` 追加了标准响应体。 **修复方式**: 1. **中间件**(`handler_response.go`):在检查 `GetHandlerResponse()` 前增加两道防线: - 检测 `Content-Disposition` 头(handler 直接写入下载文件时设置) - 检测 `BufferLength() > 0 && GetHandlerResponse() == nil`(handler 已写响应体且返回值确实为 nil) 2. **导入端**(`model_import_export.go`):`json.Unmarshal` 改为 `json.NewDecoder(...).Decode()`,只解析第一个完整 JSON 值,兼容已有的脏导出文件。 ### 2026-06-03:DO 插入遗漏 NOT NULL 字段导致 INSERT 必然失败 **问题描述**:幂等中间件(`idempotency.go`)将原生 SQL 重构为 DO 对象插入时,遗漏了 `expires_at` 字段: ```go dao.SysIdempotencyRecords.Ctx(ctx).Data(do.SysIdempotencyRecords{ IdempotencyKey: idempotencyKey, Status: "processing", }).Insert() // 未设置 ExpiresAt ``` 而 `sys_idempotency_records.expires_at` 为 `TIMESTAMPTZ NOT NULL` 且无 `DEFAULT`。每次插入都因 NOT NULL 约束失败,代码又把"插入失败"一律当作"并发重复请求"返回 409,导致所有带 `Idempotency-Key` 的请求恒返回 409。 **原因**:GoFrame 的 OmitNil/OmitEmpty 行为——DO 中未赋值的字段为 `nil`,ORM 自动从 INSERT 语句中剔除该列。对于**有 DEFAULT 的列**这是期望行为;但对**无 DEFAULT 的 NOT NULL 列**,剔除后数据库无值可填,INSERT 直接失败。原生 SQL 版本显式写了 `expires_at = NOW() + INTERVAL '24 hours'`,重构时丢失。 **修复方式**:DO 插入时显式赋值 `ExpiresAt: gtime.Now().Add(idempotencyTTL)`。 **正确做法**:将原生 SQL 改写为 DO 插入时,逐列核对目标表中**所有 NOT NULL 且无 DEFAULT 的列**是否都在 DO 中赋了值——这类列不能依赖框架的 OmitNil 自动跳过,否则 INSERT 必然失败。框架自动维护的 `created_at`/`updated_at` 不在此列。 ### 2026-06-07:`length` 校验规则对 `[]string` 校验的是 JSON 字符串长度,不是元素个数 **问题描述**:Webhook 创建接口的 Events 字段使用 `v:"required|length:1,50#请选择事件|事件数量不正确"`,用户选了 3-4 个事件后提交报错"事件数量不正确"。 **原因**:GoFrame v2 的 `length:min,max` 校验规则对 `[]string` 类型不是检查 slice 元素个数,而是先将整个 slice 通过 `json.Marshal` 转成 JSON 字符串,再检查该字符串的字符长度。例如 `["member.created","key.deleted"]` 的 JSON 字符串长度约 33 个字符。用户选了 3-4 个事件后 JSON 字符串就可能超过 50 字符,导致校验失败。 **修复方式**:去掉 `length:1,50`,只保留 `required`(事件从固定列表中选择,无需额外长度校验)。 **正确做法**: ```go // 错误 — length 对 []string 校验的是 JSON 字符串长度,不是元素个数 Events []string `json:"events" v:"required|length:1,50#请选择事件|事件数量不正确"` // 正确 — 只用 required 保证至少选了一个事件 Events []string `json:"events" v:"required#请选择事件"` // 如果需要校验每个元素字符串长度,用 foreach|length Events []string `json:"events" v:"required|foreach|length:1,100#请选择事件|事件名长度不正确"` ``` **注意**:GoFrame v2 没有内置规则直接校验 slice 的元素个数。如需限制元素数量,需自定义校验规则或在 logic 层手动检查。 ### 2026-06-16:gerror.NewCode 误用为格式化构造,错误消息占位符未替换 **问题描述**:注册时命中禁用词,返回的错误消息未格式化,用户看到 `%s包含禁用词「%s」,请修改后重试, 用户名, test`——`%s` 原样输出,参数被用 `, ` 拼接在末尾。 **原因**:`gerror.NewCode(code gcode.Code, text ...string)` 的第二及后续参数是**字面文本**(`...string`),不是 `format + args`。代码误把含 `%s` 占位符的模板连同 `fieldName`、`word` 一起传入,GoFrame 把它们当作多段 text 用 `, ` 拼接,模板原样保留、占位符不替换。带格式化的对应方法是 `gerror.NewCodef(code, format, args...)`。 ```go // 错误 — NewCode 的参数是字面 text(...string),%s 不会被替换,多余参数被拼到末尾 return gerror.NewCode(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word) // 输出:%s包含禁用词「%s」,请修改后重试, 用户名, test // 正确 — 用 NewCodef 做格式化 return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word) // 输出:用户名包含禁用词「test」,请修改后重试 ``` **修复方式**:`internal/logic/common/validation.go` 中 `ValidateForbiddenWords` 的 `gerror.NewCode` 改为 `gerror.NewCodef`。 **正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。 ### 2026-06-23:Scan 查询单标量值(`*string`/`*int`)报参数类型错误 **问题描述**:`organization.go` 的 `GetOrgInfo` 查询租户等级名称时,`var levelName *string; dao.Xxx.Scan(&levelName)` 触发 WARN:`element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`,等级名查不到(降级为空)。 **原因**:GoFrame 的 `Scan` **仅支持 struct / *struct / []struct / []*struct**,不支持标量类型(string/int/bool 等)的指针。`levelName` 声明为 `*string`,`&levelName` 是 `**string`,不在支持范围内,直接报参数类型错误。注意这和 2026-05-14 记录的「Scan 值类型 vs 指针类型」是两回事——那个讲的是 struct 查询无行时的行为差异,本次是 **Scan 根本不接受标量**。 **修复方式**:查询单个标量值改用 `Value()`,返回 `*gvar.Var`,再用 `.String()`/`.Int64()`/`.Bool()` 取值: ```go // 错误 — Scan 不支持标量类型指针 var levelName *string dao.Xxx.Ctx(ctx).Where(...).Fields("name").Scan(&levelName) // 报参数类型错误 // 正确 — 单标量值用 Value() v, err := dao.Xxx.Ctx(ctx).Where(...).Fields("name").Value() if err != nil { g.Log().Warningf(ctx, "查询失败: %v", err) } else { levelName = v.String() // 无记录时返回 "",不会报错 } ``` **正确做法**:按查询意图选择 API: - 查询单行记录 → `Scan(&structPtr)`(指针类型,无行返回 nil,见 2026-05-14 记录) - 查询多行记录 → `Scan(&[]struct{})` - 查询单个标量值(`COUNT`、`SUM`、单个字段)→ `Value()` 返回 `*gvar.Var`,用 `.String()/.Int64()/.Bool()` 取值,**不要用 `Scan(&scalar)` 或 `Scan(&scalarPtr)`** ### 2026-07-16:CAS 更新用「已改写的目标状态」当 WHERE 谓词导致超时兜底网永久失效 **问题**:异步任务超时兜底 `handleTimedOutTasks`(`internal/logic/task/async_polling.go`)把任务标记失败时,先执行 `t.Status = "FAILURE"`,随后又把 `t.Status` 传给 `UpdateTaskCAS(ctx, t, t.Status)` 作为 CAS 的 oldStatus。`GetTimedOutTasks` 只返回非终态行(`status NOT IN ('SUCCESS','FAILURE')`),于是 CAS 生成的 SQL 是 `WHERE id=? AND status='FAILURE' SET status='FAILURE'`,对任何非终态行匹配 0 行 → `RowsAffected()==0` → 返回 CAS conflict → `continue`。结果:**超时任务从未被真正 FAILURE、预扣永不退款(资损)、active 计数永不递减**,且影响所有异步平台(sora/kling/suno/mj/ali/gemini/sync_image)。 **原因**:GoFrame 的「CAS 更新」惯用法 `Where("id", id).Where("status", oldStatus).Update(...)` + `RowsAffected()` 依赖 oldStatus 是**变更前**的真实值。此处在计算 oldStatus 之前就地改写了同一个 `t.Status` 字段,使谓词退化为「新值 == 旧值」,永不成立。 **修复方式**:把「计算 oldStatus + 改写为 FAILURE」抽成纯函数 `buildTimeoutFailure(t, now) (oldStatus string)`,在覆盖前捕获真实状态返回,供 CAS 使用;并补 DB-free 回归单测 `TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate` 锁定「oldStatus 必须是覆盖前状态、绝不为 FAILURE」。 **正确做法**:任何 CAS 式条件更新,**先快照旧值再改写**,用快照做 WHERE 谓词: ```go // 错误 — 先改写再拿改写后的值当谓词,CAS 永不匹配 t.Status = "FAILURE" dao.Xxx.Ctx(ctx).Where("id", t.ID).Where("status", t.Status).Update(...) // WHERE status='FAILURE' // 正确 — 先快照旧状态 oldStatus := t.Status t.Status = "FAILURE" dao.Xxx.Ctx(ctx).Where("id", t.ID).Where("status", oldStatus).Update(...) // WHERE status='IN_PROGRESS' ``` ### 2026-07-24:DO 更新无法置 NULL,且强类型时间字段连 gdb.Raw 也不可用 **问题**:用户解锁接口返回成功,但数据库 `locked_until` 未被清除(仍是锁定时间),账号依旧登不进。同样的写法潜伏在登录成功重置、锁定过期自愈、重置密码顺带清锁,共 9 处。 **原因(两层)**: 1. **DO 更新对 nil 字段的 omit 行为**:GoFrame DO 对象更新时,值为 nil 的字段会被自动跳过、不进入 SET 子句("部分更新"设计意图,见 2026-05-28)。`Data(do.SysAdminUsers{..., LockedUntil: nil})` 中 `LockedUntil: nil` 被当作"未设置"而非"写 NULL",列原值保留。`Update` 不报错、affected rows 可能为 0,接口照常返回成功——极其隐蔽。 2. **本项目 DO 是混合类型**(`internal/model/do/sys_admin_users.go` 实测): - `FailedAttempts any` —— 标准接口字段,赋 `0`(非 nil)正常写入 - `LockedUntil *gtime.Time` —— **强类型时间指针**(非 any),赋 nil 被 omit **官方推荐与本项目现实的冲突**: GoFrame 官方(goframe-v2 skill)对"置 NULL"的推荐做法是 DO + `gdb.Raw("NULL")`: ```go dao.Instances.Ctx(ctx).Where(cols.Id, id).Data(do.Instance{IdleSince: gdb.Raw("NULL")}).Update() ``` 但这要求该 DO 字段是 `any`/`interface{}`(才能接受 `gdb.Raw` 这个字符串类型)。**本项目的 `LockedUntil` 是强类型 `*gtime.Time`**,`gdb.Raw("NULL")` 无法赋值,编译报 `cannot use gdb.Raw("NULL") (of type gdb.Raw) as *gtime.Time`。官方推荐的 `gdb.Raw` 路径在本项目此类强类型字段上**不适用**(已实测编译失败)。 **修复**:本项目对强类型 `*gtime.Time` 字段置 NULL,只能用 `map[string]interface{}`(map 不触发 omit,nil 显式写 NULL): ```go // 错误 — DO 的 nil 被 omit,locked_until 不会被置 NULL dao.Xxx.Ctx(ctx).Where("id", id).Data(do.Xxx{ FailedAttempts: 0, LockedUntil: nil, // ← 被 omit,列原值保留 }).Update() // 错误 — 本项目 LockedUntil 是强类型 *gtime.Time,gdb.Raw 类型不匹配,编译失败 dao.Xxx.Ctx(ctx).Where("id", id).Data(do.Xxx{ LockedUntil: gdb.Raw("NULL"), // ← cannot use gdb.Raw as *gtime.Time }).Update() // 正确(本项目强类型时间字段置 NULL 的唯一可行途径)— map dao.Xxx.Ctx(ctx).Where("id", id).Data(map[string]interface{}{ "failed_attempts": 0, "locked_until": nil, // ← 写入 NULL }).Update() ``` > 若 DO 字段是 `any`(如本项目的 `FailedAttempts`),官方 `gdb.Raw("NULL")` 可用、DO 方案更合规。本记录的 map 方案**仅针对强类型字段**(`*gtime.Time` 等)置 NULL 这一本项目特定场景;此处为一致起见,`failed_attempts` 也一并用 map。 **影响范围(9 处,本次全修)**:`admin/admin_user.go`(UnlockUser)、`admin/auth.go`(登录成功重置 + 锁定过期自愈)、`admin/member.go`(ResetMemberPassword + UnlockMember)、`tenant/auth.go`(登录成功重置 + 锁定过期自愈)、`tenant/member.go`(UnlockMember)、`tenant/email.go`(邮箱重置密码)。 **正确做法(按字段类型选择)**: - 普通部分更新(非空字段)→ 用 DO struct(见 2026-05-28) - 置 NULL/清空**且字段是 `any`/`interface{}`** → DO + `gdb.Raw("NULL")`(GoFrame 官方推荐) - 置 NULL/清空**且字段是强类型**(本项目 `*gtime.Time`)→ `map[string]interface{}`(`gdb.Raw` 类型不匹配,map 是唯一途径) 排查信号:接口返回成功但数据库某可空字段没变化,且代码用了 `Data(do.Xxx{Field: nil})`。 ### 2026-08-03:补偿路径复用已取消的请求 ctx + 资金多语句未用事务,导致冻结余额永久泄漏 **问题**:高并发下客户端超时断开时出现 `pre-deduct db rollback after track failure: ... context canceled`。预扣的冻结 UPDATE 已提交,但追踪 INSERT(`bil_prededuct_tracks`)因 ctx 取消失败,随后的回滚补偿 `unfreezeDBAmount(ctx)` 复用**同一个已取消的 ctx** 也必然失败。最终 `bil_wallets.frozen_balance` 永久多冻结且无追踪记录——孤儿清理与对账均以 tracks 表为权威,这笔泄漏对整个对账体系不可见,租户可用余额被持续蚕食。 **原因(三层)**: 1. **补偿/回滚操作复用请求 ctx**:补偿正是在出错(常为 ctx 取消)时才执行的,用会随客户端断开取消的 ctx 等于没有补偿。GoFrame 的 `g.DB().Exec(ctx)` 在 ctx 取消后直接返回 `context canceled`。 2. **资金多语句操作未包事务**:冻结 UPDATE 与追踪 INSERT 在 autocommit 下各自成独立事务,中间任意点被打断即状态撕裂,只能依赖脆弱的手动补偿。 3. **ctx canceled 被误判为 Redis 故障**:`PreDeduct` 对 Redis 报错不加区分一律降级到 `preDeductDB`,客户端断开(ctx 取消)也会触发降级去打 DB 热点行。 **修复**(`internal/logic/billing/wallet.go`): ```go // 错误 — 两条语句独立提交 + 补偿复用请求 ctx g.DB().Exec(ctx, "UPDATE bil_wallets SET frozen_balance = frozen_balance + ? ...") // 已提交 trackPreDeduct(ctx, ...) // ctx 取消 → 失败 unfreezeDBAmount(ctx, ...) // 同一 ctx → 补偿也失败 → 冻结永久泄漏 // 正确 — 资金临界区:单事务原子化 + WithoutCancel 脱离取消 + 独立兜底超时 dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), preDeductDBTimeout) defer cancel() err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { // 注意:事务内的裸 SQL 必须用 g.DB().Ctx(txCtx).Exec(txCtx, ...) 传播事务 if _, err := g.DB().Ctx(txCtx).Exec(txCtx, "UPDATE bil_wallets ..."); err != nil { return err } return trackPreDeduct(txCtx, ...) // 任一失败整体回滚,无需手动补偿 }) ``` 同时在 `preDeductDB` 入口检查 `ctx.Err()`:请求 ctx 已取消时直接返回错误,不再降级打 DB。 **正确做法(通用规则)**: - **一旦决定发生资金状态变更,写入过程必须不可被客户端取消**:临界区统一用 `context.WithoutCancel(ctx)` + 独立超时(防连接异常无限占用)。已有先例:`rollbackRedisPreDeduct`、`IncrMemberQuotaUsed`。 - **关联的多条资金语句必须包进 `g.DB().Transaction`**,靠事务回滚而非手动补偿保证原子性;事务内裸 SQL 用 `g.DB().Ctx(ctx).Exec(ctx, ...)`(与 `executeSettlementTx` 一致),`g.DB().Exec(ctx, ...)` 不带 `.Ctx()` 不会加入 ctx 携带的事务。 - **降级分支先判 `ctx.Err()`**:`context canceled` 不是基础设施故障,不应触发降级重试。 排查信号:日志同时出现「主操作 context canceled」和「rollback/补偿也 context canceled」,且资金字段与明细表对不上。 ### 2026-08-03(二):进程级全局状态回源使用请求 ctx + 负结果不缓存,客户端断开时刷出 DB 报错风暴 **问题**:高并发客户端断开时出现大量 `[Config] DB error reading option tenant_qps_limit: ... context canceled`。 **原因(三层叠加,`ConfigService.GetOption`)**: 1. **全局配置回源用请求 ctx**:配置缓存是进程级共享状态,回填 DB 查询却随单个请求的 ctx 取消而中断——客户端断开既刷误报,又导致缓存填不上、后续请求继续穿透。 2. **未落库的 key 不缓存(无负缓存)**:`option == nil` 走注册表默认值分支时不写缓存,导致该 key **每次调用都穿透查 `sys_options`**。`LoadRateLimitConfig` 每请求读 5 个 key(`CheckRateLimit`/`AcquireConcurrent` 各调一次),只要这些 key 没在后台配置过,就是每请求最多 10 次 DB 查询的读风暴。 3. **无 singleflight**:缓存过期瞬间所有并发请求同时回源。 **修复**(`internal/logic/common/config.go`):回源包进 `singleflight.Do(key, ...)`,闭包内用 `context.WithoutCancel(ctx)` 先双重检查缓存再查 DB;DB 有行缓存行值,无行缓存注册表默认值(负缓存)。缓存默认值不影响改配置生效——`SetOption` 写库后 `cache.Delete` + Pub/Sub 广播失效。 **正确做法(通用规则)**: - **进程级共享状态(配置、字典、全局缓存)的回源查询,一律 `context.WithoutCancel(ctx)`**:回填结果服务于所有请求,不属于发起请求的生命周期。请求私有数据(该请求自己的钱包/Key 查询)才随请求 ctx 取消。 - **回退到默认值的分支也要写缓存(负缓存)**,否则"未配置"成为永久缓存穿透源;前提是写路径有对应的缓存失效。 - **同 key 并发回源加 `singleflight`**(项目已有先例:`walletSyncGroup`)。 排查信号:某张小配置表的 SELECT 出现在高频请求路径日志中(尤其带 context canceled),说明缓存没挡住——检查负结果是否被缓存、回源是否用了请求 ctx。 ### 2026-08-03(三):客户端断开后请求继续空转流水线,ctx 取消被逐级误报为业务错误 **问题**:高并发客户端断开时连环出现三类告警——`api_key_quota: load failed ... context canceled, skipping check`、`member_quota: load failed ... skipping check`、`[RelayHandler] No available channel for model=...`。 **原因**:请求 ctx 取消后流水线不终止,每个阶段各自撞上 canceled ctx 并按本阶段的错误语义误报:额度检查把取消当 DB 故障告警并 fail-open"跳过";调度器的 Redis 操作(快照/租约/绑定)被取消导致空决策,被误报成"无可用渠道",还会记失败用量、污染渠道统计。 **修复**: 1. `RelayHandler` 入口 `ctx.Err()` fail-fast——断开的请求直接终止,不进入流水线; 2. 调度空决策(`sess.Next` 返回 nil)与 `CheckTenantModelAccess` 出错处识别 `context.Canceled/DeadlineExceeded`:静默退还预扣(`WithoutCancel`)后返回,不记失败用量、不打无可用渠道告警(对齐既有的 `MaterializeSelection` 断开处理); 3. `CheckMemberQuota`/`CheckApiKeyQuota` 加载失败时区分取消与真实 DB 故障:取消静默跳过(请求随后在预扣处 fail-fast),仅真实故障才 WARN。 **正确做法(通用规则,与本日(一)(二)合并成完整的 ctx 取消处理决策表)**: | 操作性质 | ctx 取消时的正确行为 | |---------|---------------------| | 请求私有读(本请求的额度/Key/权限查询) | 随请求终止;**不告警**(取消不是故障),必要时 Debug | | 进程级共享状态回源(配置/字典缓存回填) | `WithoutCancel` 继续完成(见(二)) | | 资金状态变更(预扣/结算/解冻及其补偿) | `WithoutCancel` + 事务 + 兜底超时,不可撕裂(见(一)) | | 长流水线编排(relay handler) | 入口与各昂贵阶段前 `ctx.Err()` fail-fast;下游报错先判 `errors.Is(err, context.Canceled)`,勿映射为业务错误("无可用渠道"/"余额不足"等) | 排查信号:同一 request_id 在日志里留下一串不同模块、不同语义的报错,尾部都是 `context canceled`——说明缺 fail-fast 入口闸门,且各模块把取消误当成了自己领域的故障。 ### 2026-08-14(五):更新接口非指针 int 字段被零值无条件覆写(渠道 weight/priority 被洗成 0) **问题**:渠道创建后数据库 `chn_channels.weight` 是 0,但创建表单明明提交了 100。前端"仅切换状态"或"仅更新 API Key"的 PUT 请求会把已有渠道的 weight/priority 洗成 0。 **原因**:`ChannelUpdateReq.Weight/Priority` 是非指针 `int`,JSON 里不带该字段时解析为 Go 零值 0,无法区分"未提交"与"显式提交 0";而 `UpdateChannel` 里 `data.Weight = req.Weight` 无条件写入,`do` 结构体的非 nil 字段(含 0)都会进 UPDATE SET 列表,于是 0 覆盖了库里的 100。前端曾在多处用"提交时带上当前 weight"打补丁,但详情页的状态切换/Key 更新漏带,且编辑表单 `weight || 100` 的 falsy 兜底把数据损坏掩盖成"显示正常"。 **修复**: 1. `ChannelUpdateReq.Weight/Priority` 改为 `*int`(与同结构体 `MaxConcurrency`、`Type` 等"留空不更新"字段风格一致),Logic 里仅在非 nil 时赋值; 2. 删除前端"必须携带当前 priority/weight"的 workaround(ChannelsPage 的状态切换/行内权重/行内层级三处),只提交变更字段; 3. 详情页编辑表单去掉 `detail.weight || 100` 兜底,显示真实库值,避免再次掩盖数据异常。 **正确做法(通用规则)**: - **更新接口的"可选更新"字段一律用指针类型**(`*int`/`*string`/`*bool`),`nil` = 不更新,非 nil(含零值)= 显式更新;非指针字段意味着"调用方必须每次全量携带",要在 API 注释里写明并让所有前端调用点遵守。 - **do 结构体字段是 `interface{}`**:nil 才会被 ORM 过滤,非 nil 的 0/""/false 都会写入——"零值是否合法的业务值"必须在 Req 层用指针区分,不能指望 ORM 帮你跳过。 - **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。 排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。 ### 2026-08-16:httptest.NewServer 直接包裹未 Start 的 g.Server,每个请求都在 Session.Close() panic **问题**:`go test -v` 下凡是用 `httptest.NewServer(g.Server(guid.S()))` 方式测 HTTP 处理逻辑的用例,每个请求都向 stderr 刷一段 `http: panic serving ... nil pointer dereference`(`gsession.(*Session).Close`)。测试仍然 PASS——响应在 panic 前已完整写出、panic 发生在 deferred `handleAfterRequestDone` 中且被 net/http recover——导致问题长期被掩盖(不带 `-v` 时 `go test` 丢弃通过用例的输出)。涉及 `internal/middleware`、`internal/response`、`internal/handler/setup` 三处。 **原因**:gf v2.10.2 只在 `Server.Start()` 中初始化 `sessionManager`(ghttp_server.go),而 `ServeHTTP` 对每个请求无条件执行 `request.Session.Close()`,后者第一行就解引用 `s.manager.storage`。未经 `Start()` 的 server 其 `sessionManager` 为 nil → `Session.manager` 为 nil → 必 panic。无导出 API 可以单独初始化 manager,绕不开。 **修复**:新增共享测试辅助 `internal/testutil.StartGFServer(t, s)`——把 server 绑定到 `127.0.0.1:0`(内核随机分配端口避免冲突,绑定回环地址避免 Windows 防火墙弹窗)走真实 `s.Start()`,返回 `http://127.0.0.1:{port}` 供用例直接请求,`t.Cleanup` 注册 `s.Shutdown()`。`Start()` 返回时 listener 已创建完毕(`startServer` 内 `wg.Wait()` 等待 `CreateListener` 完成后才返回),`GetListenedPort()` 立即可用,无需轮询。三处测试全部改用该辅助。 **正确做法**:测试中需要真实走 ghttp 请求链路(中间件、handler、hook)时,一律用 `testutil.StartGFServer(t, s)` 启动真实 server;**禁止**把 `g.Server` 直接塞进 `httptest.NewServer`——那只会执行 `ServeHTTP` 路径而不执行 `Start()` 的初始化(session manager、graceful listener 等),属于未定义用法。`httptest.NewServer` 仅用于包装自建的普通 `http.Handler`。 排查信号:`go test -v` 输出中出现成段的 `http: panic serving` + `gsession.(*Session).Close` 栈。 ### 2026-08-14(五):更新接口非指针 int 字段被零值无条件覆写(渠道 weight/priority 被洗成 0) **问题**:渠道创建后数据库 `chn_channels.weight` 是 0,但创建表单明明提交了 100。前端"仅切换状态"或"仅更新 API Key"的 PUT 请求会把已有渠道的 weight/priority 洗成 0。 **原因**:`ChannelUpdateReq.Weight/Priority` 是非指针 `int`,JSON 里不带该字段时解析为 Go 零值 0,无法区分"未提交"与"显式提交 0";而 `UpdateChannel` 里 `data.Weight = req.Weight` 无条件写入,`do` 结构体的非 nil 字段(含 0)都会进 UPDATE SET 列表,于是 0 覆盖了库里的 100。前端曾在多处用"提交时带上当前 weight"打补丁,但详情页的状态切换/Key 更新漏带,且编辑表单 `weight || 100` 的 falsy 兜底把数据损坏掩盖成"显示正常"。 **修复**: 1. `ChannelUpdateReq.Weight/Priority` 改为 `*int`(与同结构体 `MaxConcurrency`、`Type` 等"留空不更新"字段风格一致),Logic 里仅在非 nil 时赋值; 2. 删除前端"必须携带当前 priority/weight"的 workaround(ChannelsPage 的状态切换/行内权重/行内层级三处),只提交变更字段; 3. 详情页编辑表单去掉 `detail.weight || 100` 兜底,显示真实库值,避免再次掩盖数据异常。 **正确做法(通用规则)**: - **更新接口的"可选更新"字段一律用指针类型**(`*int`/`*string`/`*bool`),`nil` = 不更新,非 nil(含零值)= 显式更新;非指针字段意味着"调用方必须每次全量携带",要在 API 注释里写明并让所有前端调用点遵守。 - **do 结构体字段是 `interface{}`**:nil 才会被 ORM 过滤,非 nil 的 0/""/false 都会写入——"零值是否合法的业务值"必须在 Req 层用指针区分,不能指望 ORM 帮你跳过。 - **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。 排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。 ### 2026-08-20(三):Fields 挂在共用模型上导致 COUNT(col1, col2, ...) 报错 **问题**:`TenantApiKeySelect` 中把 `.Fields("id, name, ...")` 挂在模型链上后调用 `m.Count()`,PostgreSQL 报 `function count(bigint, character varying, ...) does not exist`——gdb 生成了 `SELECT COUNT("id","name",...)` 而不是 `COUNT(*)`。 **原因**:gdb 的 `Count()` 会复用模型上已设置的 `Fields` 作为 COUNT 的参数列表(多字段时逐个传入 count 聚合函数),而 PostgreSQL 的 `count` 只接受单个参数(MySQL 语义下多参 count 也不等于总行数)。这是"链式复用同一 model 做计数 + 查询"模式下的隐蔽陷阱:查列表正常、计数 SQL 被悄悄改写。 **修复**:`.Fields(...)` 只挂在分页查询链上(`m.Fields(...).Page(...).Scan(...)`),`Count()` 用不带 Fields 的基础模型(生成 `COUNT(*)`)。 **正确做法**:凡"先 Count 再分页 Scan"的写法,构建条件模型(Where/Order)后**先 Count**,再把 `Fields/Page/Scan` 链在同一个基础模型上;不要在 Count 之前设置 Fields。若必须提前设置 Fields,用 `m.Clone()` 或为 Count 单独构建条件链。 排查信号:日志出现 `function count(... N 个参数 ...) does not exist` 或 `COUNT("a","b")` 形态的 SQL,且调用栈落在 `*.Count()`。 ### 2026-08-20(三):Model.Scan(&string) 静默吞错,局部更新把未提交的 JSONB 字段重置为默认值 **问题**:渠道调试日志「保存捕捉条件后开关自动关闭」。PUT 只带 `debug_log_tenant_id` 等过滤字段,settings JSONB 里的 `debug_log_enabled:true` 被静默丢弃。查库发现过滤字段写入成功、唯独开关标志消失。 **原因**:读旧 settings 用的是 `_ = dao.ChnChannels...Fields("settings").Scan(¤tSettings)`——gdb 的 `Model.Scan` **只接受 struct/*struct/[]struct/[]*struct**,传入 `*string` 直接返回错误 `element of parameter "pointer" for function Scan should type of struct/...`;`_ =` 把错误吞掉后 `currentSettings` 永远是空串,`ParseChannelSettings("")` 返回默认值,套上本次请求的字段 marshal 回写——**所有未在本次请求中显式提交的 settings 字段全部被重置为默认**。开关单独提交时显式带上了 enabled 所以正常;过滤条件单独提交时没带 enabled,于是被重置为 false。这是个从原 UseProxy 更新块继承来的潜伏 bug:此前每次切换 use_proxy 同样会把 timeout_seconds 覆盖、header_override 等自定义配置静默洗掉,只是显式字段每次都带、无人察觉。同模式的 `GetTenantAuditLevel`(audit.go,只读路径)也中招——租户级审计设置因此**从未生效过**。 **修复**:读单列字符串一律用 `Model.Value()`(返回 `*gvar.Var`,`v.String()` 取值),两处(channel.go 更新块、audit.go)均已改为 Value()。 **正确做法(通用规则)**: - **读单个标量列用 `.Value()` 或 `.Array()`/`.All()` 后取字段,禁止 `Scan(&string)`/`Scan(&int)`**——Scan 只认 struct 系列,对基础类型指针直接报错。 - **禁止 `_ =` 吞掉 DB 读取错误**,尤其是「读旧值 → 改字段 → 写回」的合并更新模式:读失败意味着回写会把整个 JSONB 洗成默认值,属于数据破坏而非可忽略的小错。 - 「读-改-写」JSONB 配置的合并更新块,读失败时应中止更新并返回错误,而不是带着默认值继续。 排查信号:JSONB 配置列中「本次没提交的字段莫名回到默认值」;`grep 'Scan(&' `命中基础类型指针。 ### 2026-08-26(二):gf redis 驱动 HMGET 缺失字段返回空字符串而非 nil,IsNil() 判缺失失效 **问题**:渠道健康度一段时间后自动掉到个位数。排查发现健康快照(`runMaintenance` → `ReadRuntime` 平均渠道下所有模型的 succ EWMA)把**无健康键的模型读成 succ=0** 平均了进去;健康键 TTL 24h,无流量模型的键过期后即变成 0,渠道健康度随键过期逐渐坍缩到个位数。 **原因**:`g.Redis().Do(ctx, "HMGET", key, "f1", "f2")` 对**不存在的 key/字段**,经 gf redis contrib 驱动转换后返回的是 `[]string{"", ""}`(空字符串),`.Vars()` 后每个元素 **`IsNil()==false`**、`Float64()==0`。`ReadRuntime` 用 `!vals[0].IsNil()` 判「有数据才覆盖乐观默认值 1」,判断永远为真 → 默认值被 0 覆盖。调度打分侧 `healthFactor` 恰好有 `succ<=0 && lat==0 视为满分` 的兜底所以转发不受影响,但快照聚合没有兜底,展示值被拖垮。`readBreaker` 同模式(空串 state 恰好 Int()==0==CLOSED 侥幸正确)。 **修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `TestMissingHealthKey_ReadsDefault`。 **正确做法(通用规则)**:判断 gf redis `HMGET`/批量读的字段是否存在,**不要依赖 `IsNil()`**(驱动转换后 nil 会变空字符串);用 `String() != ""` 判断,或改用 Lua 脚本内 `HGET ... or '默认值'` 在 Redis 侧兜底。单 key `HGET` 缺失返回的是真 nil(`IsNil()==true`),两者行为不一致,混用时尤其注意。 ### 2026-08-27:Redis HMGET 缺失字段的 gvar 判空失效(`IsNil()` 恒为 false),默认值兜底全线失守 **问题**:渠道探测一切正常(Redis succ_ewma=1.0),管理后台渠道健康度却长期停在 25,其余渠道全是 0。排查发现维护快照读到的几乎所有「从未被真实流量访问过」的渠道×模型健康值都是 `0.000`,而这些模型的 Redis key **根本不存在**——按设计应回落到默认满分 1.0。 **原因**:`RedisState.ReadRuntime` 用如下写法读健康 EWMA: ```go v, _ := g.Redis().Do(ctx, "HMGET", key, "succ_ewma", "lat_ewma") vals := v.Vars() if !vals[0].IsNil() { out.SuccEwma = vals[0].Float64() } // ← 判空失效 ``` HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它们包装成**非 nil 的空 gvar**:实测 `IsNil()=false`、`IsEmpty()=true`、`String()=""`、`Float64()=0`。于是判空分支永远进入,`Float64("")` 静默返回 **0**,把「无数据」变成了「健康分 0 分」。影响远超展示:`ReadRuntime` 同时供调度目录构建 `healthFactor`,所有冷模型的路由权重被压成 0。 两个读取方对同一份「无数据」表现还不一致,极大增加了排查难度——管理后台 `batchGetModelHealth` 用 `fmt.Sscanf` 解析,解析失败就不赋值,UI 显示"无数据";维护循环用 gconv 读成 0。同一个模型,UI 说无数据、健康分算 0。 **修复**:`state_redis.go` 的 `ReadRuntime` 改为显式字符串校验——`TrimSpace(String())` 为空则保持默认值,非空再 `strconv.ParseFloat` 并做值域校验(succ 取 `0 < f <= 1`;EWMA 由正数衰减而来,数学上不会精确到达 0,精确 0 视为异常数据)。回归测试 `TestReadRuntime脏数据防御`。 **正确做法(通用规则)**: - **禁止用 `gvar.IsNil()` 判断 Redis 读取结果是否存在**——gredis 的空回复是非 nil 空 gvar,`IsNil()` 恒为 false。用 `IsEmpty()`,或取 `String()` 后判空串。 - **禁止把 `gvar.Float64()`/`Int()` 直接用于「缺失应回落默认值」的场景**:gconv 对空串/非法值静默返回零值,会把「无数据」变成「0 分/0 次」这类**具有强业务含义的极端值**。默认值不是 0 的字段(健康分、成功率、权重、配额),必须显式校验后再赋值。 - 同一份状态有多个读取方时(调度用 gconv、管理后台用 Sscanf),**解析方式必须统一**,否则同一数据在不同页面表现不同,故障期误导排查方向。 排查信号:某项指标「本该回落默认值却全是 0」;同一数据 UI 显示"无数据"而后台计算按 0 处理;`grep -n 'IsNil()' ` 命中 Redis 读取路径。 ### 2026-08-31:`g.Cfg().MustGet` 读取不存在的配置段静默返回空值,告警邮件长期发送失败 **问题**:监控告警的邮件通知从上线起就没成功发出过,`ntf_send_log` 里堆满 `status=failed` 的记录,错误信息是 SMTP 连接失败——但管理后台「系统设置 → 邮件配置」里 SMTP 配置完全正常,验证码等其他邮件都能正常投递。 **原因**:`internal/logic/monitor/alert_notify.go` 的 `sendAlertEmailToAdmins` 自己拼 `EmailConfig`,读的是配置文件: ```go sender := common.NewEmailSender(&common.EmailConfig{ Host: g.Cfg().MustGet(ctx, "email.smtp.host").String(), // ← 配置文件里根本没有 email 段 Port: g.Cfg().MustGet(ctx, "email.smtp.port").Int(), ... }) ``` `manifest/config/config.yaml` 中并不存在 `email` 段。`MustGet` 的 `Must` 只针对**读取过程出错**(配置文件损坏等)才 panic,**「键不存在」不是错误**:它返回一个空 `*gvar.Var`,`.String()` 得到 `""`、`.Int()` 得到 `0`。于是 Host 为空、Port 为 0,每封告警邮件都要走满 3 次重试才失败,而且失败原因看起来像"SMTP 服务器有问题",与真正的原因(配置压根没读到)南辕北辙。 **修复**:改为与其余三条发信链路一致,走设置注册表 `common.EmailConfigFromOptions(ctx)`(数据源 `sys_options`),配置缺失时返回明确错误并直接跳过发送。至此邮件配置的唯一来源是数据库,配置文件不再参与。 **正确做法(通用规则)**: - **`MustGet` 不保证键存在**——它只保证读取过程无错。对必填配置要显式判空并给出可读错误,不要让空值一路流进业务逻辑变成"连不上/额度为 0/超时 0 秒"这类误导性症状。 - **运行期可由管理员调整的配置一律走设置注册表 `common.Config()`(`sys_options`),不要放配置文件**:配置文件只承载启动期基础设施配置(数据库、Redis、监听端口)。两套来源并存时,改了后台却不生效、或某条链路读到空配置,是最难排查的一类问题。 - 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。 排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。 ### 2026-09-01:测试里用 `gtest.DataPath()` 当 server 名,Windows 下 `Start()` 静默失败 **问题**:`internal/middleware` 的 `request_id_test.go` 三个用例在 Windows 上全部失败,断言形如 `EXPECT 0 == 10`、`EXPECT 0 == 100`,或客户端报 `EOF`——看起来像中间件没生成 RequestId,实则请求根本没发出去。 **原因**:测试用路径当服务器名: ```go s := g.Server(gtest.DataPath("request-id-test")) // 名字是 D:\...\testdata\request-id-test s.Start() // 返回值被丢弃 ``` GoFrame 用**服务器名**拼 session 存储目录:`%TEMP%\gsessions\`。名字里带盘符冒号时路径非法,`os.MkdirAll` 失败,`Start()` 返回 error——但测试没接这个返回值,于是服务器没监听,`GetListenedPort()` 返回 `-1`,客户端请求 `http://127.0.0.1:-1` 直接 EOF。Linux 下 `gtest.DataPath` 返回的路径不含冒号,同样的代码能跑通,所以问题只在 Windows 暴露。 同一文件还继承了 `manifest/config/config.yaml` 的 `server.address: :18888`(真实应用端口),即便名字合法也会与本机运行中的服务抢端口。 **修复**:服务器名改用普通标识符,并显式绑定空闲端口: ```go s := g.Server("mw-request-id-dual") // 纯名字,不是路径 s.SetAddr("127.0.0.1:0") // 不继承配置文件端口,让系统分配 s.SetDumpRouterMap(false) s.Start() ``` **正确做法(通用规则)**: - **`g.Server(name)` 的参数是「服务器名」不是「路径」**:它会参与 session 目录等文件路径拼接,只能用普通标识符(字母/数字/连字符)。`gtest.DataPath()` 是用来定位测试数据文件的,不要拿来当名字。 - **测试里起 server 必须 `SetAddr("127.0.0.1:0")`**:`g.Server()` 会读取项目配置,默认继承生产监听端口,在本机跑着服务时必然冲突。 - **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。 排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。 ### 2026-09-01(二):channel_proxy_url 双数据源分裂——设置页保存的代理对 relay 渠道转发不生效 **问题**:管理后台「系统设置 → 渠道配置 → 代理地址」保存后,渠道编辑中开启「使用代理」的请求并不走该代理。设置页把 `channel_proxy_url` 写入 `sys_options`,但 relay 层(`relay/common/http_client.go` 的 `GetSystemProxyURL`)读的是 `g.Cfg().MustGet(ctx, "channel_proxy_url")`——即 `manifest/config/config.yaml` 配置文件里的**同名 key**。两套数据源互不相通:设置页的值只被 `oauth/client.go`(OAuth 出站)读到,它宣称的主要用途(渠道转发代理、Realtime WebSocket 拨号)从未读到过。 **原因**:同一配置 key 被两个模块各自实现了读取逻辑,一个走设置注册表(`sys_options`),一个走 `g.Cfg()`(配置文件),没有收敛到同一加载入口。`g.Cfg().MustGet` 对键不存在不报错、静默返回空串(见 2026-08-31 记录),掩盖了分裂——渠道一直"代理未配置",无人察觉。 **修复**: 1. `relay/common/http_client.go`:`GetSystemProxyURL` 不再读 `g.Cfg()`(含 10s TTL 轮询缓存一并删除),改为读包内 atomic 值,新增 `SetSystemProxyURL` setter; 2. `internal/cmd/cmd.go`:沿用 `SetGlobalRequestTimeoutSeconds` 的桥接注入模式(relay 不反向 import internal,会循环依赖)——启动时 + `OnSettingsChanged("channel_proxy_url")` 时从 `sys_options` 读取并注入,免重启、多实例经 Redis pub/sub 各自刷新; 3. `config.example.yaml` 删除已失效的 `channel_proxy_url` key,配置文件不再承载该配置。 **正确做法(通用规则)**: - **一个配置 key 只能有一个数据源、一个加载入口**。凡注册进设置注册表(`sys_options`,管理员可改)的 key,所有消费方——包括 relay 这类不 import internal 的独立模块——都必须经同一加载函数取值;独立模块用「setter 注入 + OnSettingsChanged 刷新」桥接(本项目既有模式:全局超时 `SetGlobalRequestTimeoutSeconds`)。 - **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。 排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。 ### 2026-09-03(三):ghttp 归一化尾部斜杠但不归一化中间的重复斜杠 **问题**:管理后台的授权(`AdminPermissionGuard`)按 `r.URL.Path` 做精确路径 / 前缀 / 前缀+后缀三段匹配。若框架对路径的归一化范围与预期不符,同一个 handler 就可能有多种匹配结果——加一个斜杠就绕到另一条(可能更弱的)规则上。 **框架实测行为**(GoFrame v2,`ghttp` 路由,实测端口探针验证): | 请求路径 | 是否匹配到路由 | handler 里的 `r.URL.Path` | |----------|----------------|---------------------------| | `/api/admin/roles` | ✅ | `/api/admin/roles` | | `/api/admin/roles/` | ✅ | `/api/admin/roles`(**尾部斜杠已被去掉**) | | `/api/admin/roles//` | ✅ | `/api/admin/roles`(同上) | | `/api/admin//roles` | ✅ | `/api/admin//roles`(**中间的重复斜杠原样保留**) | | `/api/admin/./roles`、`/api/x/../admin/roles` | ❌ 404 | — | | `/api/admin/ROLES` | ❌ 404 | — | 即:**尾部斜杠会被归一化,路径中间的空段不会**;`.` / `..` 段与大小写变体压根匹配不到路由。 **正确做法(通用规则)**: - **任何按 `r.URL.Path` 做字符串匹配的中间件(鉴权、限流、审计采样、灰度路由),都不能假设路径已规范化**。中间的 `//` 会带着原样路径进到 handler,字符串规则却按规范形式书写,两者对不上。 - 处理方式选**拒收**而非**重写**:重写会把原本因规则不匹配而被拒的变体变成放行(等于放宽授权面),拒收既不放宽也不留缝隙。本项目在 `AdminPermissionGuard` 入口用 `isCanonicalPath` 直接 403,正常客户端不会产生这类路径。 - 尾部斜杠已由框架处理,无需自行 trim,**也不要因此把 `/xxx/` 当作合法输入去写规则**——handler 永远看不到它。 排查信号:某接口"多加一个斜杠就能绕过权限/限流";中间件按路径匹配的规则表里出现了同一 handler 的两条规则。 ### 2026-09-05:`Model.Value()` 不带 Fields 返回结果集第一列(id),不是想要的业务列 **问题**:`internal/logic/common/config.go` 的 `SetOption` 中,本位币只读保护用 `dao.SysOptions.Ctx(ctx).Where("key", key).Value()` 读取已存的 `billing_currency` 值,与提交值比对实现"相同值放行"。但 `Model.Value()` 不指定字段时,GoFrame 生成 `SELECT * ... LIMIT 1` 并取**结果集第一列**(`gdb_core_underlying.go` 中 `FirstResultColumn = columnTypes[0].Name()`),对 `sys_options` 来说是 `id`。于是 `old` 永远是行 id(如 `"42"`),与 `"USD"/"CNY"` 永不相等——系统初始化后,设置页保存整个 payment 分类必报"本位币在系统初始化后不可更改",即使用户根本没改本位币。 **原因**:`Value()` 的语义是"返回查询结果第一行第一列的值",取哪一列完全由 `Fields()`(或 `Value(fieldsAndWhere...)` 参数)决定;不带字段调用时落到表的第一列(通常是 `id`),且不报错,属于静默取错列。 **修复方式**:显式指定列 `dao.SysOptions.Ctx(ctx).Where("key", key).Fields("value").Value()`,与代码库其余 5 处 `Fields("xxx").Value()` 的既有写法保持一致。 **正确做法(通用规则)**:`Value()` / `Array()` 这类取单列的快捷方法**必须**显式 `Fields("列名")`,否则取到的是 `id`。评审信号:`grep -rn '\.Value()'` 命中且前面没有 `.Fields(` 的调用。 ### 2026-09-05:Scan 进无 orm 标签的匿名结构体——TableFields 元数据查询失败时帕斯卡列名裸进 SQL **问题**:管理后台租户列表接口 `/api/admin/tenants` 偶发报 `pq: column "Id" does not exist`,SQL 里出现 `"Id","Name","LogoURL"` 等帕斯卡命名列。PostgreSQL 引号标识符大小写敏感,真实列是 `id`/`logo_url`,查询必然失败。 **原因**:`internal/logic/admin/tenant.go` 的 `ListTenants` 把查询结果 `Scan` 进一个**只有 json 标签、没有 orm 标签的匿名结构体**。gf 的 `Model.Scan`(`doStructs`)有个内置行为 *"Auto selecting fields by struct attributes"*:模型未显式 `Fields()` 时,会用**扫描结构体的字段名**自动生成 SELECT 字段列表。字段解析链路(`gdb_func.go` 的 `getFieldsFromStructOrMap`):有 orm 标签取标签值,**没有则取 Go 字段名原样**;随后 `mappingAndFilterToTableFields` 尝试用真实表结构把 `Id→id`、`LogoURL→logo_url` 纠偏,但它对元数据查询失败的容忍方式是(`gdb_model_utility.go`): ```go fieldsMap, _ := m.TableFields(fieldsTable) // 错误被静默吞掉 if len(fieldsMap) == 0 { return fields // 元数据拿不到就原样透传 Go 字段名 } ``` pgsql 驱动的 `TableFields` 要实时查 `pg_attribute` 系统表(`'表名'::regclass`)。远程数据库瞬时抖动一次、这条目录查询失败,帕斯卡字段名就直接进了 SQL。**平时不炸的原因**:TableFields 成功结果进程内永久缓存(`gcache.DurationNoExpire`),启动后第一次查成功就一直复用;失败不缓存,所以重试通常即恢复。同请求内先执行的 `Count()` 走数据链路成功,进一步印证只有目录查询那一步挂了。 **修复方式**:给匿名结构体补上 orm 标签(`json:"id" orm:"id"` 等 13 个字段)。orm 标签值直接成为 SELECT 字段名——即使 TableFields 失败、字段名原样透传,透传出去的也是正确的小写蛇形列名,SQL 依然成立,彻底消除对运行时元数据的依赖。随后用 go/AST 脚本全仓排查同款写法,批量修复 24 处(幂等中间件、admin/tenant/open 列表与导出、成员模型范围、预算巡检等)。 **不受影响的两种同貌写法(排查时排除)**: - **模型链上有显式 `.Fields(...)`**:`doStructs` 的自动投影有前置条件 `len(fields)==0 && len(fieldsEx)==0`,显式 Fields 时不触发,SELECT 列已固定。 - **`Raw(sql).Scan(&x)` 与 `result[0].Struct(&x)`**:Raw 路径在 `getFormattedSqlAndArgs` 里直接透传 rawSql、不拼接 Fields;`Record.Struct` 是内存行→结构体转换,根本不生成 SQL。 **正确做法(通用规则)**: - **凡 `Scan`/`Struct`/`Structs` 进非 `model/entity` 生成的结构体(匿名结构体、手写 DTO),每个字段必须带 `orm` 标签**,列名写死不依赖运行时映射;`entity.Xxx` 自带 orm 标签天然安全。 - 判断风险的方法:`grep -rn 'var .* \[\]struct {' internal/` 找到匿名结构体,再看它是否被传给 `dao.Xxx.Ctx(ctx)...Scan(&x)` 链路且链上无 `Fields(...)`/`Raw(...)`——是则全部依赖这条元数据映射,远程 DB 抖动哪个请求撞上哪个接口炸,且报错完全看不出是网络问题。 排查信号:`pq: column "Xxx" does not exist` 且 SQL 列名是帕斯卡命名;偶发、重试即好、与网络抖动时间点吻合。 ### 2026-09-18:请求体超过 ClientMaxBodySize 时 gf 直接 panic,客户端收到裸 500 "Internal Error" **问题**:线上 ZCode 客户端 POST `/v1/messages` 报 500 "Internal Error"(访问日志错误码 `50, "Internal Error"`,错误栈 `Read from request Body failed` → `http: request body too large`),请求耗时 53 秒——客户端慢速上传超大请求体(agent 长上下文 + 内联 base64 媒体),读满上限后被拒。 **原因**:三层机制叠加: 1. gf 在 `ServeHTTP` 里给每个请求体包 `http.MaxBytesReader`,上限取 `server.clientMaxBodySize`,**默认 8MB**(`ghttp_server_config.go`),未配置时即 8MB 生效。 2. 任何代码调 `r.GetBody()`(内部 `MakeBodyRepeatableRead`)读取超限时,gf **直接 `panic(gerror.WrapCode(gcode.CodeInternalError(50, "Internal Error"), err, "Read from request Body failed"))`**——不是返回错误。 3. 该 panic 会被 gf `middleware.Next()` 里逐层包裹的 `gutil.TryCatch` **先于自定义 Recovery 中间件**捕获(`ghttp_request_middleware.go` 的 catch 分支 `WriteStatus(500, exception)`),所以项目统一的 JSON 错误格式与 Recovery 兜底全都接不到,客户端拿到框架裸 500 文本。 **修复方式**:`manifest/config/config.yaml` 显式配置 `server.clientMaxBodySize: "32MB"`(支持 "32MB"/"512KB" 字符串,gf 用 `gfile.StrToSize` 解析);并新增 `middleware.RelayBodyLimit`(`internal/middleware/body_limit.go`)挂在 relay 路由组 `ApiKeyAuth` 之后、`ContentFilter` 之前:按 `Content-Length` 预检,超限先 `io.Copy(io.Discard, r.Body)` 排空再返回 413 + 协议原生格式错误(`/v1/messages` 前缀走 Claude 格式 `request_too_large`,其余走 OpenAI 格式 `invalid_request_error`)。预检阈值从同一配置键读取,与 `MaxBytesReader` 实际上限天然一致。 **正确做法(通用规则)**: - **调大上限 ≠ 响应体送达**:拒绝超大请求时必须**先排空请求体再写响应**。客户端上传大 body 往往耗时几十秒,服务端抢先响应并关连接,响应体会在半路被掐掉(实测 body 为空、响应头齐全的竞态);`r.Body` 已被 `MaxBytesReader` 封顶,排空量有界,不会被恶意大包拖死。 - 无 `Content-Length` 的分块传输无法预检,超限时仍走 gf 的裸 500 兜底(主流 SDK 均携带 Content-Length,属可接受残留)。 - 自定义 Recovery 中间件**不是**万能兜底:gf 中间件链每层 handler 自带 TryCatch,handler 内的 panic 到不了最外层自定义中间件。 排查信号:访问日志出现 `50, "Internal Error"` + `Read from request Body failed` + `http: request body too large` 栈;耗时数万毫秒的 500。