Files
Scoreboard/server/internal/httpapi/fonts.go
T
AskaEth e5e0944d26 feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
  按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
  operations/     放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
  interceptions/  拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
                  DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
                  否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
  降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
  防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。

## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
  示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
  本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。

修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。

测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

315 lines
10 KiB
Go

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 "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法管理字体")
}
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 tooLarge("字体文件超过 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 {
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
// 字体名是用户起的、可以同名重传,不能像随机名图片那样长缓存,否则换了字体现场看不出来。
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, file)
}