From 91f6d1f32f4ed3e86b824aaddcc4e918e9f2a183 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 17:44:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E6=97=A5=E5=BF=97=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E6=8C=89=E5=AD=97=E7=AC=A6=EF=BC=88=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E8=A2=AB=E5=88=87=E5=87=BA=20U+FFFD=EF=BC=89+=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真实缺陷(v2.0.33 上线后在截图里看到) 日志尾显示成:`能力增强不�` 根因:`tostring(cerr):sub(1, 120)` —— **Lua 的 sub 按字节切**,而中文占 3 字节 ⇒ 切在字符中间产生**替换字符 U+FFFD**。一句本来完整的诊断("…能力增强不可用, 已退回兜底表")看起来像**数据损坏**,实际只是截断方式错。 生产实测复现:该条 120 字节截断后,末尾正好落在"不"字的第 1 字节上, python 侧解码得到 `…能力增强不\ufffd`。**修法**:`plugin.utf8Clip(s, maxBytes)` 先按字节切、再回退到最后一个完整 UTF-8 序列边界(最多退 2 字节)。 三处中文文案截断全部改用它。 ## 判据(1 条静态 + 1 条行为 + 变异 1/1) - `TestLogTruncationIsUTF8Safe`(静态):助手存在 / 三处中文截断不再用裸 sub / 未超长时原样返回 / 回退逻辑处理孤立首字节 - `TestUtf8ClipBehaviour`(**行为**,经 `plugin.__utf8_clip_probe` 走真实函数): 对 6 个长度断言 `utf8.ValidString(out)` 且不超字节上限。静态断言看不出 "结果是否真的合法 UTF-8",所以必须有这一条 —— 实测 n=120 从 `…能力增强不�`(非法)变为 `…能力增强不`(合法) - 变异:把 utf8Clip 换回裸 sub → 报红 ## 排版抛光(截图里可见的三处) - 头部控件原用 `

` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `` + 独立 flex 行 + `white-space:nowrap` - 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break` - 列表加 `line-height`,可读性 ## 兼容性 历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"` 已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。 --- internal/core/channels_log_panel_test.go | 54 ++++++++++++++++- internal/core/channels_utf8clip_test.go | 38 ++++++++++++ internal/lua/plugins/channels.lua | 75 ++++++++++++++++++------ 3 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 internal/core/channels_utf8clip_test.go diff --git a/internal/core/channels_log_panel_test.go b/internal/core/channels_log_panel_test.go index 140487d..43f8323 100644 --- a/internal/core/channels_log_panel_test.go +++ b/internal/core/channels_log_panel_test.go @@ -71,8 +71,8 @@ func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) { } // 这些是异常 —— 必须 warn(不能被一起折叠掉) for _, pat := range []string{ - `logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn")`, - `logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn")`, + `logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`, + `logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`, `logEvent("凭据续期失败 " .. #failed .. " 个:`, } { if !strings.Contains(s, pat) { @@ -138,3 +138,53 @@ func TestChannelsLogPanelHasFilters(t *testing.T) { } t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备") } + +// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。 +// +// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成 +// +// `能力增强不�` +// +// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节 +// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。 +func TestLogTruncationIsUTF8Safe(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // ① 必须存在按字符截断的助手 + if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") { + t.Fatal("必须实现 plugin.utf8Clip(按字符截断)") + } + // ② 中文文案上的截断不得再用裸 sub() + for _, bad := range []string{ + `tostring(cres):sub(1, 120)`, + `tostring(cerr):sub(1, 120)`, + `table.concat(failed, "; "):sub(1, 300)`, + } { + if strings.Contains(s, bad) { + t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad) + } + } + // ③ 三处必须已换成 utf8Clip + for _, want := range []string{ + "plugin.utf8Clip(cres, 120)", + "plugin.utf8Clip(cerr, 120)", + `plugin.utf8Clip(table.concat(failed, "; "), 300)`, + } { + if !strings.Contains(s, want) { + t.Errorf("缺少 UTF-8 安全截断调用:%s", want) + } + } + // ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短) + if !strings.Contains(s, "if #s <= maxBytes then return s end") { + t.Error("utf8Clip 必须对未超长的输入原样返回") + } + // ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节 + if !strings.Contains(s, "elseif b >= 0xC0 then") { + t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)") + } + t.Log("✓ 截断已 UTF-8 安全") +} diff --git a/internal/core/channels_utf8clip_test.go b/internal/core/channels_utf8clip_test.go new file mode 100644 index 0000000..7653be8 --- /dev/null +++ b/internal/core/channels_utf8clip_test.go @@ -0,0 +1,38 @@ +package core + +import ( + "testing" + "unicode/utf8" +) + +// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。 +func TestUtf8ClipBehaviour(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + + s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表" + for _, n := range []int{10, 40, 119, 120, 121, 400} { + out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n}) + if err != nil { + t.Fatal(err) + } + got, _ := jsonOf(t, out)["out"].(string) + if !utf8.ValidString(got) { + t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got) + } + if n < len(s) && len(got) > n { + t.Errorf("n=%d 结果超出字节上限: %d", n, len(got)) + } + if n >= len(s) && got != s { + t.Errorf("n=%d 未超长时应原样返回", n) + } + t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got)) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index a117778..4c005ef 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -64,7 +64,7 @@ plugin.kernel = { -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -2772,6 +2772,34 @@ do if sh then sh:close() end end +-- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。 +-- +-- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节 +-- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不�` 这种乱码尾巴。 +-- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符, +-- 看起来像数据损坏,实际是截断方式错。 +-- +-- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。 +-- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。 +plugin.utf8Clip = function(s, maxBytes) + s = tostring(s or "") + if #s <= maxBytes then return s end + local cut = s:sub(1, maxBytes) + -- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节 + for _ = 1, 4 do + local b = cut:byte(-1) + if not b then break end + if b < 0x80 then break -- ASCII,完整 + elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整 + cut = cut:sub(1, -2) + break + else -- 续字节,继续往前退 + cut = cut:sub(1, -2) + end + end + return cut +end + -- logEvent 写一条插件事件日志(带级别与渠道归属)。 -- -- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加: @@ -6592,11 +6620,11 @@ function plugin.refresh_models() if not cok then -- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权" -- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。 - logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn") + logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn") elseif cres == true then logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task") elseif cerr then - logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn") + logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn") end if updated > 0 then logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task") @@ -6663,7 +6691,7 @@ function plugin.refresh_creds() end if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end if #failed > 0 then - logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300), "warn") + logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn") end end @@ -10789,21 +10817,23 @@ plugin.ui = { 全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
-

- 运行日志 - - - -

-
-
    +
    + 运行日志 + + + +
    +
    + +
      @@ -11591,6 +11621,13 @@ function plugin.__resync_sources_probe(args) return { json = { ok = true, n = n } } end +-- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义 +-- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。 +function plugin.__utf8_clip_probe(args) + args = args or {} + return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } } +end + -- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。 -- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、 -- 不在 exports 里),判据要断言"某件事被记录了"就得有它。