Files
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

335 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"encoding/json"
"errors"
"io"
"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
// logDetail 是给日志用的错误原文(5xx)。它和 detail 是**两件独立的事**:
// detail 控制「回不回前端」,logDetail 控制「日志里记什么」,5xx 目前两者都设。
// 分开是为了留住选择权——哪天要收口响应体,把 detail 设成 nil 即可,
// 日志侧的排查线索不会跟着一起丢。
logDetail string
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip 记录的是「runtime 栈里从 Origin 数到真正做校验那段业务代码的层数」,
// 调用链是:业务代码 → 具体构造函数(badRequest 等)→ newAPIError → newAPIErrorSkip
// → callerOrigin → logging.Origin,所以这里从 4 起算。
//
// 往这条链上加封装时**必须同步加一**,否则拦截点会退化成 response.go 里的 helper,
// 现场拿着它找不到任何校验代码(4xx 那串拦截点有测试钉着:TestInterceptLogPointsAtValidator)。
func newAPIError(status int, code, message string, detail any) *apiError {
return newAPIErrorSkip(4, status, code, message, detail)
}
// newAPIErrorSkip 是带显式层数的构造器,供链路层数不同的错误用(目前是 internal / internalMsg,
// 它们比 badRequest 那串多一层:业务代码 → internal → internalMsg → 这里)。
func newAPIErrorSkip(skip int, status int, code, message string, detail any) *apiError {
return &apiError{
status: status,
code: code,
message: message,
detail: detail,
origin: callerOrigin(skip),
}
}
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)
}
// wrongCredential 是 400:已登录用户做敏感操作时提供的凭据不对(目前只有「修改密码时原密码错」)。
//
// 不能用 401 —— 401 的含义是「这个请求没有有效会话」,而本请求的会话是好的;
// 前端按 401 的既有约定(AUTH_REQUIRED)会把人踢下线,填错一次原密码就被登出,现场不可接受。
// code 仍沿用 AUTH_FAILED,前端的错误码联合类型与文案都不需要改。
func wrongCredential(message string) *apiError {
return newAPIError(http.StatusBadRequest, "AUTH_FAILED", message, nil)
}
// 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
}
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,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
//
// 与 decodeJSONPresence 用同一把尺卡住请求体大小(maxJSONBodyBytes):
// 走这条更通用的路径的写接口有三十多个,此前一个上限都没有,
// 一次请求就能把几百 MB 读进内存(单进程服务,内存放大即整站不可用)。
func decodeJSON(r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
// 超限要报 413 而不是 400「不是合法 JSON」:前端据此提示「请求体过大」,
// 而不是让现场去查一个其实没写错的 JSON。
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return tooLarge("请求体超过上限")
}
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
}
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
// 配合 decodeJSONPresence 的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
const maxJSONBodyBytes = 8 << 20
// keySet 是「请求体里真的出现过哪些键」。
type keySet map[string]json.RawMessage
func (k keySet) has(key string) bool {
_, ok := k[key]
return ok
}
// decodeJSONPresence 与 decodeJSON 一样解析请求体,另外回报「哪些键出现过」。
//
// 存在的理由:区分「字段没传」与「显式传了零值」。Go 的 bool 零值是 false,
// 建单位 / 大项时漏传 active 会被静默写成「停用」,而停用的单位在界面上等于不存在 ——
// 少写一个字段就变成另一种东西的接口,只能靠「键是否存在」判断调用方意图。
func decodeJSONPresence(r *http.Request, dst any) (keySet, error) {
raw, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
if err != nil {
return nil, badRequest("读取请求体失败", err.Error())
}
if len(raw) > maxJSONBodyBytes {
return nil, tooLarge("请求体超过上限")
}
if err := json.Unmarshal(raw, dst); err != nil {
return nil, badRequest("请求体不是合法 JSON", err.Error())
}
var keys keySet
if err := json.Unmarshal(raw, &keys); err != nil {
// 顶层不是对象(例如是数组)时没有「键」的概念,交给 dst 的解析结果去报错。
return keySet{}, nil
}
return keys, 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.ErrPointsOverrideUnsupported):
return badRequest("pointsOverride 已不再支持,请改用「积分调整」为该单位/运动员加减分", 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)
}
}