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, // 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。 // // 与 decodeJSONPresence 用同一把尺卡住请求体大小(maxJSONBodyBytes): // 走这条更通用的路径的写接口有三十多个,此前一个上限都没有, // 一次请求就能把几百 MB 读进内存(单进程服务,内存放大即整站不可用)。 func decodeJSON(r *http.Request, dst any) error { r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes) dec := json.NewDecoder(r.Body) if err := dec.Decode(dst); err != nil { // 超限要报 413 而不是 400「不是合法 JSON」:前端据此提示「请求体过大」, // 而不是让现场去查一个其实没写错的 JSON。 var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { return tooLarge("请求体超过上限") } return badRequest("请求体不是合法 JSON", err.Error()) } return nil } // maxJSONBodyBytes 是普通 JSON 接口的请求体上限。 // 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限; // 配合 decodeJSONPresence 的「整读一次」,必须补一个(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.ErrPointsOverrideUnsupported): return badRequest("pointsOverride 已不再支持,请改用「积分调整」为该单位/运动员加减分", 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) } }