feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user