Files
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:

导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向

导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)

错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)

大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
  字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
  scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)

文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)

自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
2026-09-11 15:35:42 +08:00

96 lines
4.1 KiB
Go

package httpapi
import (
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件把「拦截点」(哪一行、哪个函数拦下的)记进拦截日志。
// 主动拦截的报错必须自带拦截点:默认级别是 info,现场只看得见 WARN 那一行,
// 只给一句中文提示的话,排查时仍然不知道是哪段校验拦的。
// 捕获实现放在 internal/logging,与 middleware 共用一份,避免两边各写一套。
// callerOrigin 捕获调用点;skip 从 callerOrigin 自己算起(见 newAPIError 的说明),
// 这里 +1 是把自己这一层算进 logging.Origin 的帧数。
func callerOrigin(skip int) string {
return logging.Origin(skip + 1)
}
// originFromStack 兜底:错误不是经构造函数造的(例如把底层错误原样返回出来)时,
// 沿栈找到第一个不在 response.go / 本文件里的帧,就是返回这个错误的那段业务代码。
func originFromStack(skip int) string {
pcs := make([]uintptr, 16)
n := runtime.Callers(skip, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if frame.File != "" && !strings.HasSuffix(frame.File, "response.go") &&
!strings.HasSuffix(frame.File, "origin.go") && !strings.Contains(frame.Function, "/internal/logging.") {
return logging.FormatOrigin(frame.File, frame.Line, frame.Function)
}
if !more {
return "unknown"
}
}
}
// interceptRow 生成给前端的行级错误文案,并顺手留一条 DEBUG 拦截日志(拦进 interceptions/)。
// 导入的行级错误原本只进响应体,服务端不留痕的话,现场「我明明填对了它却说格式不对」没法复盘。
func (s *server) interceptRow(row int, format string, args ...any) string {
reason := fmt.Sprintf(format, args...)
s.logger.Debug("导入行被拦截",
"row", row,
"reason", reason,
"origin", callerOrigin(2),
logging.ChannelKey, logging.ChannelInterception,
)
return fmt.Sprintf("第 %d 行:%s", row, reason)
}
// logIntercepted 记录一次被拦截的请求。
// WARN 是默认级别(info)下现场唯一看得见的一行,所以「为什么被拦」与「拦截点」都放在它身上;
// DEBUG 只补上下文(code/detail/ip/角色),不能只靠它。带类别标记的行会进 interceptions/。
// 这里绝不整段记请求体:里面有证件号这类个人信息,`detail` 与响应同源,属既有泄露面。
func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
u := middleware.User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := middleware.GetRequestID(r.Context())
if api.status >= http.StatusInternalServerError {
// 5xx 的原始错误串(SQLite / 引擎)在这里留痕,现场看日志能定位;
// 响应体里也有一份(见 response.go 的 internalMsg),前端不需要翻服务端日志。
// role / ip 与下面 4xx 的 WARN 同口径:按日志查「谁干的」时,5xx 不能缺一半线索。
logger.Error("请求失败",
"code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "ip", middleware.ClientIP(r), "requestId", reqID)
return
}
logger.Warn("请求被拦截",
"reason", api.message, "origin", api.origin, "code", api.code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", api.code, "detail", api.detail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "ip", middleware.ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}