Files
HomeAgent/internal/plugins/webui/gzip_test.go
JianFeeeee ef08ebb7d3 chore(mascot): 立绘 v2 —— 真 alpha 素材 + 头部裁切头像 + 素材判据
老大给的 4 张新立绘里有 3 张是「棋盘格假透明」JPEG(像素是灰白格子,
不是 alpha)。直接进仓库会在深色面板上露出格子,所以先抠成真 alpha 再落盘。

## 为什么主图与头像要分两个文件

现役 mascot.webp 唯一的消费点是**聊天消息的 30px 圆形头像**
(dashboard.js 4 处 aiAvatar + GUI app.js 2 处),概览页并没有放立绘。
主图是坐姿全身,按现有 CSS(object-fit:cover + scale(1.5))裁成圆后
只剩裙子、看不到脸 —— 只换图不做头像版,等于把头像换成一张看不懂的图。

- assets/branding/mascot-xiaozhai.webp        1024² 主立绘(官方展示)
- assets/branding/mascot-xiaozhai-avatar.webp  512² 头部方裁(运行期源)
- internal/plugins/webui/mascot.webp           256² 头像版(原 133KB→24KB)
- cmd/gui/renderer/mascot.webp                 128² 头像版(原 133KB→9KB)

参考稿另存三张(不参与运行):sheet(设定总图)/ portrait(半身)/
turnaround(三视图)。

## 抠图怎么做的(可复现,不是手搓)

背景是 20.45px 周期的棋盘格(237/254 两色)。判据取「中性灰 且 灰度≥228」
的像素做候选,再从图像边界做连通域传播 —— 这样白裙子/白围裙因为不中性
(带蓝调)不会被误判,避免了当年 site/README 记录的 flood-fill 渗色问题。
边界再内缩 1px 抹白边,0.7px 高斯羽化去锯齿。抠完透明占比 55.8%,
深/浅两种背景上目检均无残留格子与白边。

## CSS:scale(1.5) → 1.25

原来的 1.5 是为「全身图裁圆」补的放大。换成头部方裁后,1.5 会切掉两侧
发绳与呆毛 —— 按真实 30px 渲染放大对照看过 1.0/1.15/1.25/1.4 四档。

## 新增判据(含反向验证)

internal/plugins/webui/mascot_asset_test.go 直接读 WebP 容器头,
断言 VP8X 的 Alpha 标志位与 ALPH chunk **同时存在**,外加正方形/≥128px/
≤128KB。理由:这类退化在肉眼 review 里抓不住(缩到 30px 就是一团浅色),
而用户每天都在看;且 site/README.md 记着当年正因为「白底图没法抠」
干脆不放立绘,这个坑值得一个自动化判据。

反向验证(已实跑,非声称):把素材换成 HEAD 旧图,判据报红并准确指出
「Alpha 标志位未置位 / 缺 ALPH chunk / 体积超限」;换回新图转绿。
另有一条独立用例构造「带 VP8X 但不置 Alpha 位」的最小容器,
确认解析器不会对退化素材误判为绿。

## 顺带修的两处

1. assets/docs/{zh,en}/PLUGIN_DEV.md 的 mascot 引用路径是坏的:
   `../../assets/branding/...` 从 assets/docs/zh/ 出发解析成
   `assets/assets/branding/...`,文件不存在(旧版一直是坏的)。
   已改为 `../../branding/...` 并逐条验证路径可解析。
   同时指向新的头像版(那处是 20px 圆形内联小图标)。
2. gzip.go / gzip_test.go 注释里的「mascot.webp(133KB)」改为不带死数字
   的说法 —— 体积会随素材变,注释写死数字只会变成误导。

## 验证

go build ./...            → 0
go vet ./internal/plugins/webui/ ./cmd/gui/ → 干净
gofmt -l                  → 空
go test ./internal/plugins/webui/ -count=1 → ok(15.1s,含新增 2 个用例)

## 没做的事

- 没碰 main、没开 PR、没部署、没重启服务。
- **概览页仍未放立绘**。site/README 里「素材不可用」这条已不成立(素材有
  alpha 了),但版面是否该放图属于设计取舍,不在本次范围 —— 文档已相应改写,
  不再用「素材不行」当理由。
- 抠图对发丝边缘是「够用」不是「完美」:0.7px 羽化在 1024 下看不出来,
  但若将来要印物料级大图,需要重出带原生 alpha 的源图。
2026-10-18 07:38:10 +08:00

450 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"bufio"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// TestGzipCompressesJSON 是本次改动的正面判据。
//
// 生产实测:首屏 API 合计 792,933 B 且服务端此前**完全没有**
// Content-Encoding;同一份数据 gzip -9 后 chat/history 554,764 →
// 174,594(-69%)、kernel 152,667 → 37,697(-75%)。
func TestGzipCompressesJSON(t *testing.T) {
// 造一个「像生产那样重复度高」的 JSON:同批 key 反复出现。
var sb strings.Builder
sb.WriteByte('[')
for i := 0; i < 400; i++ {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(`{"id":12345,"name":"工具结果会撑爆共享段 arena","type":"Concept","mention_count":228,"updated_at":"2026-09-26T14:54:16Z"}`)
}
sb.WriteByte(']')
payload := sb.String()
raw := len(payload)
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
io.WriteString(w, payload)
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
// 手动关掉自动解压,才能量到 wire 尺寸。
tr := &http.Transport{DisableCompression: true}
cl := &http.Client{Transport: tr}
resp, err := cl.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
wire, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q,应为 gzip", got)
}
if len(wire) >= raw {
t.Fatalf("压缩无效:raw=%d wire=%d(应显著变小)", raw, len(wire))
}
t.Logf("JSON 压缩:%d → %d 字节(-%.0f%%)", raw, len(wire),
100*float64(raw-len(wire))/float64(raw))
// 内容必须一字不差可还原。
dec := readGzFrom(t, payload, wire)
if dec != payload {
t.Fatalf("解压后内容与原始不一致(len %d vs %d)", len(dec), len(payload))
}
}
// readGzFrom 用已拿到的 wire 字节解压,避免再发请求。
func readGzFrom(t *testing.T, _ string, wire []byte) string {
t.Helper()
zr, err := gzip.NewReader(strings.NewReader(string(wire)))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
defer zr.Close()
b, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(b)
}
// TestGzipSkipsSmallResponses 钉住「小响应不压」。
//
// 几百字节的 JSON 压完反而更大(gzip 头 23 字节 + deflate 无收益),
// 压它是纯亏。与 nginx 的 gzip_min_length 1000 对齐。
func TestGzipSkipsSmallResponses(t *testing.T) {
body := `{"ok":true}`
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
io.WriteString(w, body)
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Fatalf("小响应不应压缩,却带了 Content-Encoding=%q", ce)
}
b, _ := io.ReadAll(resp.Body)
if string(b) != body {
t.Fatalf("小响应内容被破坏: %q", string(b))
}
}
// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。
//
// mascot.webp 在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
// 纯浪费 CPU——而 CPU 正是压缩要省的东西。
func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) {
for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} {
t.Run(ct, func(t *testing.T) {
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", ct)
io.WriteString(w, strings.Repeat("x", 5000))
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Fatalf("%s 不应压缩,却带了 %q", ct, ce)
}
})
}
}
func bytesRepeat(s string, n int) string {
return strings.Repeat(s, n)
}
// TestGzipNoRequestNoCompress 钉住「客户端不要就不压」。
func TestGzipNoRequestNoCompress(t *testing.T) {
body := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 300)
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
io.WriteString(w, body)
})))
defer srv.Close()
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).
Get(srv.URL)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Fatalf("未声明 Accept-Encoding 时不应压缩,却带了 %q", ce)
}
b, _ := io.ReadAll(resp.Body)
if string(b) != body {
t.Fatalf("未压缩路径内容被破坏")
}
}
// ★ TestGzipSetsVary 是缓存正确性的判据。
//
// 同一 URL 会因 Accept-Encoding 不同而返回不同编码。缺 Vary 时
// 中间缓存(nginx/CDN/浏览器)可能把 gzip 版发给不支持压缩的客户端。
func TestGzipSetsVary(t *testing.T) {
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
io.WriteString(w, strings.Repeat(`{"a":"小宅"},`, 400))
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary = %q,应含 Accept-Encoding", resp.Header.Get("Vary"))
}
}
// TestGzipDropsContentLength 钉住「压缩后不能留 Content-Length」。
//
// 留着就是**错的**长度(gzip 后更短),客户端按 Content-Length 读会
// 截断或多等。
// ★ TestGzipDropsContentLength 钉住「压缩后不能留**陈旧的** Content-Length」。
//
// ★ 这条判据的写法我先写错了,值得记下来:初版断言「压缩响应不应带
// Content-Length」并实测失败。追查后证明**是我的判据错了、代码是对的**:
//
// 在 Go 里 Header.Del("Content-Length") 之后,若响应体小到能被 net/http
// 一次性缓冲(< 2048B),它会**自动重算**并补上压缩后的真实长度。
// 实测:原始 14000B → wire 119B → 响应头 Content-Length: 119(正确)。
//
// 真正要防的是「陈旧长度」:留着 14000 而实际发 119 时,客户端按
// Content-Length 读满 14000 会先拿到 119 字节再吃 unexpected EOF
// (已用对照探针实测复现)。所以判据必须是:
// ① 读到的字节数 == 声明的 Content-Length(自洽)
// ② 该值 == **压缩后**的 wire 长度,而非压缩前的原始长度
func TestGzipDropsContentLength(t *testing.T) {
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 故意设成**原始**长度,模拟「handler 自己写的长度」。
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
io.WriteString(w, payload)
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
wire, readErr := io.ReadAll(resp.Body)
if readErr != nil {
t.Fatalf("★ 读 body 失败(陈旧 Content-Length 的典型症状): %v", readErr)
}
cl := resp.Header.Get("Content-Length")
if cl == "" {
return // 无 Content-Length(chunked)同样正确
}
// ① 自洽:声明的长度 == 实际读到的字节数
if n, err := strconv.Atoi(cl); err != nil || n != len(wire) {
t.Fatalf("Content-Length=%s 与实际读到的 %d 字节不一致(陈旧长度)", cl, len(wire))
}
// ② 该值必须是压缩后的长度,而不是压缩前的原始长度
if cl == strconv.Itoa(len(payload)) {
t.Fatalf("Content-Length 仍是压缩前的 %d —— 客户端会读到压缩数据后吃 unexpected EOF", len(payload))
}
dec := readGzFrom(t, payload, wire)
if dec != payload {
t.Fatalf("解压内容不一致")
}
}
// TestGzipStaleContentLengthWouldBreak 正面记录「陈旧长度」的危害。
//
// 若中间件忘了 Del("Content-Length"),客户端按原始长度读满就会撞
// unexpected EOF。这条把危害钉成可执行的判据,而不是只靠注释。
func TestGzipStaleContentLengthWouldBreak(t *testing.T) {
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
// 模拟「忘记 Del」的错误中间件:压了却留着原始长度。
broken := gzipMWBrokenNoDel(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
io.WriteString(w, payload)
}))
srv := httptest.NewServer(broken)
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
return // 请求就失败了,也算「陈旧长度确实有害」
}
defer resp.Body.Close()
wire, readErr := io.ReadAll(resp.Body)
// 对照:本包的真中间件不会出这个问题;这里只断言「要么失败、要么读到
// 不足声明长度的数据」——两者都证明陈旧长度有害。
if readErr == nil && len(wire) == len(payload) {
t.Skip("该环境恰好容忍了陈旧长度,无法构造反例")
}
t.Logf("陈旧 Content-Length 的实际后果:读到 %d/%d 字节,readErr=%v",
len(wire), len(payload), readErr)
}
// gzipMWBrokenNoDel 是**故意坏**的中间件,只给上面那条对照判据用。
func gzipMWBrokenNoDel(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
next.ServeHTTP(gw, r)
gw.finish()
// 故意不 Del("Content-Length")
gw.close()
})
}
// ★★ TestGzipPreservesFlusher 是**最关键**的一条。
//
// handler 里是 `w.(http.Flusher)` 的类型断言(handleChatEvents /
// streamOpenAI)。包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil
// ⇒ 代码走降级分支 ⇒ SSE 静默坏掉(表现为「收不到流式」而不是报错)。
//
// 判据:经过 gzipMW 之后,handler 仍必须能断言出 Flusher 并真的生效。
func TestGzipPreservesFlusher(t *testing.T) {
var gotFlusher bool
var flushWorked bool
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, ok := w.(http.Flusher)
gotFlusher = ok
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
io.WriteString(w, strings.Repeat(`{"chunk":"x"},`, 300))
if ok {
f.Flush()
// 记下:Flush 之后能不能立刻观察到数据写出。
_, flushWorked = f.(interface{ FlushError() error })
}
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
io.ReadAll(resp.Body)
if !gotFlusher {
t.Fatal("★ 经过 gzipMW 后 w.(http.Flusher) 断言失败 —— " +
"handleChatEvents/streamOpenAI 会走降级分支,SSE 静默坏掉")
}
_ = flushWorked
}
// ★ TestSSENotGzipped 钉住「SSE 不压」。
//
// text/event-stream 一旦进 gzip 缓冲,flush 语义就废了
// (表现为「前端收不到流式,要等缓冲攒够」)。
func TestSSENotGzipped(t *testing.T) {
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
io.WriteString(w, "data: hi\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
})))
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("Accept", "text/event-stream")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
t.Fatal("★ SSE 被压缩了 —— flush 语义会被毁掉")
}
rd := bufio.NewReader(resp.Body)
line, err := rd.ReadString('\n')
if err != nil {
t.Fatalf("read first line: %v", err)
}
if !strings.HasPrefix(line, "data:") {
t.Fatalf("首行 = %q", line)
}
}
// TestGzipNoBodyStatuses 钉住 204/304 不带 Content-Encoding。
func TestGzipNoBodyStatuses(t *testing.T) {
for _, code := range []int{http.StatusNoContent, http.StatusNotModified} {
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
})))
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Errorf("状态 %d 不应带 Content-Encoding,却有 %q", code, ce)
}
resp.Body.Close()
srv.Close()
}
}
// TestGzipStatusCodePreserved 钉住状态码不被中间件改写。
//
// requireAPI 的 401/503、requireWeb 的 302 都走这条路。
func TestGzipStatusCodePreserved(t *testing.T) {
for _, code := range []int{200, 201, 302, 401, 404, 500, 503} {
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
io.WriteString(w, strings.Repeat(`{"e":"x"},`, 400))
})))
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{
Transport: &http.Transport{DisableCompression: true},
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}).Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
if resp.StatusCode != code {
t.Errorf("状态码 = %d,应为 %d", resp.StatusCode, code)
}
io.ReadAll(resp.Body)
resp.Body.Close()
srv.Close()
}
}
// TestGzipRealChainSSEStillWorks 端到端:走生产同一条链(Handler())打真 SSE。
//
// 这条最有价值——它同时验证 gzip 接入没破坏既有 SSE 行为。
func TestGzipRealChainSSEStillWorks(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil)
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Accept-Encoding", "gzip")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("SSE 连接失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode)
}
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
t.Fatal("★ SSE 不应被压缩")
}
// 短时间内必须还活着(能读到首帧或至少没被立刻断开)
rd := bufio.NewReader(resp.Body)
type res struct{ ok bool }
ch := make(chan res, 1)
go func() {
_, err := rd.ReadString('\n')
ch <- res{err == nil}
}()
select {
case r := <-ch:
if !r.ok {
t.Error("SSE 首读失败")
}
case <-time.After(5 * time.Second):
_ = resp.Body.Close()
}
}