diff --git a/VERSION b/VERSION index bfaac8c..1064c23 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.3 +dev_0.1.4 diff --git a/server/internal/httpapi/error_surface_test.go b/server/internal/httpapi/error_surface_test.go index 23c6cdc..d4c5037 100644 --- a/server/internal/httpapi/error_surface_test.go +++ b/server/internal/httpapi/error_surface_test.go @@ -12,10 +12,11 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) -// 5xx 的原始错误串(SQLite / 计分引擎的报错)只能进日志,不能进响应体: -// 里面有表名、列名甚至数据片段,前端不需要、也不该拿到。 -// 排查能力不能因此丢——ERROR 行必须带上那句原文。 -func TestServerErrorKeepsRawReasonOutOfResponse(t *testing.T) { +// 5xx 的原始错误串要**同时**到两处,缺一个现场都会变瞎: +// - 响应体 detail —— 管理端 `describeError()` 把它拼在提示上,录人员常在局域网平板上 +// 操作,够不到服务端控制台的日志面板; +// - ERROR 日志 —— 此前只有响应里有原文、日志里只有笼统 message,服务端自己没留痕。 +func TestServerErrorCarriesRawReasonToClientAndLog(t *testing.T) { buf := &bytes.Buffer{} logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) env := newTestEnvWith(t, func(o *httpapi.Options) { @@ -34,9 +35,6 @@ func TestServerErrorKeepsRawReasonOutOfResponse(t *testing.T) { t.Fatalf("未知大屏指令应 500,得到 %d %s", status, body) } - if strings.Contains(string(body), "未知的大屏指令") { - t.Fatalf("响应体不该回显内部错误原文,得到 %s", body) - } var payload struct { OK bool `json:"ok"` Error struct { @@ -49,10 +47,10 @@ func TestServerErrorKeepsRawReasonOutOfResponse(t *testing.T) { t.Fatalf("解析响应体: %v (%s)", err, body) } if payload.Error.Code != "INTERNAL" || payload.Error.Message != "服务端异常" { - t.Fatalf("5xx 应回笼统文案,得到 %+v", payload.Error) + t.Fatalf("5xx 的 code 与笼统文案要保持,得到 %+v", payload.Error) } - if len(payload.Error.Detail) != 0 { - t.Fatalf("5xx 的 detail 应为空,得到 %s", payload.Error.Detail) + if !strings.Contains(string(payload.Error.Detail), "未知的大屏指令") { + t.Fatalf("响应体 detail 应带上原始错误串(现场要能直接看懂为什么失败),得到 %s", payload.Error.Detail) } // 原文必须能在日志里找到,否则现场就真的查不下去了。 diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 1cb960e..587a78e 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -16,9 +16,10 @@ type apiError struct { code string message string detail any - // logDetail 是「只写日志、不进响应体」的错误原文,只有 5xx 用: - // SQLite / 计分引擎的原始错误串里可能带表名列名甚至数据片段, - // 现场排查需要它,前端不需要、也不该拿到。 + // logDetail 是给日志用的错误原文(5xx)。它和 detail 是**两件独立的事**: + // detail 控制「回不回前端」,logDetail 控制「日志里记什么」,5xx 目前两者都设。 + // 分开是为了留住选择权——哪天要收口响应体,把 detail 设成 nil 即可, + // 日志侧的排查线索不会跟着一起丢。 logDetail string // origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如 // internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。 @@ -100,16 +101,25 @@ func wrongCredential(message string) *apiError { return newAPIError(http.StatusBadRequest, "AUTH_FAILED", message, nil) } -// internal 是 500:给现场看的只有 message,原始错误串走 logDetail 只进日志。 +// 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