Compare commits

...

1 Commits

Author SHA1 Message Date
91f6d1f32f fix(channels): 日志截断按字符(中文被切出 U+FFFD)+ 面板排版
## 真实缺陷(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 → 报红

## 排版抛光(截图里可见的三处)
- 头部控件原用 `<h2>` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `<b>` +
  独立 flex 行 + `white-space:nowrap`
- 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break`
- 列表加 `line-height`,可读性

## 兼容性
历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
2026-10-16 17:44:04 +08:00
3 changed files with 146 additions and 21 deletions

View File

@ -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 截图里可见):日志尾显示成
//
// `能力增强不<E5BCBA>`
//
// 根因:`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 安全")
}

View File

@ -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))
}
}

View File

@ -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**,前端显示成 `能力增强不<E5BCBA>` 这种乱码尾巴。
-- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符,
-- 看起来像数据损坏,实际是截断方式错。
--
-- 做法:先按字节切,再回退到最后一个完整 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 = {
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
</div>
<div class="card" id="ch-log-card">
<h2 style="margin:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span>运行日志</span>
<span class="grow"></span>
<label class="muted" style="font-size:12px;font-weight:400;display:flex;align-items:center;gap:5px">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()"> 显示例行任务
</label>
<select id="ch-log-level" onchange="chLogRender()"
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
<option value="all">全部级别</option>
<option value="warn">警告及以上</option>
<option value="error">仅错误</option>
</select>
</h2>
<div class="muted" style="font-size:12px;margin:6px 0 8px" id="ch-log-hint"></div>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto"></ul>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<b style="font-size:15px">运行日志</b>
<span class="grow"></span>
<label class="muted" style="font-size:12px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()" style="margin:0"> 显示例行任务
</label>
<select id="ch-log-level" onchange="chLogRender()"
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
<option value="all">全部级别</option>
<option value="warn">警告及以上</option>
<option value="error">仅错误</option>
</select>
</div>
<div class="muted" style="font-size:12px;margin:8px 0" id="ch-log-hint"></div>
<!-- ★ 文本**允许换行**(word-break),否则长诊断在窄卡片里被截断,
而截断的尾巴正是最该看到的部分(如 HTTP 状态/具体原因)。 -->
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto;word-break:break-word;line-height:1.5"></ul>
</div>
</div>
</div>
@ -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 里),判据要断言"某件事被记录了"就得有它。