Files
Scoreboard/server/internal/httpapi/response.go
T
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

322 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// apiError 是可直接映射为统一响应结构的业务错误(见 devdocs/05 第 8 节错误码)。
type apiError struct {
status int
code string
message string
detail any
// logDetail 是给日志用的错误原文(5xx)。它和 detail 是**两件独立的事**:
// detail 控制「回不回前端」,logDetail 控制「日志里记什么」,5xx 目前两者都设。
// 分开是为了留住选择权——哪天要收口响应体,把 detail 设成 nil 即可,
// 日志侧的排查线索不会跟着一起丢。
logDetail string
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip 记录的是「runtime 栈里从 Origin 数到真正做校验那段业务代码的层数」,
// 调用链是:业务代码 → 具体构造函数(badRequest 等)→ newAPIError → newAPIErrorSkip
// → callerOrigin → logging.Origin,所以这里从 4 起算。
//
// 往这条链上加封装时**必须同步加一**,否则拦截点会退化成 response.go 里的 helper,
// 现场拿着它找不到任何校验代码(4xx 那串拦截点有测试钉着:TestInterceptLogPointsAtValidator)。
func newAPIError(status int, code, message string, detail any) *apiError {
return newAPIErrorSkip(4, status, code, message, detail)
}
// newAPIErrorSkip 是带显式层数的构造器,供链路层数不同的错误用(目前是 internal / internalMsg,
// 它们比 badRequest 那串多一层:业务代码 → internal → internalMsg → 这里)。
func newAPIErrorSkip(skip int, status int, code, message string, detail any) *apiError {
return &apiError{
status: status,
code: code,
message: message,
detail: detail,
origin: callerOrigin(skip),
}
}
func badRequest(message string, detail any) *apiError {
return newAPIError(http.StatusBadRequest, "VALIDATION_FAILED", message, detail)
}
func notFound(message string) *apiError {
return newAPIError(http.StatusNotFound, "VALIDATION_FAILED", message, nil)
}
func conflict(message string) *apiError {
return newAPIError(http.StatusConflict, "CONFLICT", message, nil)
}
func stateInvalid(message string) *apiError {
return newAPIError(http.StatusConflict, "STATE_INVALID", message, nil)
}
// unauthorized 是 401:code 区分「没登录」与「凭据不对」。
func unauthorized(code, message string) *apiError {
return newAPIError(http.StatusUnauthorized, code, message, nil)
}
// forbidden 是 403:角色不足或来源校验不过。
func forbidden(message string) *apiError {
return newAPIError(http.StatusForbidden, "FORBIDDEN", message, nil)
}
// rateLimited 是 429:写操作/登录太频繁,稍后可重试。
func rateLimited(message string) *apiError {
return newAPIError(http.StatusTooManyRequests, "RATE_LIMITED", message, nil)
}
// tooLarge 是 413:正文超过上限,与「格式不对」区分开,前端才知道该怎么处理。
func tooLarge(message string) *apiError {
return newAPIError(http.StatusRequestEntityTooLarge, "VALIDATION_FAILED", message, nil)
}
// serviceUnavailable 是 503:依赖未接入或目录未配置,配置好即可用,重试无用。
func serviceUnavailable(code, message string) *apiError {
return newAPIError(http.StatusServiceUnavailable, code, message, nil)
}
// wrongCredential 是 400:已登录用户做敏感操作时提供的凭据不对(目前只有「修改密码时原密码错」)。
//
// 不能用 401 —— 401 的含义是「这个请求没有有效会话」,而本请求的会话是好的;
// 前端按 401 的既有约定(AUTH_REQUIRED)会把人踢下线,填错一次原密码就被登出,现场不可接受。
// code 仍沿用 AUTH_FAILED,前端的错误码联合类型与文案都不需要改。
func wrongCredential(message string) *apiError {
return newAPIError(http.StatusBadRequest, "AUTH_FAILED", message, nil)
}
// internal 是 500:message 是给人看的笼统文案,原始错误串由 internalMsg 同时回前端与写日志。
func internal(err error) *apiError {
return internalMsg("服务端异常", err)
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
// skip=4:业务代码 → internal/internalMsg → newAPIErrorSkip → callerOrigin,比 newAPIError 多一层。
//
// 原始错误串**同时**回给前端(detail)并写进日志(logDetail),两边都要:
// - 前端:管理端 `describeError()` 会把 detail 拼在提示里,录人员常常在局域网平板上
// 操作,看不到服务端控制台的日志面板,只给一句「服务端异常」等于把原因藏在他够不着的地方;
// - 日志:此前 5xx 只把原文回了响应、日志里只有笼统 message,服务端自己反而没留痕。
//
// 本系统完全离线内网、账号可控,用「表名列名可见」换现场可诊断性是划算的;
// 真正要防的是请求体里的证件号,那部分仍然不记(见 origin.go 的说明)。
func internalMsg(message string, err error) *apiError {
api := newAPIErrorSkip(4, http.StatusInternalServerError, "INTERNAL", message, nil)
if err != nil {
api.detail = err.Error()
api.logDetail = err.Error()
}
return api
}
func notImplemented(message string) *apiError {
return newAPIError(http.StatusNotImplemented, "INTERNAL", message, nil)
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
type handler func(w http.ResponseWriter, r *http.Request) (any, error)
// handle 统一把业务结果/错误写成 REST 响应。
func (s *server) handle(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := fn(w, r)
if err != nil {
if errors.Is(err, errHandled) {
return
}
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}
}
// handleVoid 用于无返回体的写操作。
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
// writeAPIError 把业务错误写成统一响应,并按级别记日志:
// 4xx 是「我们主动拦下的」,WARN 给出原因与拦截点(默认 info 级别下必须可见),DEBUG 补上下文;
// 5xx 是服务端自身异常,用 ERROR 并带上拦截点。
func (s *server) writeAPIError(w http.ResponseWriter, r *http.Request, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
// 兜底:错误没经构造函数(拿不到 origin)时,沿栈找到返回它的那段业务代码。
if api.origin == "" {
api.origin = originFromStack(3)
}
logIntercepted(r, s.logger, api)
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": api.code,
"message": api.message,
},
}
if api.detail != nil {
body["error"].(map[string]any)["detail"] = api.detail
}
writeJSON(w, api.status, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
}
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
const maxJSONBodyBytes = 8 << 20
// keySet 是「请求体里真的出现过哪些键」。
type keySet map[string]json.RawMessage
func (k keySet) has(key string) bool {
_, ok := k[key]
return ok
}
// decodeJSONPresence 与 decodeJSON 一样解析请求体,另外回报「哪些键出现过」。
//
// 存在的理由:区分「字段没传」与「显式传了零值」。Go 的 bool 零值是 false,
// 建单位 / 大项时漏传 active 会被静默写成「停用」,而停用的单位在界面上等于不存在 ——
// 少写一个字段就变成另一种东西的接口,只能靠「键是否存在」判断调用方意图。
func decodeJSONPresence(r *http.Request, dst any) (keySet, error) {
raw, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
if err != nil {
return nil, badRequest("读取请求体失败", err.Error())
}
if len(raw) > maxJSONBodyBytes {
return nil, tooLarge("请求体超过上限")
}
if err := json.Unmarshal(raw, dst); err != nil {
return nil, badRequest("请求体不是合法 JSON", err.Error())
}
var keys keySet
if err := json.Unmarshal(raw, &keys); err != nil {
// 顶层不是对象(例如是数组)时没有「键」的概念,交给 dst 的解析结果去报错。
return keySet{}, nil
}
return keys, nil
}
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
func pageQuery(r *http.Request) (page, size int) {
page = intQuery(r, "page", 1)
size = intQuery(r, "size", 50)
if page < 1 {
page = 1
}
switch {
case size < 1:
size = 50
case size > 500:
size = 500
}
return page, size
}
func intQuery(r *http.Request, key string, def int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
return n
}
func int64Query(r *http.Request, key string) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// pathID 解析路由通配符 {id}。
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return 0, badRequest("路径参数 "+name+" 非法", raw)
}
return n, nil
}
// pageResult 是分页响应体(见 devdocs/05 第 6 节)。
type pageResult[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// mapDomainError 把 domain 引擎的哨兵错误映射为 REST 错误码。
func mapDomainError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrManualAdjustmentDisabled),
errors.Is(err, domain.ErrAdjustmentReasonRequired),
errors.Is(err, domain.ErrCompareRequired),
errors.Is(err, domain.ErrUnknownBasis),
errors.Is(err, domain.ErrUnknownLedgerOp):
return stateInvalid(err.Error())
case errors.Is(err, domain.ErrProfileNotFound):
return badRequest("计分通道引用的积分模板不存在", err.Error())
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
return stateInvalid("该积分流水已作废,不能重复作废")
case errors.Is(err, domain.ErrLedgerNotFound):
return notFound("积分流水不存在")
default:
return internal(err)
}
}