- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
136 lines
4.0 KiB
Go
136 lines
4.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/dto"
|
|
"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))
|
|
// 录入员要判断破纪录加分/人工调整是否启用,但无权读 /api/settings,故单开只读开关。
|
|
mux.HandleFunc("GET /api/rules/switches", s.read(auth.RoleRecorder, s.ruleSwitches))
|
|
}
|
|
|
|
// ruleSwitches 只返回布尔开关,不下发规则内容、模板明细或文件路径。
|
|
func (s *server) ruleSwitches(w http.ResponseWriter, r *http.Request) (any, error) {
|
|
snap, err := rules.Load(s.ev)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
pack := snap.Pack
|
|
return dto.RuleSwitches{
|
|
RecordBonusEnabled: pack.RecordBonus.Enabled,
|
|
AdjustmentsEnabled: pack.ManualAdjustment.Enabled,
|
|
AdjustmentReasonRequired: pack.ManualAdjustment.ReasonRequired,
|
|
MedalCountsTeamEvents: pack.Standings.MedalCountsTeamEventsEnabled(),
|
|
ShowIndividualBoard: pack.Standings.ShowIndividualBoardEnabled(),
|
|
ShowIndividualMedals: pack.Standings.ShowIndividualMedalsEnabled(),
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|