Files
Scoreboard/server/internal/httpapi/handlers_system.go
T
AskaEth e5e0944d26 feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

70 lines
2.7 KiB
Go

package httpapi
import (
"crypto/subtle"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。
const shutdownTokenHeader = "X-Shutdown-Token"
// systemRoutes 挂载系统级只读信息与优雅关闭握手。
func (s *server) systemRoutes(mux *http.ServeMux) {
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
// 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。
mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler))
}
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
return map[string]any{
"internalSignature": version.InternalSignature,
// 构建时间同样是编译期注入的,未注入时为空串,由前端显示「未注入」。
"buildTime": version.BuildTime,
}, nil
}
// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。
func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
method, ok := s.shutdownAuth(r)
if !ok {
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
return nil, forbidden("无权关闭服务")
}
if s.shutdown == nil {
return nil, serviceUnavailable("INTERNAL", "服务端未接入优雅关闭")
}
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
// 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。
go s.shutdown()
return nil, errHandled
}
// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。
// 环境变量为空时只认 admin 会话。
func (s *server) shutdownAuth(r *http.Request) (string, bool) {
if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return "session", true
}
expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN"))
if expected == "" {
return "", false
}
got := r.Header.Get(shutdownTokenHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
return "", false
}
return "token", true
}