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"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
This commit is contained in:
JianFeeeee
2026-10-16 17:44:04 +08:00
parent 2d7388ad35
commit dda537cda9
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))
}
}