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 节「服务端日志与拦截复盘」)。
This commit is contained in:
1 parent
8e707e1e9b
commit
e5e0944d26
32 files changed
+2017
-138
No files matched your search
@@ -41,9 +41,9 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
|
||||
switch {
|
||||
case err == auth.ErrRateLimited:
|
||||
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
|
||||
return nil, rateLimited("登录尝试过于频繁,请稍后再试")
|
||||
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
|
||||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
|
||||
return nil, unauthorized("AUTH_FAILED", err.Error())
|
||||
case err != nil:
|
||||
return nil, internal(err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ type changePasswordInput struct {
|
||||
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
u := middleware.User(r.Context())
|
||||
if u.Role == auth.RoleGuest {
|
||||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
|
||||
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
|
||||
}
|
||||
|
||||
var in changePasswordInput
|
||||
@@ -105,7 +105,7 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
|
||||
}
|
||||
// 会话被盗后不能直接改密,必须再验一次原密码
|
||||
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
|
||||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: "原密码不正确"}
|
||||
return nil, unauthorized("AUTH_FAILED", "原密码不正确")
|
||||
}
|
||||
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -168,8 +168,7 @@ func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
includeDeleted := isQueryTrue(r, "includeDeleted")
|
||||
// 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。
|
||||
if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
|
||||
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN",
|
||||
message: "只有管理员可以查看已彻底删除的单位"}
|
||||
return nil, forbidden("只有管理员可以查看已彻底删除的单位")
|
||||
}
|
||||
return s.listTeams(r.Context(), includeDeleted)
|
||||
}
|
||||
@@ -819,34 +818,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
athleteName := cell("athlete_name")
|
||||
if eventCode == "" || teamCode == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
|
||||
continue
|
||||
}
|
||||
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
|
||||
status, err := normalizeImportStatus(cell("status"))
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
|
||||
continue
|
||||
}
|
||||
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
var categoryID int64
|
||||
@@ -854,14 +853,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -874,19 +873,19 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
continue
|
||||
}
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
continue
|
||||
}
|
||||
if idCard != "" {
|
||||
if seenIDCards[idCard] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
@@ -894,7 +893,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
if bib != "" {
|
||||
if seenBibs[bib] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
seenBibs[bib] = true
|
||||
@@ -912,7 +911,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
@@ -937,7 +936,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
case created:
|
||||
result.Created++
|
||||
default:
|
||||
@@ -1015,8 +1014,7 @@ type uploadResult struct {
|
||||
func (s *server) uploadsDir() (string, error) {
|
||||
dir := strings.TrimSpace(s.projectDir)
|
||||
if dir == "" {
|
||||
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
|
||||
message: "当前未配置赛事项目目录,无法上传图片"}
|
||||
return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法上传图片")
|
||||
}
|
||||
return filepath.Join(dir, "uploads"), nil
|
||||
}
|
||||
@@ -1092,18 +1090,18 @@ func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
ctype, ok := uploadContentType(name)
|
||||
if !ok {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
s.writeAPIError(w, r, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
s.writeAPIError(w, r, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
|
||||
file := filepath.Join(dir, filepath.Base(name))
|
||||
if info, err := os.Stat(file); err != nil || info.IsDir() {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
s.writeAPIError(w, r, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
|
||||
Reference in new issue
Block a user