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:
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
|
||||
|
||||
@@ -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 白名单不正确")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("规则导入导出"))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
Reference in new issue
Block a user