Files
Scoreboard/server/internal/httpapi/rules_handlers.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

116 lines
3.0 KiB
Go

package httpapi
import (
"context"
"io"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// rulesRoutes 挂载规则包的导出与导入(导入支持 ?preview=1 只预览差异)。
func (s *server) rulesRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
}
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
pack, err := rules.Export(s.ev, rules.Meta{})
if err != nil {
return nil, internal(err)
}
body, err := rules.Marshal(pack)
if err != nil {
return nil, internal(err)
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="rules.yaml"`)
if _, err := w.Write(body); err != nil {
s.logger.Error("规则包写出失败", "err", err)
}
return nil, errHandled
}
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
return nil, badRequest("读取请求体失败", nil)
}
if len(body) == 0 {
return nil, badRequest("请求体为空", nil)
}
pack, err := rules.Parse(body)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
cur, err := rules.Load(s.ev)
if err != nil {
return nil, internal(err)
}
cs, err := rules.Diff(cur, pack)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
return cs, nil
}
res, err := rules.Apply(s.ev, cs)
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分必须全量重算。
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
return nil, internal(err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return res, nil
}
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
for _, eventID := range ids {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
_ = tx.Rollback()
return err
}
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}