## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
335 lines
12 KiB
Go
335 lines
12 KiB
Go
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)
|
||
}
|
||
}
|