Files
Scoreboard/server/internal/httpapi/idempotency.go
T
AskaEth bc8b1ee6c0 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
2026-09-09 19:02:24 +08:00

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)
}