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)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||||
)
|
||||
|
||||
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
@@ -98,8 +100,23 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
name = raw
|
||||
}
|
||||
|
||||
opts := export.ProjectExportOptions{Name: name, AppVersion: s.version}
|
||||
if s.projectDir != "" {
|
||||
// uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。
|
||||
uploads := filepath.Join(s.projectDir, "uploads")
|
||||
if info, err := os.Stat(uploads); err == nil && info.IsDir() {
|
||||
opts.UploadsDir = uploads
|
||||
}
|
||||
}
|
||||
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
opts.RulesPath = rulesPath
|
||||
|
||||
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
|
||||
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{Name: name}); err != nil {
|
||||
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = os.Remove(dst) }()
|
||||
@@ -110,6 +127,33 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
return nil, errHandled
|
||||
}
|
||||
|
||||
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
|
||||
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
|
||||
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
|
||||
pack, err := rules.Export(s.ev, rules.Meta{})
|
||||
if err != nil {
|
||||
return "", func() {}, internal(err)
|
||||
}
|
||||
body, err := rules.Marshal(pack)
|
||||
if err != nil {
|
||||
return "", func() {}, internal(err)
|
||||
}
|
||||
f, err := os.CreateTemp("", "scoreboard-rules-*.yaml")
|
||||
if err != nil {
|
||||
return "", func() {}, internal(err)
|
||||
}
|
||||
if _, err := f.Write(body); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", func() {}, internal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return "", func() {}, internal(err)
|
||||
}
|
||||
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
|
||||
}
|
||||
|
||||
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
if err := r.ParseMultipartForm(512 << 20); err != nil {
|
||||
return nil, badRequest("解析上传失败", nil)
|
||||
@@ -146,6 +190,7 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error(), nil)
|
||||
}
|
||||
s.audit(r.Context(), "project.import", "project", 0, nil, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -77,19 +77,11 @@ func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
// ---------- 积分与排名 ----------
|
||||
|
||||
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
in, err := s.loadStandingsInput(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := s.teamMap(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
|
||||
return s.standingsSnapshot(r.Context(), s.ev)
|
||||
}
|
||||
|
||||
func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
in, err := s.loadStandingsInput(r.Context())
|
||||
in, err := s.loadStandingsInput(r.Context(), s.ev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// Realtime 是实时层的最小接口;未接入时写操作只落库,不推送。
|
||||
type Realtime interface {
|
||||
Broadcast(changes []realtime.Change)
|
||||
LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error)
|
||||
Instances() []realtime.Instance
|
||||
}
|
||||
|
||||
// broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。
|
||||
// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。
|
||||
func (s *server) broadcastChanges(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.realtime == nil || s.ev == nil || !isWriteMethod(r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
before, ok := s.changeVersion(r.Context())
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
if rec.status >= http.StatusBadRequest {
|
||||
return
|
||||
}
|
||||
|
||||
// 请求结束后 r.Context() 可能已被取消,推送不应因此中断。
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
changes, err := s.realtime.LoadChanges(ctx, before, 0)
|
||||
if err != nil {
|
||||
s.logger.Error("读取实时变更失败", "err", err)
|
||||
return
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
s.realtime.Broadcast(changes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。
|
||||
func (s *server) changeVersion(ctx context.Context) (int64, bool) {
|
||||
var v sql.NullInt64
|
||||
if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
|
||||
s.logger.Error("读取变更版本失败", "err", err)
|
||||
return 0, false
|
||||
}
|
||||
return v.Int64, true
|
||||
}
|
||||
|
||||
func isWriteMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder 记录响应状态码,用于判断写请求是否成功。
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
if !w.written {
|
||||
w.status = code
|
||||
w.written = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !w.written {
|
||||
w.written = true
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// displayInstancesHandler 返回在线大屏实例列表(在线看板)。
|
||||
func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
if s.realtime == nil {
|
||||
return []realtime.Instance{}, nil
|
||||
}
|
||||
list := s.realtime.Instances()
|
||||
if list == nil {
|
||||
list = []realtime.Instance{}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -153,15 +153,26 @@ func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
|
||||
}
|
||||
|
||||
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
|
||||
list, err := s.listTeams(ctx)
|
||||
return teamMapQ(ctx, s.ev)
|
||||
}
|
||||
|
||||
// teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。
|
||||
func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, internal(err)
|
||||
}
|
||||
out := make(map[int64]teamDTO, len(list))
|
||||
for _, t := range list {
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64]teamDTO)
|
||||
for rows.Next() {
|
||||
t, err := scanTeam(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[t.ID] = t
|
||||
}
|
||||
return out, nil
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
|
||||
|
||||
@@ -225,24 +225,24 @@ func boolDefault(v string, def bool) bool {
|
||||
|
||||
// ---------- standings ----------
|
||||
|
||||
func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) {
|
||||
teams, err := s.domainTeams(ctx)
|
||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||
teams, err := s.domainTeams(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
athletes, err := s.domainAthletes(ctx)
|
||||
athletes, err := s.domainAthletes(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
ledger, err := s.activeLedger(ctx)
|
||||
ledger, err := s.activeLedger(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
ranked, err := s.buildRanked(ctx)
|
||||
ranked, err := s.buildRanked(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
values, err := loadSettingsMap(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
@@ -259,8 +259,31 @@ func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
||||
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
||||
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) {
|
||||
in, err := s.loadStandingsInput(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := teamMapQ(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
|
||||
}
|
||||
|
||||
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
|
||||
func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error {
|
||||
snapshot, err := s.standingsSnapshot(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID)
|
||||
}
|
||||
|
||||
func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -284,8 +307,8 @@ func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
|
||||
rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
|
||||
func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
@@ -310,8 +333,8 @@ func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
|
||||
|
||||
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
|
||||
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。
|
||||
func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
@@ -495,9 +518,17 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var state string
|
||||
var eventID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil {
|
||||
var (
|
||||
state string
|
||||
eventID int64
|
||||
entryID int64
|
||||
level string
|
||||
resultVal sql.NullFloat64
|
||||
resultText sql.NullString
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
@@ -514,9 +545,23 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
// 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。
|
||||
if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET bonus_points = COALESCE((
|
||||
SELECT SUM(points) FROM score_ledger
|
||||
WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0)
|
||||
WHERE id = ?`, eventID, entryID, id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
@@ -524,6 +569,44 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
|
||||
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
|
||||
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
|
||||
var (
|
||||
teamID int64
|
||||
athleteID sql.NullInt64
|
||||
athleteName sql.NullString
|
||||
teamName sql.NullString
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT e.team_id, e.athlete_id, a.name, t.name
|
||||
FROM entries e
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound("报名项不存在")
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
holder := teamName.String
|
||||
if athleteID.Valid && athleteName.String != "" {
|
||||
holder = athleteName.String
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
|
||||
eventID, level); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`,
|
||||
eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- adjustments ----------
|
||||
|
||||
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
|
||||
@@ -614,6 +697,10 @@ func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userI
|
||||
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
// 调整分进积分榜,必须写版本号,否则大屏不会刷新。
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return adjustmentDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
@@ -669,7 +756,7 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st
|
||||
nullID(userID), reason, id); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return adjustmentDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
|
||||
@@ -83,7 +83,57 @@ func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if err := attachResultPoints(ctx, q, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachResultPoints 给已发布成绩补上得分(该成绩产生的 result 流水合计)。
|
||||
// 草稿没有流水,保持 nil;录入页据此预览发布后的分值变化。
|
||||
func attachResultPoints(ctx context.Context, q queryer, items []resultDTO) error {
|
||||
ids := make([]any, 0, len(items))
|
||||
index := make(map[int64]int, len(items))
|
||||
for i := range items {
|
||||
if items[i].State != "published" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, items[i].ID)
|
||||
index[items[i].ID] = i
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT result_id, SUM(points) FROM score_ledger
|
||||
WHERE voided = 0 AND source_type = 'result' AND result_id IN (`+placeholders(len(ids))+`)
|
||||
GROUP BY result_id`, ids...)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
resultID int64
|
||||
points float64
|
||||
)
|
||||
if err := rows.Scan(&resultID, &points); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if i, ok := index[resultID]; ok {
|
||||
v := points
|
||||
items[i].Points = &v
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
|
||||
@@ -94,7 +144,11 @@ func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
|
||||
if err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
return r, nil
|
||||
items := []resultDTO{r}
|
||||
if err := attachResultPoints(ctx, s.ev, items); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
|
||||
@@ -194,8 +248,8 @@ func joinAnd(parts []string) string {
|
||||
|
||||
// ---------- ledger ----------
|
||||
|
||||
func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) {
|
||||
return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
|
||||
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// ---------- 实时层替身 ----------
|
||||
|
||||
type fakeRealtime struct {
|
||||
db *sql.DB
|
||||
mu sync.Mutex
|
||||
batches [][]realtime.Change
|
||||
instances []realtime.Instance
|
||||
}
|
||||
|
||||
func (f *fakeRealtime) Broadcast(changes []realtime.Change) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.batches = append(f.batches, changes)
|
||||
}
|
||||
|
||||
func (f *fakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) {
|
||||
q := `SELECT id, entity_type, entity_id, action, payload_json FROM change_log WHERE id > ? ORDER BY id`
|
||||
args := []any{sinceVersion}
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := f.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]realtime.Change, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ch realtime.Change
|
||||
entity string
|
||||
entityID sql.NullInt64
|
||||
action string
|
||||
payload string
|
||||
)
|
||||
if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch.Entity = entity
|
||||
ch.ID = entityID.Int64
|
||||
ch.Action = action
|
||||
if json.Valid([]byte(payload)) {
|
||||
ch.Data = json.RawMessage(payload)
|
||||
} else {
|
||||
ch.Data = json.RawMessage(`{}`)
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (f *fakeRealtime) Instances() []realtime.Instance { return f.instances }
|
||||
|
||||
func (f *fakeRealtime) firstBatch(t *testing.T) []realtime.Change {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
f.mu.Lock()
|
||||
if len(f.batches) > 0 {
|
||||
batch := f.batches[0]
|
||||
f.mu.Unlock()
|
||||
return batch
|
||||
}
|
||||
f.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("等待实时广播超时")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *testEnv) doRaw(client *http.Client, method, path string, body []byte, contentType string, csrf bool) (int, []byte) {
|
||||
e.t.Helper()
|
||||
req, err := http.NewRequest(method, e.server.URL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
e.t.Fatalf("构造请求: %v", err)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if csrf {
|
||||
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
e.t.Fatalf("请求 %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// ---------- must_change_password ----------
|
||||
|
||||
// 必须改密的账号除改密/登出外一律禁止写操作,避免初始密码长期可用(devdocs/08 第 1 节)。
|
||||
func TestMustChangePasswordBlocksWrites(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
userID := env.createUser("recorder", "recorder-pass", "recorder")
|
||||
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
|
||||
t.Fatalf("设置 must_change_password: %v", err)
|
||||
}
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 读操作仍放行:前端要能渲染改密页。
|
||||
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
|
||||
t.Fatalf("改密前读 me 应 200,得到 %d", status)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
||||
"code": "X", "name": "越权单位", "active": true,
|
||||
}, true)
|
||||
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("改密前写操作应 403 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 改密本身必须放行,否则用户永远无法解除限制。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
|
||||
"currentPassword": "recorder-pass",
|
||||
"newPassword": "new-pass-123",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改密应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 改密后重新登录,写操作恢复。
|
||||
env.login(client, "recorder", "new-pass-123")
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
||||
"code": "X", "name": "越权单位", "active": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改密后写操作应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 登出也必须放行,否则改密失败的用户无法退出。
|
||||
func TestMustChangePasswordAllowsLogout(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
userID := env.createUser("recorder", "recorder-pass", "recorder")
|
||||
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
|
||||
t.Fatalf("设置 must_change_password: %v", err)
|
||||
}
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
|
||||
t.Fatalf("改密前登出应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- X-Forwarded-For 伪造 ----------
|
||||
|
||||
// 默认不信任代理头:伪造 XFF 不能绕过登录限流(否则等于没有限流)。
|
||||
func TestSpoofedForwardedForDoesNotBypassLoginLimit(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
"username": "recorder", "password": "wrong-pass",
|
||||
}, false, map[string]string{"X-Forwarded-For": fmt.Sprintf("203.0.113.%d", i+1)})
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("第 %d 次错误登录应 401,得到 %d %+v", i+1, status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
"username": "recorder", "password": "wrong-pass",
|
||||
}, false, map[string]string{"X-Forwarded-For": "203.0.113.250"})
|
||||
if status != http.StatusTooManyRequests || env2.Error == nil || env2.Error.Code != "RATE_LIMITED" {
|
||||
t.Fatalf("伪造 XFF 后第 6 次应被限流 429,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 流水幂等 ----------
|
||||
|
||||
// 重复发布不能累积破纪录加分流水,同时必须保留人工调整流水(devdocs/02 §6)。
|
||||
func TestPublishDoesNotAccumulateRecordBonusAndKeepsAdjustments(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "publish-1",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("首次发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 模拟已存在的破纪录加分流水与人工调整流水。
|
||||
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, source_type, source_id, points)
|
||||
VALUES (?, ?, ?, 'record_bonus', ?, 5)`, ids.teamA, ids.event, ids.entryA, ids.entryA)
|
||||
env.exec(t, `INSERT INTO score_ledger (team_id, source_type, source_id, points)
|
||||
VALUES (?, 'adjustment', 1, 3)`, ids.teamA)
|
||||
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "publish-2",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("再次发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var activeBonus, activeAdjust, activeResult int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
|
||||
WHERE voided = 0 AND source_type = 'record_bonus' AND event_id = ?`, ids.event).Scan(&activeBonus); err != nil {
|
||||
t.Fatalf("统计破纪录流水: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0 AND source_type = 'adjustment'`).Scan(&activeAdjust); err != nil {
|
||||
t.Fatalf("统计调整流水: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
|
||||
WHERE voided = 0 AND source_type = 'result' AND event_id = ?`, ids.event).Scan(&activeResult); err != nil {
|
||||
t.Fatalf("统计成绩流水: %v", err)
|
||||
}
|
||||
|
||||
if activeBonus != 0 {
|
||||
t.Fatalf("重算应作废旧破纪录加分流水且不重复累积,得到 %d 条", activeBonus)
|
||||
}
|
||||
if activeAdjust != 1 {
|
||||
t.Fatalf("重算不得作废人工调整,得到 %d 条", activeAdjust)
|
||||
}
|
||||
if activeResult != 2 {
|
||||
t.Fatalf("重算后成绩流水应仍为 2 条,得到 %d", activeResult)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- standings 变更载荷 ----------
|
||||
|
||||
// standings 的 replace 载荷必须是整张榜单数组(devdocs/05 第 2 节),否则大屏会被清空。
|
||||
func TestPublishChangeLogCarriesFullStandingsSnapshot(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "publish-1",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var payload string
|
||||
if err := env.ev.QueryRow(`SELECT payload_json FROM change_log
|
||||
WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
|
||||
t.Fatalf("读取 standings 变更: %v", err)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &rows); err != nil {
|
||||
t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("standings 载荷应含 2 个单位,得到 %d (%s)", len(rows), payload)
|
||||
}
|
||||
if rows[0]["teamCode"] != "A" || rows[0]["totalPoints"].(float64) != 10 {
|
||||
t.Fatalf("standings 载荷内容不对: %+v", rows[0])
|
||||
}
|
||||
|
||||
// 发布前必须留备份(08 第 6 节):成功时写 system.backup 审计。
|
||||
var backupAudits int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs
|
||||
WHERE action = 'system.backup' AND after_json LIKE '%publish%'`).Scan(&backupAudits); err != nil {
|
||||
t.Fatalf("查询备份审计: %v", err)
|
||||
}
|
||||
if backupAudits == 0 {
|
||||
t.Fatalf("发布应写入发布前备份审计")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 赛事包内容 ----------
|
||||
|
||||
// 赛事包必须内含 rules.yaml 与 uploads/(devdocs/04 §1.1),否则跨机无法复现。
|
||||
func TestExportProjectZipIncludesRulesAndUploads(t *testing.T) {
|
||||
projectDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(projectDir, "uploads"), 0o755); err != nil {
|
||||
t.Fatalf("创建 uploads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "uploads", "logo.txt"), []byte("logo"), 0o644); err != nil {
|
||||
t.Fatalf("写 uploads 文件: %v", err)
|
||||
}
|
||||
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("导出赛事包: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("读取 zip: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, f := range zr.File {
|
||||
names[f.Name] = true
|
||||
}
|
||||
for _, want := range []string{"meta.yaml", "event.db", "rules.yaml", "uploads/logo.txt"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("赛事包缺少 %s,实际含 %v", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- result.points ----------
|
||||
|
||||
// 已发布成绩必须带得分,供录入页预览与审查页对比。
|
||||
func TestPublishedResultsReturnPoints(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "publish-1",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/results?eventId=%d&state=published", ids.event), nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读取成绩应 200,得到 %d", status)
|
||||
}
|
||||
page := decodeData[struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}](t, env2)
|
||||
items := page.Items
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("应返回 2 条成绩,得到 %d", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it["points"] == nil {
|
||||
t.Fatalf("已发布成绩应返回 points: %+v", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- records 表写入 ----------
|
||||
|
||||
// 批准破纪录必须更新纪录库的当前纪录(devdocs/02 §3、devdocs/04 records)。
|
||||
func TestApproveRecordBreakUpsertsRecords(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
||||
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
|
||||
"resultValue": 10.5, "resultText": "10.50",
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
breakID := int64(created["id"].(float64))
|
||||
|
||||
if status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("批准破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var (
|
||||
holder sql.NullString
|
||||
teamID sql.NullInt64
|
||||
isCurrent bool
|
||||
source string
|
||||
)
|
||||
err := env.ev.QueryRow(`SELECT holder_name, holder_team_id, is_current, source
|
||||
FROM records WHERE event_id = ? AND level = 'meet'`, ids.event).
|
||||
Scan(&holder, &teamID, &isCurrent, &source)
|
||||
if err != nil {
|
||||
t.Fatalf("查询纪录库: %v", err)
|
||||
}
|
||||
if !holder.Valid || holder.String != "张伟" {
|
||||
t.Fatalf("纪录持有者应为运动员姓名,得到 %+v", holder)
|
||||
}
|
||||
if !teamID.Valid || teamID.Int64 != ids.teamA {
|
||||
t.Fatalf("纪录单位应为 %d,得到 %+v", ids.teamA, teamID)
|
||||
}
|
||||
if !isCurrent || source != "current" {
|
||||
t.Fatalf("新纪录应 is_current=1 source=current,得到 isCurrent=%v source=%q", isCurrent, source)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 规则导入审计 ----------
|
||||
|
||||
// 规则导入成功后必须留审计(devdocs/02 §7.2、08 §3)。
|
||||
func TestRulesImportWritesAudit(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
resp, err := client.Get(env.server.URL + "/api/rules/export")
|
||||
if err != nil {
|
||||
t.Fatalf("导出规则包: %v", err)
|
||||
}
|
||||
pack, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", pack, "application/yaml", true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'rules.import'`).Scan(&count); err != nil {
|
||||
t.Fatalf("查询审计: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("规则导入应写 1 条审计,得到 %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 实时广播与在线看板 ----------
|
||||
|
||||
// 写事务提交后要把新变更推给实时层;大屏实例列表对 recorder 开放。
|
||||
func TestWritesBroadcastChangesAndListDisplayInstances(t *testing.T) {
|
||||
fake := &fakeRealtime{}
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
|
||||
fake.db = env.ev
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "publish-1",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
batch := fake.firstBatch(t)
|
||||
found := false
|
||||
for _, ch := range batch {
|
||||
if ch.Entity == "standings" {
|
||||
found = true
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(ch.Data, &rows); err != nil || len(rows) == 0 {
|
||||
t.Fatalf("广播的 standings 应为非空数组: %v %s", err, ch.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("广播批次应包含 standings 变更: %+v", batch)
|
||||
}
|
||||
|
||||
fake.instances = []realtime.Instance{{ID: "display-1", Online: true, Page: "standings"}}
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
list := decodeData[[]map[string]any](t, env2)
|
||||
if len(list) != 1 || list[0]["id"] != "display-1" {
|
||||
t.Fatalf("大屏实例列表不对: %+v", list)
|
||||
}
|
||||
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("guest 读大屏实例应 403,得到 %d", status)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
|
||||
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
|
||||
}
|
||||
|
||||
@@ -101,6 +102,11 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||||
)
|
||||
|
||||
// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。
|
||||
@@ -224,16 +228,13 @@ func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
|
||||
}
|
||||
|
||||
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
|
||||
cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax}
|
||||
values, err := loadSettingsMap(ctx, q)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
return domain.RecordBonusConfig{}, err
|
||||
}
|
||||
cfg.Enabled = values["record_bonus.enabled"] == "true"
|
||||
if values["record_bonus.mode"] == string(domain.RecordBonusSum) {
|
||||
cfg.Mode = domain.RecordBonusSum
|
||||
}
|
||||
return cfg, nil
|
||||
// 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读,
|
||||
// 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。
|
||||
return rules.RecordBonusFromSettings(values), nil
|
||||
}
|
||||
|
||||
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
|
||||
@@ -328,8 +329,9 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
}
|
||||
|
||||
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
|
||||
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
|
||||
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
|
||||
scope := `source_type = 'result' AND event_id = ?`
|
||||
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
|
||||
@@ -390,6 +392,9 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
catID = *in.CategoryID
|
||||
}
|
||||
|
||||
// 发布是不可逆的对外动作,先留一份快照;失败只记日志与审计,不阻塞发布(08 第 6 节)。
|
||||
s.backupBeforePublish(ctx)
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -436,7 +441,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -447,6 +452,24 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
return s.resultsByEvent(ctx, in.EventID)
|
||||
}
|
||||
|
||||
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
|
||||
func (s *server) backupBeforePublish(ctx context.Context) {
|
||||
if strings.TrimSpace(s.dataDir) == "" {
|
||||
return
|
||||
}
|
||||
info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{
|
||||
Dir: filepath.Join(s.dataDir, "backups"),
|
||||
Prefix: "event",
|
||||
Reason: "publish",
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("发布前备份失败", "err", err)
|
||||
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path})
|
||||
}
|
||||
|
||||
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return nil, badRequest("eventId 必填", nil)
|
||||
@@ -495,7 +518,7 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
|
||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -566,7 +589,7 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
|
||||
@@ -33,21 +33,27 @@ type Options struct {
|
||||
DataDir string
|
||||
// Display 可选,实时层接入后传入。
|
||||
Display DisplaySender
|
||||
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
|
||||
Realtime Realtime
|
||||
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
|
||||
WS http.Handler
|
||||
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
|
||||
ProjectDir string
|
||||
// WebFS 可选,默认使用内嵌前端资源。
|
||||
WebFS fs.FS
|
||||
}
|
||||
|
||||
type server struct {
|
||||
logger *slog.Logger
|
||||
version string
|
||||
sys *sql.DB
|
||||
ev *sql.DB
|
||||
auth *auth.Service
|
||||
dataDir string
|
||||
display DisplaySender
|
||||
idem *idempotencyStore
|
||||
logger *slog.Logger
|
||||
version string
|
||||
sys *sql.DB
|
||||
ev *sql.DB
|
||||
auth *auth.Service
|
||||
dataDir string
|
||||
projectDir string
|
||||
display DisplaySender
|
||||
realtime Realtime
|
||||
idem *idempotencyStore
|
||||
}
|
||||
|
||||
// New 组装并返回服务端的 HTTP 处理器。
|
||||
@@ -62,14 +68,16 @@ func New(opts Options) http.Handler {
|
||||
}
|
||||
|
||||
s := &server{
|
||||
logger: logger,
|
||||
version: opts.Version,
|
||||
sys: opts.System,
|
||||
ev: opts.Event,
|
||||
auth: svc,
|
||||
dataDir: opts.DataDir,
|
||||
display: opts.Display,
|
||||
idem: newIdempotencyStore(10 * time.Minute),
|
||||
logger: logger,
|
||||
version: opts.Version,
|
||||
sys: opts.System,
|
||||
ev: opts.Event,
|
||||
auth: svc,
|
||||
dataDir: opts.DataDir,
|
||||
projectDir: opts.ProjectDir,
|
||||
display: opts.Display,
|
||||
realtime: opts.Realtime,
|
||||
idem: newIdempotencyStore(10 * time.Minute),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -94,9 +102,11 @@ func New(opts Options) http.Handler {
|
||||
}
|
||||
|
||||
var h http.Handler = mux
|
||||
h = s.broadcastChanges(h)
|
||||
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
|
||||
h = middleware.RateLimitWrite(svc)(h)
|
||||
h = middleware.CSRF(h)
|
||||
h = middleware.MustChangePassword(h)
|
||||
h = middleware.Authenticate(svc)(h)
|
||||
h = middleware.Log(logger)(h)
|
||||
h = middleware.Recover(logger)(h)
|
||||
@@ -205,6 +215,7 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
// 大屏控制与运维
|
||||
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
|
||||
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
|
||||
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
|
||||
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ type testEnv struct {
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
return newTestEnvWith(t, nil)
|
||||
}
|
||||
|
||||
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
|
||||
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -60,15 +66,18 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
|
||||
handler := httpapi.New(httpapi.Options{
|
||||
opts := httpapi.Options{
|
||||
Logger: logger,
|
||||
Version: "test",
|
||||
System: st.DB(),
|
||||
Event: ev,
|
||||
Auth: svc,
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
})
|
||||
srv := httptest.NewServer(handler)
|
||||
}
|
||||
if customize != nil {
|
||||
customize(&opts)
|
||||
}
|
||||
srv := httptest.NewServer(httpapi.New(opts))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
|
||||
|
||||
Reference in new issue
Block a user