fix(model):修复模型结构体未设置导致查询出错

This commit is contained in:
qian committed 2026-09-06 23:02:34 +08:00
1 parent b267119f12
commit 80b4754208
14 files changed
+159 -127

No files matched your search

+29
View File
@@ -927,3 +927,32 @@ s.Start()
**修复方式**:显式指定列 `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 列名是帕斯卡命名;偶发、重试即好、与网络抖动时间点吻合。
+19 -19
View File
@@ -29,16 +29,16 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
var users []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
LastLoginIp string `json:"last_login_ip"`
LockedUntil *gtime.Time `json:"locked_until"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").
Page(page, pageSize).
@@ -517,15 +517,15 @@ func (s *sAdmin) ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*
for {
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
var batch []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
LastLoginIp string `json:"last_login_ip"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
if err := m.OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
return
+7 -7
View File
@@ -719,13 +719,13 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq)
// GetChannelAbilities 获取渠道模型能力列表
func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) {
var abilities []struct {
ID int64 `json:"id"`
ModelName string `json:"model_name"`
UpstreamModel string `json:"upstream_model"`
Enabled bool `json:"enabled"`
CostRatio float64 `json:"cost_ratio"`
SupportsResponses bool `json:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses"`
ID int64 `json:"id" orm:"id"`
ModelName string `json:"model_name" orm:"model_name"`
UpstreamModel string `json:"upstream_model" orm:"upstream_model"`
Enabled bool `json:"enabled" orm:"enabled"`
CostRatio float64 `json:"cost_ratio" orm:"cost_ratio"`
SupportsResponses bool `json:"supports_responses" orm:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses" orm:"chat_via_responses"`
}
err := dao.ChnAbilities.Ctx(ctx).
+12 -12
View File
@@ -443,18 +443,18 @@ func parseCapabilities(raw string) map[string]bool {
// GetModelPricing 获取模型定价
func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) {
var rows []struct {
BillingMode string `json:"billing_mode"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
PriceNote *string `json:"price_note"`
DiscountLabel *string `json:"discount_label"`
PriceChangeNote *string `json:"price_change_note"`
BillingMode string `json:"billing_mode" orm:"billing_mode"`
MinTokens int64 `json:"min_tokens" orm:"min_tokens"`
MaxTokens *int64 `json:"max_tokens" orm:"max_tokens"`
InputPrice float64 `json:"input_price" orm:"input_price"`
OutputPrice float64 `json:"output_price" orm:"output_price"`
PerRequestPrice *float64 `json:"per_request_price" orm:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price" orm:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price" orm:"cache_creation_price"`
TimeSegments string `json:"time_segments" orm:"time_segments"`
PriceNote *string `json:"price_note" orm:"price_note"`
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
}
err := dao.MdlPricing.Ctx(ctx).
+7 -7
View File
@@ -163,7 +163,7 @@ func PermissionGroups() []v1.PermissionGroup {
func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) {
// Get permission points
var perms []struct {
PermissionPoint string `json:"permission_point"`
PermissionPoint string `json:"permission_point" orm:"permission_point"`
}
err := dao.SysAdminRolePerms.Ctx(ctx).
Where("admin_user_id", req.Id).
@@ -179,9 +179,9 @@ func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermission
// Get data scopes
var scopes []struct {
ID int64 `json:"id"`
ScopeType string `json:"scope_type"`
ScopeValue string `json:"scope_value"`
ID int64 `json:"id" orm:"id"`
ScopeType string `json:"scope_type" orm:"scope_type"`
ScopeValue string `json:"scope_value" orm:"scope_value"`
}
err = dao.SysAdminDataScopes.Ctx(ctx).
Where("admin_user_id", req.Id).
@@ -348,9 +348,9 @@ func GetDataScopes(ctx context.Context, userID int64, role string) ([]v1.DataSco
}
var scopes []struct {
ID int64 `json:"id"`
ScopeType string `json:"scope_type"`
ScopeValue string `json:"scope_value"`
ID int64 `json:"id" orm:"id"`
ScopeType string `json:"scope_type" orm:"scope_type"`
ScopeValue string `json:"scope_value" orm:"scope_value"`
}
err := dao.SysAdminDataScopes.Ctx(ctx).
Where("admin_user_id", userID).
+8 -8
View File
@@ -101,14 +101,14 @@ func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.Adm
return nil, err
}
var roles []struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
IsBuiltin bool `json:"is_builtin"`
IsEnabled bool `json:"is_enabled"`
Sort int `json:"sort"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id" orm:"id"`
Code string `json:"code" orm:"code"`
Name string `json:"name" orm:"name"`
Description string `json:"description" orm:"description"`
IsBuiltin bool `json:"is_builtin" orm:"is_builtin"`
IsEnabled bool `json:"is_enabled" orm:"is_enabled"`
Sort int `json:"sort" orm:"sort"`
CreatedAt string `json:"created_at" orm:"created_at"`
}
err := dao.SysAdminRoles.Ctx(ctx).OrderAsc("sort").OrderAsc("id").Scan(&roles)
if err = lcommon.IgnoreScanNoRows(err); err != nil {
+16 -13
View File
@@ -290,20 +290,23 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
m = m.Where("status", req.Status)
}
// 扫描结构体必须带 orm 标签:gf 的 Scan 会用「结构体字段名」自动生成 SELECT 字段列表,
// 无 orm 标签时靠运行时查表结构(TableFields)做 Id→id 映射;该元数据查询失败会被框架静默吞掉,
// 帕斯卡字段名将原样进 SQL,PostgreSQL 引号列名大小写敏感,报 column "Id" does not exist。
var tenants []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
MaxMembers *int `json:"max_members"`
MaxConcurrency *int `json:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope"`
Settings string `json:"settings"`
Level int `json:"level"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
Id int64 `json:"id" orm:"id"`
Name string `json:"name" orm:"name"`
Code string `json:"code" orm:"code"`
LogoURL string `json:"logo_url" orm:"logo_url"`
OwnerUserID int64 `json:"owner_user_id" orm:"owner_user_id"`
Status string `json:"status" orm:"status"`
MaxMembers *int `json:"max_members" orm:"max_members"`
MaxConcurrency *int `json:"max_concurrency" orm:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope"`
Settings string `json:"settings" orm:"settings"`
Level int `json:"level" orm:"level"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
}
err = m.OrderDesc("id").
Page(page, pageSize).
+8 -8
View File
@@ -82,14 +82,14 @@ func (s *sAdmin) UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanu
}
var tasks []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
Result string `json:"result"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt *time.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Name string `json:"name" orm:"name"`
Status string `json:"status" orm:"status"`
ErrorMessage string `json:"error_message" orm:"error_message"`
Result string `json:"result" orm:"result"`
StartedAt *time.Time `json:"started_at" orm:"started_at"`
FinishedAt *time.Time `json:"finished_at" orm:"finished_at"`
CreatedAt *time.Time `json:"created_at" orm:"created_at"`
}
err = m.Page(page, pageSize).Scan(&tasks)
if err != nil {
+12 -12
View File
@@ -101,12 +101,12 @@ func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) (
}
var users []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
CreatedAt string `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").Page(page, pageSize).Scan(&users)
if err != nil {
@@ -354,7 +354,7 @@ func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsRe
}
var scopes []struct {
ModelID string `json:"model_id"`
ModelID string `json:"model_id" orm:"model_id"`
}
err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
@@ -460,11 +460,11 @@ func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.Op
}
var keys []struct {
Id int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Status string `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Name string `json:"name" orm:"name"`
KeyPrefix string `json:"key_prefix" orm:"key_prefix"`
Status string `json:"status" orm:"status"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").Page(page, pageSize).Scan(&keys)
if err != nil {
+7 -7
View File
@@ -65,13 +65,13 @@ func (s *sSettings) PublicAnnouncements(ctx context.Context, req *v1.PublicAnnou
}
var items []struct {
Id int64 `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Content string `json:"content"`
IsPinned int `json:"is_pinned"`
DisplayPosition string `json:"display_position"`
CreatedAt string `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Title string `json:"title" orm:"title"`
Type string `json:"type" orm:"type"`
Content string `json:"content" orm:"content"`
IsPinned int `json:"is_pinned" orm:"is_pinned"`
DisplayPosition string `json:"display_position" orm:"display_position"`
CreatedAt string `json:"created_at" orm:"created_at"`
}
err := m.OrderDesc("is_pinned").OrderDesc("created_at").
Limit(20).
+27 -27
View File
@@ -49,18 +49,18 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq)
}
var users []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LockedUntil string `json:"locked_until"`
CreatedAt string `json:"created_at"`
QuotaType string `json:"quota_type"`
QuotaLimit float64 `json:"quota_limit"`
QuotaPeriod string `json:"quota_period"`
UpdatedAt string `json:"updated_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
LockedUntil string `json:"locked_until" orm:"locked_until"`
CreatedAt string `json:"created_at" orm:"created_at"`
QuotaType string `json:"quota_type" orm:"quota_type"`
QuotaLimit float64 `json:"quota_limit" orm:"quota_limit"`
QuotaPeriod string `json:"quota_period" orm:"quota_period"`
UpdatedAt string `json:"updated_at" orm:"updated_at"`
}
err = model.OrderDesc("id").
Page(page, pageSize).
@@ -81,8 +81,8 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq)
hasScopeMap := make(map[int64]bool)
if len(userIds) > 0 {
var scopeRows []struct {
UserId int64 `json:"user_id"`
ModelId int64 `json:"model_id"`
UserId int64 `json:"user_id" orm:"user_id"`
ModelId int64 `json:"model_id" orm:"model_id"`
}
if err = dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
@@ -1176,17 +1176,17 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
}
var users []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
QuotaType string `json:"quota_type"`
QuotaLimit float64 `json:"quota_limit"`
QuotaPeriod string `json:"quota_period"`
UpdatedAt string `json:"updated_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
CreatedAt string `json:"created_at" orm:"created_at"`
QuotaType string `json:"quota_type" orm:"quota_type"`
QuotaLimit float64 `json:"quota_limit" orm:"quota_limit"`
QuotaPeriod string `json:"quota_period" orm:"quota_period"`
UpdatedAt string `json:"updated_at" orm:"updated_at"`
}
err := model.OrderDesc("id").Limit(1000).Offset(offset).Scan(&users)
if err = common.IgnoreScanNoRows(err); err != nil {
@@ -1203,8 +1203,8 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
monthCostMap := make(map[int64]float64)
if len(userIds) > 0 {
var scopeRows []struct {
UserId int64 `json:"user_id"`
ModelId int64 `json:"model_id"`
UserId int64 `json:"user_id" orm:"user_id"`
ModelId int64 `json:"model_id" orm:"model_id"`
}
if err = dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
+1 -1
View File
@@ -34,7 +34,7 @@ func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberMod
}
var rows []struct {
ModelID int64 `json:"model_id"`
ModelID int64 `json:"model_id" orm:"model_id"`
}
err = dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
+3 -3
View File
@@ -399,9 +399,9 @@ func (s *sTenant) ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUna
func CheckBudgetExhausted(ctx context.Context) error {
// Find active projects with budget set
var projects []struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Budget float64 `json:"budget"`
ID int64 `json:"id" orm:"id"`
TenantID int64 `json:"tenant_id" orm:"tenant_id"`
Budget float64 `json:"budget" orm:"budget"`
}
err := dao.TntProjects.Ctx(ctx).
Where("status", "active").
+3 -3
View File
@@ -58,9 +58,9 @@ func Idempotency(r *ghttp.Request) {
// Check if this key has been processed before
var record struct {
Id int64 `json:"id"`
Status string `json:"status"`
ResponseBody string `json:"response_body"`
Id int64 `json:"id" orm:"id"`
Status string `json:"status" orm:"status"`
ResponseBody string `json:"response_body" orm:"response_body"`
}
err := dao.SysIdempotencyRecords.Ctx(ctx).