From a52e6a4a3b25cf9061be563b9e785c0a67f10d4a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 14:46:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=205xx=20=E7=9A=84=20detail=20=E7=95=99?= =?UTF-8?q?=E5=9C=A8=E5=93=8D=E5=BA=94=E9=87=8C=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=A1=A5=E6=97=A5=E5=BF=97=E4=BE=A7=E7=95=99=E7=97=95=EF=BC=88?= =?UTF-8?q?=E8=87=AA=E6=88=91=E7=BA=A0=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前一个提交按 §4.2 的旧判据「internal(err) 把原始错误串写进响应 detail(应仅日志)」 把 5xx 的 detail 去掉了。这是改错了方向,本提交翻回来: - 管理端 describeError()(systemApi.ts:85)是故意把 error.detail 拼进提示的, EventInfoPanel / UsersPanel / FontUploadField / ImageUploadField 等 5 处都在用。 录人员常常在局域网平板上操作,够不到服务端控制台的日志面板——去掉 detail 等于把失败原因藏在他拿不到的地方。 - 旧判据还漏了:原来真正的缺口不是「响应里有原文」,而是「日志里没有原文」。 正确修法是补日志,不是砍响应。 - 现口径:两边都要。apiError.detail 与 apiError.logDetail 是两个独立字段 (一个管回不回前端、一个管日志记什么),5xx 目前两者都设;要收口时只把 detail 置 nil 即可,日志线索不受影响。 - 测试 TestServerErrorKeepsRawReasonOutOfResponse 改名并反转断言为 TestServerErrorCarriesRawReasonToClientAndLog:同时断言响应 detail 带原文、 ERROR 日志也带原文。 版本号 dev_0.1.3 → dev_0.1.4(0.1.2 / 0.1.3 均未交付,debug/ 只留 0.1.4)。 --- VERSION | 2 +- server/internal/httpapi/error_surface_test.go | 18 ++++++++---------- server/internal/httpapi/response.go | 18 ++++++++++++++---- 3 files changed, 23 insertions(+), 15 deletions(-) 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