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/ 同步更新。
This commit is contained in:
AskaEth committed 2026-09-10 17:45:11 +08:00
1 parent 9c6d0bd3b3
commit fc96d17660
42 files changed
+2451 -95

No files matched your search

@@ -113,6 +113,14 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
opts.UploadsDir = uploads
}
}
// 上传字体单独再接一次:它就在 uploads 目录里,整目录打包本来就会带上,
// 但字体是大屏渲染的硬依赖,这里显式再兜一层,避免以后 uploads 打包条件变动时静默漏掉。
if fonts, err := s.fontsDir(); err == nil {
if info, err := os.Stat(fonts); err == nil && info.IsDir() {
opts.FontsDir = fonts
opts.IncludeFonts = true
}
}
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
if err != nil {
return nil, err
+316
View File
@@ -0,0 +1,316 @@
package httpapi
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
// ---------- 字体清单 ----------
// fontDTO 是 GET /api/fonts 的一项,也是上传成功后返回的对象。
// 内置字体没有文件,URL 为 null;上传字体带 /uploads/fonts/<文件名> 下的静态地址。
type fontDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Builtin bool `json:"builtin"`
URL *string `json:"url"`
}
// builtinFonts 是随前端一起分发的内置字体(@font-face 见 web/src/shared/styles/fonts.css)。
// id 与 CSS 家族名的对应关系是固定契约,前端 web/src/display/fonts.ts 必须与这里一致。
var builtinFonts = []fontDTO{
{ID: "builtin:noto-sans", Label: "Noto Sans SC(思源黑体同源)", Builtin: true},
{ID: "builtin:noto-serif", Label: "Noto Serif SC(思源宋体同源)", Builtin: true},
{ID: "builtin:misans", Label: "MiSans", Builtin: true},
{ID: "builtin:source-han-sans", Label: "Source Han Sans CN(思源黑体)", Builtin: true},
}
// 上传字体的限制:扩展名白名单与单文件上限。
const (
maxFontBytes = 20 << 20
maxFontFormSize = maxFontBytes + 1<<20
customFontID = "custom:"
)
// allowedFontTypes 是允许上传的字体扩展名与对应 Content-Type。
var allowedFontTypes = map[string]string{
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
}
func isBuiltinFontID(id string) bool {
for _, f := range builtinFonts {
if f.ID == id {
return true
}
}
return false
}
// fontsDir 返回当前赛事项目的字体目录 <项目目录>/uploads/fonts。
// 放在 uploads 下是为了随赛事包一起走:导出整包就带上了字体。
func (s *server) fontsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法管理字体"}
}
return filepath.Join(dir, "uploads", "fonts"), nil
}
// safeFontName 把客户端文件名收敛成安全落盘名。
//
// 先取 Base 去掉目录部分,再只保留 ASCII 字母数字、点、下划线、连字符与汉字,
// 其余字符替换为下划线:字体是外部资源,名字会进 URL 与磁盘路径,必须挡住路径穿越;
// 保留汉字是为了让「思源宋体.ttf」这类中文名还能认出是什么字体。
// 返回 false 表示收敛后为空或只剩扩展名,调用方按 400 处理。
func safeFontName(raw string) (string, bool) {
// Windows 客户端可能传 "C:\Fonts\思源.ttf",反斜杠在 Linux 上不会被 Base 拆开。
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), `\`, "/"))
var out strings.Builder
for _, r := range base {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-'):
out.WriteRune(r)
case unicode.Is(unicode.Han, r):
out.WriteRune(r)
default:
out.WriteRune('_')
}
}
name := out.String()
ext := strings.ToLower(filepath.Ext(name))
if name == "" || name == ext {
return "", false
}
if _, ok := allowedFontTypes[ext]; !ok {
return "", false
}
return name, true
}
// fontContentType 校验上传字体的文件名并给出 Content-Type。
// 只接受「单层文件名 + 白名单扩展名」,且必须与 safeFontName 收敛结果一致。
func fontContentType(name string) (string, bool) {
if name == "" || strings.ContainsAny(name, `/\`) {
return "", false
}
safe, ok := safeFontName(name)
if !ok || safe != name {
return "", false
}
return allowedFontTypes[strings.ToLower(filepath.Ext(name))], true
}
// customFontName 从 `custom:<文件名>` 形式的 id 里取出文件名;不是上传字体时返回 false。
func customFontName(id string) (string, bool) {
if !strings.HasPrefix(id, customFontID) {
return "", false
}
name := strings.TrimPrefix(id, customFontID)
if _, ok := fontContentType(name); !ok {
return "", false
}
return name, true
}
// checkDisplayFont 校验 display.font_* 的取值:空串表示用默认字体,
// 其余必须是内置 id,或确实存在于 uploads/fonts 下的上传字体。
func (s *server) checkDisplayFont(key, value string) error {
id := strings.TrimSpace(value)
if id == "" {
return nil
}
if isBuiltinFontID(id) {
return nil
}
name, ok := customFontName(id)
if !ok {
return badRequest(key+" 不是已知字体,请从字体列表中选择", value)
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if info, err := os.Stat(filepath.Join(dir, name)); err != nil || info.IsDir() {
return badRequest(key+" 引用的字体 "+name+" 不存在,请先上传", value)
}
return nil
}
// ---------- 接口 ----------
// listFontsHandler 返回内置字体(固定顺序在前)与已上传字体(按文件名排序)。
func (s *server) listFontsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
items := make([]fontDTO, len(builtinFonts))
copy(items, builtinFonts)
// 没有字体目录说明还没上传过,属于正常状态而不是错误。
if dir, err := s.fontsDir(); err == nil {
entries, err := os.ReadDir(dir)
if err == nil {
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if _, ok := fontContentType(e.Name()); ok {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
items = append(items, customFontDTO(name))
}
}
}
return map[string]any{"fonts": items}, nil
}
func customFontDTO(name string) fontDTO {
url := "/uploads/fonts/" + name
return fontDTO{ID: customFontID + name, Label: name, Builtin: false, URL: &url}
}
// uploadFontHandler 保存 multipart file 字段里的字体文件,成功返回 201 与字体对象。
func (s *server) uploadFontHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.fontsDir()
if err != nil {
return nil, err
}
r.Body = http.MaxBytesReader(w, r.Body, maxFontFormSize)
if err := r.ParseMultipartForm(maxFontFormSize); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return nil, fontTooLarge()
}
return nil, badRequest("上传内容解析失败", err.Error())
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", nil)
}
defer func() { _ = file.Close() }()
name, ok := safeFontName(header.Filename)
if !ok {
return nil, badRequest("字体文件名非法,只支持 ttf/otf/woff/woff2 且需带文件名", header.Filename)
}
// header.Size 由客户端声明,不可信,按真实读到的字节数判限。
body, err := io.ReadAll(io.LimitReader(file, maxFontBytes+1))
if err != nil {
return nil, badRequest("读取上传内容失败", err.Error())
}
if len(body) > maxFontBytes {
return nil, fontTooLarge()
}
if len(body) == 0 {
return nil, badRequest("上传内容为空", nil)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
// O_EXCL:并发上传同名文件时只有一个成功,其余按重名拒绝。
out, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if errors.Is(err, os.ErrExist) {
return nil, conflict("字体 " + name + " 已存在,请先删除或重命名后再上传")
}
if err != nil {
return nil, internal(err)
}
if _, err := out.Write(body); err != nil {
_ = out.Close()
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
if err := out.Close(); err != nil {
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
s.audit(r.Context(), "font.upload", "font", 0, nil, map[string]any{"file": name, "size": len(body)})
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": customFontDTO(name)})
return nil, errHandled
}
// fontTooLarge 返回 413:字体超过上限时必须给出状态码,前端才知道是「太大」而不是「格式不对」。
func fontTooLarge() *apiError {
return &apiError{status: http.StatusRequestEntityTooLarge, code: "VALIDATION_FAILED",
message: "字体文件超过 20 MiB 上限"}
}
// deleteFontHandler 删除上传字体;仍被 display.font_title / display.font_body 引用时拒绝。
func (s *server) deleteFontHandler(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
if _, ok := fontContentType(name); !ok {
return notFound("字体不存在")
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if err := s.ensureFontUnused(r, name); err != nil {
return err
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
if os.IsNotExist(err) {
return notFound("字体不存在")
}
return internal(err)
}
s.audit(r.Context(), "font.delete", "font", 0, map[string]any{"file": name}, nil)
return nil
}
// ensureFontUnused 挡住「删掉正在被大屏使用的字体」:删了之后大屏只剩回落字体,
// 现场看到的是「字体突然变了」,比直接拒绝更难排查。
func (s *server) ensureFontUnused(r *http.Request, name string) error {
settings, err := s.getSettings(r.Context())
if err != nil {
return err
}
id := customFontID + name
for _, ref := range []*string{settings.Display.FontTitle, settings.Display.FontBody} {
if ref != nil && *ref == id {
return conflict("字体 " + name + " 正在被大屏使用,请先在赛事信息里改掉标题/正文字体")
}
}
return nil
}
// serveFont 匿名提供上传字体:大屏的 @font-face 不带会话,不能要求登录。
func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := fontContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
// 字体名是用户起的、可以同名重传,不能像随机名图片那样长缓存,否则换了字体现场看不出来。
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, file)
}
@@ -0,0 +1,71 @@
package httpapi
import "testing"
// 文件名收敛规则:取 Base、只保留 [A-Za-z0-9._-] 与汉字、其余换成下划线,
// 结果为空或只剩扩展名时拒绝。它同时守住落盘路径与 URL,不允许有例外。
func TestSafeFontName(t *testing.T) {
ok := map[string]string{
"my-font.ttf": "my-font.ttf",
"思源宋体.otf": "思源宋体.otf",
"Noto Sans SC.woff2": "Noto_Sans_SC.woff2",
`C:\Fonts\思源 宋体.TTF`: "思源_宋体.TTF",
"../../etc/passwd.ttf": "passwd.ttf",
"/abs/path/font.woff": "font.woff",
"字体 v2 (正式).woff2": "字体_v2__正式_.woff2",
"a/b/c/d/..name...ttf": "..name...ttf",
`sub\dir\zh-cn-bold.ttf`: "zh-cn-bold.ttf",
}
for raw, want := range ok {
got, valid := safeFontName(raw)
if !valid || got != want {
t.Errorf("safeFontName(%q) = (%q, %v),期望 (%q, true)", raw, got, valid, want)
}
}
// 只有扩展名、空名、非白名单扩展名一律拒绝。
for _, raw := range []string{"", " ", ".ttf", ".woff2", "字体.xlsx", "font", `C:\Fonts\`, "..", "../"} {
if got, valid := safeFontName(raw); valid {
t.Errorf("safeFontName(%q) 应拒绝,得到 %q", raw, got)
}
}
}
// 静态访问只认「上传时收敛过的名字」,避免用别名读到别人的文件或目录。
func TestFontContentType(t *testing.T) {
want := map[string]string{
"a.ttf": "font/ttf",
"a.OTF": "font/otf",
"a.woff": "font/woff",
"a.woff2": "font/woff2",
"思源宋体.ttf": "font/ttf",
}
for name, ctype := range want {
got, ok := fontContentType(name)
if !ok || got != ctype {
t.Errorf("fontContentType(%q) = (%q, %v),期望 %q", name, got, ok, ctype)
}
}
for _, name := range []string{"", "a.png", "..", "../a.ttf", "sub/a.ttf", `sub\a.ttf`, "a b.ttf", "没有扩展名"} {
if _, ok := fontContentType(name); ok {
t.Errorf("fontContentType(%q) 应拒绝", name)
}
}
}
// 字体 id 的解析与生成必须对称,否则删除接口会找不到文件。
func TestCustomFontName(t *testing.T) {
name, ok := customFontName("custom:思源宋体.ttf")
if !ok || name != "思源宋体.ttf" {
t.Fatalf("customFontName = (%q, %v)", name, ok)
}
for _, id := range []string{"builtin:noto-sans", "custom:", "custom:../x.ttf", "custom:x.png", "思源宋体.ttf"} {
if _, ok := customFontName(id); ok {
t.Errorf("customFontName(%q) 应拒绝", id)
}
}
if !isBuiltinFontID("builtin:misans") || isBuiltinFontID("builtin:nope") {
t.Error("builtin 字体 id 白名单不正确")
}
}
+419
View File
@@ -0,0 +1,419 @@
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
}
+60 -1
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"bytes"
"context"
"crypto/rand"
"encoding/csv"
@@ -14,8 +15,12 @@ import (
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
"github.com/xuri/excelize/v2"
"golang.org/x/text/encoding/simplifiedchinese"
textunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
@@ -658,7 +663,11 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
}
return rows, nil
default:
reader := csv.NewReader(file)
text, err := decodeTableText(file)
if err != nil {
return nil, err
}
reader := csv.NewReader(strings.NewReader(text))
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
@@ -669,6 +678,56 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
}
}
// 报名表文本可能带的字节序标记。
var (
tableUTF8BOM = []byte{0xEF, 0xBB, 0xBF}
tableUTF16LEBOM = []byte{0xFF, 0xFE}
tableUTF16BEBOM = []byte{0xFE, 0xFF}
)
// decodeTableText 把报名表的字节流解码成 UTF-8 文本。
//
// 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上
// 「另存为 CSV」出的是 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的 Export-Csv 出 UTF-16LE。
// 这三种原样丢给 csv.Reader 都会炸,而且症状很误导——
// 中文单位名、运动员名变乱码;BOM 会粘在第一列表头上("\ufeffevent_code"),
// 于是报「报名表缺少列 event_code」,让人以为表头写错了。
func decodeTableText(file io.Reader) (string, error) {
// 上游 readImportForm 已用 MaxBytesReader 限住 body,整读不会失控。
data, err := io.ReadAll(file)
if err != nil {
return "", badRequest("读取报名表失败", err.Error())
}
switch {
case bytes.HasPrefix(data, tableUTF8BOM):
data = data[len(tableUTF8BOM):]
case bytes.HasPrefix(data, tableUTF16LEBOM):
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian)
case bytes.HasPrefix(data, tableUTF16BEBOM):
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian)
}
if utf8.Valid(data) {
return string(data), nil
}
// GBK 是 GB18030 的子集,按 GB18030 解能一并覆盖。
text, _, err := transform.String(simplifiedchinese.GB18030.NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。
func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) {
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// importColumns 是报名表必须包含的表头(大小写不敏感);
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
var importColumns = []string{
@@ -0,0 +1,94 @@
package httpapi_test
import (
"net/http"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
textunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// 报名表是现场从 Excel 存出来的,绝大多数不是 UTF-8:
// 中文 Windows 上「另存为 CSV」出 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的
// Export-Csv 出 UTF-16LE。这三种都要能正常导入,否则现场看到的是乱码,
// 或者更误导的「报名表缺少列 event_code」。
// importBody 是一行合法的个人报名(11 列,与 ImportSection 模板一致)。
const importBody = "M100,男子,A,张三,110101199001011234,男,,,,registered,\n"
// athleteName 取库中该证件号对应的人员姓名,用来确认中文没有变成乱码。
func athleteName(t *testing.T, env *testEnv, idCard string) string {
t.Helper()
var name string
if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id_card = ?`, idCard).Scan(&name); err != nil {
t.Fatalf("查询人员 %s: %v", idCard, err)
}
return name
}
// assertImportedAsZhangSan 断言这次导入真的落库了「张三」。
func assertImportedAsZhangSan(t *testing.T, env *testEnv, status int, env2 envelope) {
t.Helper()
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) {
t.Fatalf("应新建 1 条报名: %+v", result)
}
if name := athleteName(t, env, "110101199001011234"); name != "张三" {
t.Fatalf("姓名应为「张三」,得到 %q", name)
}
}
// 模板自带 BOM,用户「下载模板 → 填 → 原样传回来」必须走得通:
// BOM 不能粘在第一列表头上("\ufeffevent_code")导致误报缺列。
func TestImportAcceptsUTF8BOM(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := "\uFEFF" + importHeader + "\n" + importBody
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// Excel 默认另存出来的 GB18030。
func TestImportDecodesGB18030(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := encodeGB18030(t, importHeader+"\n"+importBody)
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// PowerShell 的 Export-Csv 默认出 UTF-16LE(带 BOM)。
func TestImportDecodesUTF16LE(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := encodeUTF16LE(t, importHeader+"\n"+importBody)
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
func encodeGB18030(t *testing.T, text string) string {
t.Helper()
out, _, err := transform.String(simplifiedchinese.GB18030.NewEncoder(), text)
if err != nil {
t.Fatalf("编码 GB18030: %v", err)
}
return out
}
func encodeUTF16LE(t *testing.T, text string) string {
t.Helper()
// UseBOM 让编码器写出 FF FE,解码侧才有 BOM 可认。
out, _, err := transform.String(textunicode.UTF16(textunicode.LittleEndian, textunicode.UseBOM).NewEncoder(), text)
if err != nil {
t.Fatalf("编码 UTF-16LE: %v", err)
}
return out
}
+3
View File
@@ -91,6 +91,9 @@ type settingsDisplayPatch struct {
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSeconds *float64 `json:"rotateSeconds"`
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
}
type settingsStandingsPatch struct {
+18
View File
@@ -129,6 +129,24 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err
}
}
// 字体 id 必须在写入时就校验:引用一个不存在的字体,大屏只会静默回落成黑体,
// 现场看到的「配置没生效」比直接报错难排查得多。空串表示用默认字体。
if p.FontTitle != nil {
if err := s.checkDisplayFont("display.fontTitle", *p.FontTitle); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_title", strings.TrimSpace(*p.FontTitle)); err != nil {
return dto.EventSettings{}, err
}
}
if p.FontBody != nil {
if err := s.checkDisplayFont("display.fontBody", *p.FontBody); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_body", strings.TrimSpace(*p.FontBody)); err != nil {
return dto.EventSettings{}, err
}
}
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
+8
View File
@@ -255,6 +255,14 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
mux.HandleFunc("GET /api/fonts", s.read(auth.RoleRecorder, s.listFontsHandler))
mux.HandleFunc("POST /api/fonts", s.write(auth.RoleAdmin, s.uploadFontHandler))
mux.HandleFunc("DELETE /api/fonts/{name}", s.writeVoid(auth.RoleAdmin, s.deleteFontHandler))
mux.HandleFunc("GET /uploads/fonts/{name}", s.serveFont)
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
+1 -1
View File
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
assertKeys(t, "settings.event", asMap(t, settings["event"]),
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce"})
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody"})
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})