- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"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
|
|
}
|
|
|
|
func (e *apiError) Error() string { return e.code + ": " + e.message }
|
|
|
|
func badRequest(message string, detail any) *apiError {
|
|
return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail}
|
|
}
|
|
|
|
func notFound(message string) *apiError {
|
|
return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message}
|
|
}
|
|
|
|
func conflict(message string) *apiError {
|
|
return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message}
|
|
}
|
|
|
|
func stateInvalid(message string) *apiError {
|
|
return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message}
|
|
}
|
|
|
|
func internal(err error) *apiError {
|
|
return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()}
|
|
}
|
|
|
|
func notImplemented(message string) *apiError {
|
|
return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
writeAPIError(w, s.logger, 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 {
|
|
writeAPIError(w, s.logger, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
|
|
}
|
|
}
|
|
|
|
func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) {
|
|
var api *apiError
|
|
if !errors.As(err, &api) {
|
|
api = internal(err)
|
|
}
|
|
if api.status >= http.StatusInternalServerError {
|
|
logger.Error("请求失败", "code", api.code, "err", api.message)
|
|
}
|
|
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)
|
|
}
|
|
}
|