- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
140 lines
3.7 KiB
Go
140 lines
3.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
|
)
|
|
|
|
// errHandled 表示响应已经由 handler 自己写出(例如幂等回放),s.handle 不再重复写。
|
|
var errHandled = errors.New("httpapi: response already written")
|
|
|
|
// idempotencyStore 缓存写操作的首次响应(见 devdocs/05 第 5 节:重复提交返回首次结果)。
|
|
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
|
|
// 进程重启后客户端重试会重新执行,属于可接受的降级。
|
|
type idempotencyStore struct {
|
|
mu sync.Mutex
|
|
entries map[string]idempotentResponse
|
|
ttl time.Duration
|
|
}
|
|
|
|
type idempotentResponse struct {
|
|
body []byte
|
|
expires time.Time
|
|
}
|
|
|
|
func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
|
|
if ttl <= 0 {
|
|
ttl = 10 * time.Minute
|
|
}
|
|
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
|
|
}
|
|
|
|
func (s *idempotencyStore) get(key string) ([]byte, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e, ok := s.entries[key]
|
|
if !ok || time.Now().After(e.expires) {
|
|
delete(s.entries, key)
|
|
return nil, false
|
|
}
|
|
return e.body, true
|
|
}
|
|
|
|
func (s *idempotencyStore) put(key string, body []byte) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := time.Now()
|
|
if len(s.entries) > 4096 {
|
|
for k, e := range s.entries {
|
|
if now.After(e.expires) {
|
|
delete(s.entries, k)
|
|
}
|
|
}
|
|
}
|
|
s.entries[key] = idempotentResponse{body: body, expires: now.Add(s.ttl)}
|
|
}
|
|
|
|
// idempotent 以 requestId 为键执行写操作;重复请求直接回放首次响应。
|
|
// 返回值恒为 errHandled 或真正的错误——调用方写成 `return nil, s.idempotent(...)`。
|
|
func (s *server) idempotent(w http.ResponseWriter, r *http.Request, requestID string, fn func() (any, error)) error {
|
|
if requestID == "" || s.idem == nil {
|
|
data, err := fn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
|
|
return errHandled
|
|
}
|
|
|
|
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
|
|
if body, ok := s.idem.get(key); ok {
|
|
writeCached(w, body)
|
|
return errHandled
|
|
}
|
|
|
|
data, err := fn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := json.Marshal(map[string]any{"ok": true, "data": data})
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
s.idem.put(key, body)
|
|
writeCached(w, body)
|
|
return errHandled
|
|
}
|
|
|
|
// cached 给所有写路由加一层基于 X-Request-Id 的幂等缓存:
|
|
// handler 自己写响应,这里负责在成功后留存响应体、重复请求时回放。
|
|
func (s *server) cached(inner http.Handler) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
requestID := r.Header.Get(middleware.HeaderRequestID)
|
|
if requestID == "" || s.idem == nil {
|
|
inner.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
|
|
if body, ok := s.idem.get(key); ok {
|
|
writeCached(w, body)
|
|
return
|
|
}
|
|
|
|
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
|
|
inner.ServeHTTP(cw, r)
|
|
if cw.status >= 200 && cw.status < 300 && cw.body.Len() > 0 {
|
|
s.idem.put(key, cw.body.Bytes())
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeCached(w http.ResponseWriter, body []byte) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("X-Idempotent-Replay", "1")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
type captureWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
body bytes.Buffer
|
|
}
|
|
|
|
func (w *captureWriter) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *captureWriter) Write(b []byte) (int, error) {
|
|
w.body.Write(b)
|
|
return w.ResponseWriter.Write(b)
|
|
}
|