三块改动,均为现场反馈:
一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 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/ 同步更新。
420 lines
15 KiB
Go
420 lines
15 KiB
Go
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
|
||
}
|