## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
operations/ 放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
interceptions/ 拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。
## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。
修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。
测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
245 lines
7.5 KiB
Go
245 lines
7.5 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"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
|
||
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
|
||
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
|
||
origin string
|
||
}
|
||
|
||
func (e *apiError) Error() string { return e.code + ": " + e.message }
|
||
|
||
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
|
||
// skip=3 是 callerOrigin → newAPIError → 具体构造函数 → 真正做校验的调用点,
|
||
// 少一层就会把 helper 自己记成拦截点,那样日志等于没说。
|
||
func newAPIError(status int, code, message string, detail any) *apiError {
|
||
return &apiError{
|
||
status: status,
|
||
code: code,
|
||
message: message,
|
||
detail: detail,
|
||
origin: callerOrigin(3),
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func internal(err error) *apiError {
|
||
return newAPIError(http.StatusInternalServerError, "INTERNAL", "服务端异常", err.Error())
|
||
}
|
||
|
||
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
|
||
func internalMsg(message string, err error) *apiError {
|
||
return newAPIError(http.StatusInternalServerError, "INTERNAL", message, err.Error())
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|