From 9bab883779a878fe465cb2d50fe6c648b7df3e99 Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 14 Aug 2026 18:27:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=9D=83=E9=87=8D=E8=A2=AB=E8=AE=BE=E7=BD=AE=E4=B8=BA?= =?UTF-8?q?0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/channel.go | 4 ++-- docs/reference/goframe-conventions.md | 20 ++++++++++++++++++++ internal/logic/admin/channel.go | 10 ++++++++-- web/admin/src/views/ai/ChannelDetailPage.vue | 2 +- web/admin/src/views/ai/ChannelsPage.vue | 9 +-------- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 8205d27..0559c2a 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -83,8 +83,8 @@ type ChannelUpdateReq struct { Type *int `json:"type" v:"min:1#请选择供应商类型" dc:"供应商类型(留空不修改)"` BaseURL string `json:"base_url" dc:"API 基础地址"` ApiKey *string `json:"api_key" dc:"更新 API Key(留空不更新)"` - Priority int `json:"priority" dc:"优先级"` - Weight int `json:"weight" dc:"权重"` + Priority *int `json:"priority" dc:"优先级(留空不更新)"` + Weight *int `json:"weight" dc:"权重(留空不更新)"` MaxConcurrency *int `json:"max_concurrency" v:"min:0" dc:"最大并发请求数(0=按上游 429 水位自动估算,留空不更新)"` TestModel string `json:"test_model" dc:"测试模型名"` Remark string `json:"remark" dc:"备注"` diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 4bc5d68..bd95dcb 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -699,3 +699,23 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { | 长流水线编排(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 路径中是否存在非指针字段的无条件赋值。 diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index b36ca6f..8d573c5 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -347,8 +347,14 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* if req.BaseURL != "" { data.BaseUrl = req.BaseURL } - data.Priority = req.Priority - data.Weight = req.Weight + // priority/weight 为指针字段,仅在显式传入时更新,避免不含这两个字段的 + // 局部更新(如仅切换状态、仅换 Key)把已有值洗成 0。 + if req.Priority != nil { + data.Priority = *req.Priority + } + if req.Weight != nil { + data.Weight = *req.Weight + } if req.MaxConcurrency != nil { data.MaxConcurrency = *req.MaxConcurrency } diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index f1a528d..f0e37fb 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -80,7 +80,7 @@ function openEditModal() { name: detail.value.name || '', base_url: detail.value.base_url || '', priority: detail.value.priority || 0, - weight: detail.value.weight || 100, + weight: detail.value.weight, max_concurrency: detail.value.max_concurrency ?? 100, tier: detail.value.tier || 'primary', strict_capacity: detail.value.strict_capacity || false, diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 73404aa..36c1f6d 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -290,7 +290,6 @@ async function resetHealth(row: any) { } // 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。 -// 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。 // status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。 async function toggleStatus(record: any) { if (record._toggling) return @@ -302,8 +301,6 @@ async function toggleStatus(record: any) { try { await request.put(`/admin/channels/${record.id}`, { status: nextStatus, - priority: record.priority, - weight: record.weight, }) message.success(nextStatus === 'active' ? '已启用' : '已禁用') } catch { @@ -314,8 +311,7 @@ async function toggleStatus(record: any) { } // === 行内编辑(权重 / 层级) === -// 行内编辑复用 PUT /admin/channels/{id}。后端 UpdateChannel 会无条件写入 priority/weight, -// 故每次提交必须携带当前 priority 与 weight,避免被重置为 0。 +// 行内编辑复用 PUT /admin/channels/{id},仅提交变更字段(其余字段留空不更新)。 const weightTimers = new Map() function onWeightChange(record: any, val: number | undefined) { @@ -337,7 +333,6 @@ async function saveWeight(record: any, val: number) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, weight: val, }) record._savedWeight = val @@ -355,8 +350,6 @@ async function saveTier(record: any, val: string) { record._saving = true try { await request.put(`/admin/channels/${record.id}`, { - priority: record.priority, - weight: record.weight, tier: val, }) record._tierSaved = val