Files
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

420 lines
15 KiB
Go
Raw Permalink 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 httpapi_test
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// ---------- 字体 ----------
// fontEnv 建好带赛事项目目录的测试环境,并给出字体落盘目录。
func fontEnv(t *testing.T) (*testEnv, string) {
t.Helper()
projectDir := t.TempDir()
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
return env, filepath.Join(projectDir, "uploads", "fonts")
}
// writeFontFile 直接在字体目录里放一个文件,模拟「已经上传过」。
func writeFontFile(t *testing.T, dir, name string, content []byte) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("创建字体目录: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil {
t.Fatalf("写入字体文件: %v", err)
}
}
// postFont 以 multipart 字段 file 上传到 /api/fonts。
func (e *testEnv) postFont(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write(content); err != nil {
t.Fatalf("写入上传内容: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/fonts", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if token := e.csrfToken(client); token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传字体: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析上传响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
type fontBody struct {
ID string `json:"id"`
Label string `json:"label"`
Builtin bool `json:"builtin"`
URL *string `json:"url"`
}
type fontListBody struct {
Fonts []fontBody `json:"fonts"`
}
// 内置字体固定排在前面且 URL 为 null,上传字体按文件名排序跟在后面。
func TestListFontsBuiltinThenUploaded(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "b.ttf", []byte("bbbb"))
writeFontFile(t, fontsDir, "a.ttf", []byte("aaaa"))
// 不是字体扩展名的残留文件不能出现在清单里。
writeFontFile(t, fontsDir, "notes.txt", []byte("x"))
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, got := env.do(client, http.MethodGet, "/api/fonts", nil, false)
if status != http.StatusOK {
t.Fatalf("读取字体清单应 200,得到 %d %+v", status, got.Error)
}
list := decodeData[fontListBody](t, got)
wantIDs := []string{
"builtin:noto-sans", "builtin:noto-serif", "builtin:misans", "builtin:source-han-sans",
"custom:a.ttf", "custom:b.ttf",
}
if len(list.Fonts) != len(wantIDs) {
t.Fatalf("字体数量 = %d,期望 %d:%+v", len(list.Fonts), len(wantIDs), list.Fonts)
}
for i, want := range wantIDs {
if list.Fonts[i].ID != want {
t.Fatalf("第 %d 项 = %q,期望 %q", i, list.Fonts[i].ID, want)
}
}
if list.Fonts[0].Label != "Noto Sans SC(思源黑体同源)" {
t.Errorf("内置字体应带中文显示名,得到 %q", list.Fonts[0].Label)
}
for _, f := range list.Fonts[:4] {
if !f.Builtin || f.URL != nil {
t.Errorf("内置字体 %s 应为 builtin=true 且 url=null:%+v", f.ID, f)
}
}
last := list.Fonts[5]
if last.Builtin || last.URL == nil || *last.URL != "/uploads/fonts/b.ttf" || last.Label != "b.ttf" {
t.Errorf("上传字体对象不符:%+v", last)
}
}
// 字体清单是系统配置,guest 读不到;admin 与 recorder 都能读。
func TestListFontsRequiresRecorder(t *testing.T) {
env, _ := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/fonts", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读字体清单应 403,得到 %d", status)
}
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
if status, _ := env.do(admin, http.MethodGet, "/api/fonts", nil, false); status != http.StatusOK {
t.Fatalf("admin 读字体清单应 200,得到 %d", status)
}
}
// 上传成功要真落盘、返回 201 与字体对象,并且匿名 GET 能取到同一份内容。
func TestUploadFontThenServeAnonymously(t *testing.T) {
env, fontsDir := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 文件名里的空格与斜杠都要被收敛:服务端只允许安全字符落盘。
status, got := env.postFont(t, client, `C:\Fonts\思源 宋体.ttf`, []byte("fake-ttf"))
if status != http.StatusCreated {
t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error)
}
font := decodeData[fontBody](t, got)
if font.ID != "custom:思源_宋体.ttf" || font.Builtin || font.URL == nil {
t.Fatalf("上传返回的字体对象不符:%+v", font)
}
if *font.URL != "/uploads/fonts/思源_宋体.ttf" {
t.Fatalf("字体 URL = %q", *font.URL)
}
if body, err := os.ReadFile(filepath.Join(fontsDir, "思源_宋体.ttf")); err != nil || string(body) != "fake-ttf" {
t.Fatalf("字体未正确落盘: %v %q", err, body)
}
// 大屏的 @font-face 不带会话,静态访问必须匿名放行。
anon, _ := env.client()
resp, err := anon.Get(env.server.URL + *font.URL)
if err != nil {
t.Fatalf("匿名取字体: %v", err)
}
defer func() { _ = resp.Body.Close() }()
served, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || string(served) != "fake-ttf" {
t.Fatalf("匿名取字体 status=%d body=%q", resp.StatusCode, served)
}
if ct := resp.Header.Get("Content-Type"); ct != "font/ttf" {
t.Errorf("Content-Type = %q,期望 font/ttf", ct)
}
// 字体会被同名重传覆盖,不能长缓存。
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("Cache-Control = %q,期望 no-cache", cc)
}
// 目录里没有的文件、非白名单扩展名一律 404。
for _, path := range []string{"/uploads/fonts/nope.ttf", "/uploads/fonts/notes.txt"} {
resp, err := anon.Get(env.server.URL + path)
if err != nil {
t.Fatalf("取 %s: %v", path, err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("取 %s 应 404,得到 %d", path, resp.StatusCode)
}
}
}
// 类型不符 400、超限 413、重名 409,错误消息必须能直接给操作员看。
func TestUploadFontRejectsBadInput(t *testing.T) {
env, fontsDir := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, got := env.postFont(t, client, "表格.xlsx", []byte("nope"))
if status != http.StatusBadRequest {
t.Fatalf("非字体文件应 400,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "字体文件名非法") {
t.Fatalf("错误消息应说明文件名非法:%+v", got.Error)
}
// 只有扩展名、没有文件名的情况同样拒绝。
if status, _ := env.postFont(t, client, ".ttf", []byte("nope")); status != http.StatusBadRequest {
t.Fatalf("只有扩展名的文件名应 400,得到 %d", status)
}
status, got = env.postFont(t, client, "big.ttf", make([]byte, 20<<20+1))
if status != http.StatusRequestEntityTooLarge {
t.Fatalf("超限字体应 413,得到 %d %+v", status, got.Error)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "20 MiB") {
t.Fatalf("413 消息应说明上限:%+v", got.Error)
}
if status, _ := env.postFont(t, client, "dup.ttf", []byte("one")); status != http.StatusCreated {
t.Fatalf("首次上传应 201,得到 %d", status)
}
status, got = env.postFont(t, client, "dup.ttf", []byte("two"))
if status != http.StatusConflict {
t.Fatalf("重名应 409,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "已存在") {
t.Fatalf("重名消息应说明已存在:%+v", got.Error)
}
if body, err := os.ReadFile(filepath.Join(fontsDir, "dup.ttf")); err != nil || string(body) != "one" {
t.Fatalf("重名被拒后原文件不能被改写: %v %q", err, body)
}
// 上传是系统级资源管理,只有 admin 能做。
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.postFont(t, recorder, "r.ttf", []byte("x")); status != http.StatusForbidden {
t.Fatalf("recorder 上传字体应 403,得到 %d", status)
}
guest, _ := env.client()
if status, _ := env.postFont(t, guest, "g.ttf", []byte("x")); status != http.StatusUnauthorized {
t.Fatalf("guest 上传字体应 401,得到 %d", status)
}
}
// 写入 settings 时必须挡住不存在的字体 id:否则大屏只会静默回落成默认字体。
func TestSettingsFontValidation(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "my-font.ttf", []byte("fake"))
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
seq := 0
put := func(title *string, body *string) (int, envelope) {
seq++
display := map[string]any{}
if title != nil {
display["fontTitle"] = *title
}
if body != nil {
display["fontBody"] = *body
}
return env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": display, "requestId": fmt.Sprintf("req-font-%d", seq)}, true)
}
for _, bad := range []string{"builtin:comic-sans", "custom:nope.ttf", "custom:../evil.ttf", "随便写的"} {
status, got := put(&bad, nil)
if status != http.StatusBadRequest {
t.Fatalf("非法字体 id %q 应 400,得到 %d", bad, status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "fontTitle") {
t.Fatalf("错误消息应指明字段:%+v", got.Error)
}
}
title := "builtin:noto-serif"
body := "custom:my-font.ttf"
if status, got := put(&title, &body); status != http.StatusOK {
t.Fatalf("合法字体应 200,得到 %d %+v", status, got.Error)
}
settings := decodeData[map[string]any](t, mustGetSettings(t, env, client))
display := settings["display"].(map[string]any)
if display["fontTitle"] != "builtin:noto-serif" || display["fontBody"] != "custom:my-font.ttf" {
t.Fatalf("字体未落库:%+v", display)
}
// 空串表示回到默认字体,必须下发显式 null。
empty := ""
if status, got := put(nil, &empty); status != http.StatusOK {
t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error)
}
settings = decodeData[map[string]any](t, mustGetSettings(t, env, client))
display = settings["display"].(map[string]any)
if display["fontBody"] != nil {
t.Fatalf("清空后 fontBody 应为 null:%+v", display)
}
if display["fontTitle"] != "builtin:noto-serif" {
t.Fatalf("只改 fontBody 时不应动到 fontTitle:%+v", display)
}
}
// 删除字体:不存在 404、被设置引用 409(提示先改配置)、改完配置后可以删掉。
func TestDeleteFont(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "used.ttf", []byte("used"))
writeFontFile(t, fontsDir, "free.ttf", []byte("free"))
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if status, _ := env.do(client, http.MethodDelete, "/api/fonts/nope.ttf", nil, true); status != http.StatusNotFound {
t.Fatalf("删除不存在的字体应 404,得到 %d", status)
}
used := "custom:used.ttf"
if status, got := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": map[string]any{"fontTitle": used}, "requestId": "req-del-1"}, true); status != http.StatusOK {
t.Fatalf("设置字体应 200,得到 %d %+v", status, got.Error)
}
status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true)
if status != http.StatusConflict {
t.Fatalf("被引用的字体应 409,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "正在被大屏使用") {
t.Fatalf("409 消息应提示先改配置:%+v", got.Error)
}
if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); err != nil {
t.Fatalf("被引用的字体不能被删除: %v", err)
}
if status, got := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": map[string]any{"fontTitle": ""}, "requestId": "req-del-2"}, true); status != http.StatusOK {
t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error)
}
if status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true); status != http.StatusOK {
t.Fatalf("不再被引用的字体应可删除,得到 %d %+v", status, got.Error)
}
if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); !os.IsNotExist(err) {
t.Fatalf("删除后文件应消失: %v", err)
}
// 删除是系统级资源管理,recorder 不能做。
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodDelete, "/api/fonts/free.ttf", nil, true); status != http.StatusForbidden {
t.Fatalf("recorder 删除字体应 403,得到 %d", status)
}
}
// 赛事包必须带上上传字体:字体是 uploads/ 下的资源,整包拷走才能在大屏上复现。
func TestExportProjectZipIncludesFonts(t *testing.T) {
env, _ := fontEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if status, got := env.postFont(t, client, "思源宋体.ttf", []byte("fake-font")); status != http.StatusCreated {
t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error)
}
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("读取 zip: %v", err)
}
found := false
for _, f := range zr.File {
if f.Name == "uploads/fonts/思源宋体.ttf" {
found = true
}
}
if !found {
names := make([]string, 0, len(zr.File))
for _, f := range zr.File {
names = append(names, f.Name)
}
t.Fatalf("赛事包缺少 uploads/fonts/思源宋体.ttf,实际含 %v", names)
}
}
// mustGetSettings 读一次 GET /api/settings,返回统一响应体。
func mustGetSettings(t *testing.T, env *testEnv, client *http.Client) envelope {
t.Helper()
status, got := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读取设置应 200,得到 %d %+v", status, got.Error)
}
return got
}