package httpapi import ( "context" "database/sql" "encoding/json" "errors" "net/http" "sort" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // ---------- 变更历史(recorder/admin 可读) ---------- // 成绩变化的三种形态,与前端展示文案一一对应。 const ( revisionChangeAdded = "added" revisionChangeRemoved = "removed" revisionChangeChanged = "changed" ) func (s *server) listRevisionsHandler(w http.ResponseWriter, r *http.Request) (any, error) { eventID, _ := int64Query(r, "eventId") limit := intQuery(r, "limit", 50) if limit < 1 { limit = 50 } if limit > 200 { limit = 200 } offset := intQuery(r, "offset", 0) if offset < 0 { offset = 0 } return s.listRevisions(r.Context(), eventID, limit, offset) } func (s *server) getRevisionDetailHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { return nil, err } return s.getRevisionDetail(r.Context(), id) } // revisionRow 是 result_revisions 的一行,含解析后的快照。 type revisionRow struct { id int64 parentID *int64 eventID int64 eventName string action string actorID *int64 message *string createdAt string snapshot []resultDTO } const revisionColumns = `r.id, r.parent_id, r.event_id, r.action, r.actor_id, r.message, r.created_at, r.snapshot_json` func scanRevision(row interface{ Scan(...any) error }) (revisionRow, error) { var ( out revisionRow parentID sql.NullInt64 actorID sql.NullInt64 message sql.NullString snapshot string eventName sql.NullString ) if err := row.Scan(&out.id, &parentID, &out.eventID, &out.action, &actorID, &message, &out.createdAt, &snapshot, &eventName); err != nil { return out, err } out.parentID = i64Ptr(parentID) out.actorID = i64Ptr(actorID) out.message = strPtr(message) out.eventName = eventName.String if err := json.Unmarshal([]byte(snapshot), &out.snapshot); err != nil { return out, internal(err) } return out, nil } func (s *server) listRevisions(ctx context.Context, eventID int64, limit, offset int) (revisionPageDTO, error) { where := []string{"1 = 1"} args := []any{} if eventID > 0 { where = append(where, "r.event_id = ?") args = append(args, eventID) } clause := " WHERE " + joinAnd(where) var total int if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM result_revisions r`+clause, args...).Scan(&total); err != nil { return revisionPageDTO{}, internal(err) } query := `SELECT ` + revisionColumns + `, COALESCE(e.name, '') FROM result_revisions r LEFT JOIN events e ON e.id = r.event_id` + clause + ` ORDER BY r.id DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return revisionPageDTO{}, internal(err) } defer func() { _ = rows.Close() }() list := make([]revisionRow, 0) for rows.Next() { row, err := scanRevision(rows) if err != nil { return revisionPageDTO{}, internal(err) } list = append(list, row) } if err := rows.Err(); err != nil { return revisionPageDTO{}, internal(err) } items, err := s.revisionListItems(ctx, list) if err != nil { return revisionPageDTO{}, err } return revisionPageDTO{Items: items, Total: total, Limit: limit, Offset: offset}, nil } // revisionListItems 为每行补上操作人姓名与「与父节点相比的成绩变化条数」。 func (s *server) revisionListItems(ctx context.Context, list []revisionRow) ([]revisionListItemDTO, error) { actorIDs := make([]int64, 0, len(list)) for _, row := range list { if row.actorID != nil { actorIDs = append(actorIDs, *row.actorID) } } names, err := s.userNames(ctx, actorIDs) if err != nil { return nil, err } items := make([]revisionListItemDTO, 0, len(list)) for _, row := range list { parent, _, err := s.parentSnapshot(ctx, row) if err != nil { return nil, err } items = append(items, revisionListItem(row, names, len(diffSnapshots(parent, row.snapshot)))) } return items, nil } func revisionListItem(row revisionRow, names map[int64]string, changedCount int) revisionListItemDTO { item := revisionListItemDTO{ ID: row.id, ParentID: row.parentID, EventID: row.eventID, EventName: row.eventName, Action: row.action, ActorID: row.actorID, Message: row.message, CreatedAt: row.createdAt, ChangedCount: changedCount, } if row.actorID != nil { item.ActorName = names[*row.actorID] } return item } func (s *server) getRevision(ctx context.Context, id int64) (revisionRow, error) { row, err := scanRevision(s.ev.QueryRowContext(ctx, `SELECT `+revisionColumns+`, COALESCE(e.name, '') FROM result_revisions r LEFT JOIN events e ON e.id = r.event_id WHERE r.id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return row, notFound("变更记录不存在") } if err != nil { return row, internal(err) } return row, nil } func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetailDTO, error) { row, err := s.getRevision(ctx, id) if err != nil { return revisionDetailDTO{}, err } parent, _, err := s.parentSnapshot(ctx, row) if err != nil { return revisionDetailDTO{}, err } changes := diffSnapshots(parent, row.snapshot) entryTeams, err := s.entryTeams(ctx, changeEntryIDs(changes)) if err != nil { return revisionDetailDTO{}, err } for i := range changes { if teamID := entryTeams[changes[i].EntryID]; teamID != 0 { changes[i].TeamID = &teamID } } // 分数增减依赖当前计分配置重算快照;配置被删改时降级为只返回成绩差异。 teams, err := s.revisionTeamDeltas(ctx, row.eventID, parent, row.snapshot, changes, entryTeams) if err != nil { s.logger.Warn("变更历史分数差异计算失败", "revisionId", id, "eventId", row.eventID, "err", err) teams = []revisionTeamDeltaDTO{} } names, err := s.userNames(ctx, actorIDList(row.actorID)) if err != nil { return revisionDetailDTO{}, err } return revisionDetailDTO{ revisionListItemDTO: revisionListItem(row, names, len(changes)), Snapshot: row.snapshot, Diff: revisionDiffDTO{Results: changes, Teams: teams}, }, nil } // parentSnapshot 返回父节点快照:优先用显式 parent_id; // 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。 func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) { var ( raw string err error ) if row.parentID != nil { err = s.ev.QueryRowContext(ctx, `SELECT snapshot_json FROM result_revisions WHERE id = ?`, *row.parentID).Scan(&raw) } else { err = s.ev.QueryRowContext(ctx, `SELECT snapshot_json FROM result_revisions WHERE event_id = ? AND id < ? ORDER BY id DESC LIMIT 1`, row.eventID, row.id).Scan(&raw) } if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } if err != nil { return nil, false, internal(err) } var snapshot []resultDTO if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { return nil, false, internal(err) } return snapshot, true, nil } func actorIDList(id *int64) []int64 { if id == nil { return nil } return []int64{*id} } func changeEntryIDs(changes []revisionResultChangeDTO) []int64 { out := make([]int64, 0, len(changes)) for _, c := range changes { out = append(out, c.EntryID) } return out } // diffSnapshots 按成绩 id 对比两份快照,输出新增/删除/修改。 func diffSnapshots(parent, current []resultDTO) []revisionResultChangeDTO { parentByID := make(map[int64]resultDTO, len(parent)) for _, r := range parent { parentByID[r.ID] = r } currentByID := make(map[int64]resultDTO, len(current)) for _, r := range current { currentByID[r.ID] = r } out := make([]revisionResultChangeDTO, 0) for _, r := range current { after := r old, ok := parentByID[r.ID] if !ok { out = append(out, revisionResultChangeDTO{ ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeAdded, After: &after, }) continue } before := old if fields := changedResultFields(before, after); len(fields) > 0 { out = append(out, revisionResultChangeDTO{ ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeChanged, Fields: fields, Before: &before, After: &after, }) } } for _, r := range parent { if _, ok := currentByID[r.ID]; ok { continue } before := r out = append(out, revisionResultChangeDTO{ ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeRemoved, Before: &before, }) } sort.SliceStable(out, func(i, j int) bool { return out[i].ResultID < out[j].ResultID }) return out } func changedResultFields(a, b resultDTO) []string { fields := make([]string, 0, 4) if !intPtrEqual(a.Rank, b.Rank) { fields = append(fields, "rank") } if a.ResultText != b.ResultText { fields = append(fields, "resultText") } if !f64PtrEqual(a.ResultValue, b.ResultValue) { fields = append(fields, "resultValue") } if a.Unit != b.Unit { fields = append(fields, "unit") } if a.IsTie != b.IsTie { fields = append(fields, "isTie") } if !f64PtrEqual(a.PointsOverride, b.PointsOverride) { fields = append(fields, "pointsOverride") } if a.Status != b.Status { fields = append(fields, "status") } if a.State != b.State { fields = append(fields, "state") } if !strPtrEqual(a.Note, b.Note) { fields = append(fields, "note") } return fields } func intPtrEqual(a, b *int) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b } func f64PtrEqual(a, b *float64) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b } func strPtrEqual(a, b *string) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b } // revisionTeamDeltas 用快照重算受影响单位的分数增减。 // 快照里没有单位与积分,只能借当前计分配置重算;仅用于展示,不参与任何计算。 func (s *server) revisionTeamDeltas(ctx context.Context, eventID int64, parent, current []resultDTO, changes []revisionResultChangeDTO, entryTeams map[int64]int64) ([]revisionTeamDeltaDTO, error) { before, err := s.snapshotTeamPoints(ctx, eventID, parent) if err != nil { return nil, err } after, err := s.snapshotTeamPoints(ctx, eventID, current) if err != nil { return nil, err } counts := make(map[int64]int) for _, c := range changes { if teamID := entryTeams[c.EntryID]; teamID != 0 { counts[teamID]++ } } // 成绩本身没变、但积分因重算而变的单位也要列出来。 for teamID, points := range after { if points != before[teamID] { if _, ok := counts[teamID]; !ok { counts[teamID] = 0 } } } teams, err := s.teamMap(ctx) if err != nil { return nil, err } out := make([]revisionTeamDeltaDTO, 0, len(counts)) for teamID, n := range counts { t := teams[teamID] out = append(out, revisionTeamDeltaDTO{ TeamID: teamID, TeamCode: t.Code, TeamName: t.Name, PointsBefore: before[teamID], PointsAfter: after[teamID], PointsDelta: after[teamID] - before[teamID], ChangedResults: n, }) } sort.SliceStable(out, func(i, j int) bool { return out[i].TeamID < out[j].TeamID }) return out, nil } // snapshotTeamPoints 把一份成绩快照按当前通道/模板重算成「单位 → 总分」。 func (s *server) snapshotTeamPoints(ctx context.Context, eventID int64, snapshot []resultDTO) (map[int64]float64, error) { out := make(map[int64]float64) ev, err := loadEventQ(ctx, s.ev, eventID) if err != nil { return nil, err } categories, err := loadCategoriesQ(ctx, s.ev, eventID) if err != nil { return nil, err } channels, err := loadChannelsQ(ctx, s.ev, eventID) if err != nil { return nil, err } profileIDs := make(map[int64]bool, len(channels)) for _, ch := range channels { profileIDs[ch.ProfileID] = true } profiles, err := loadProfilesQ(ctx, s.ev, profileIDs) if err != nil { return nil, err } entries, err := loadEntriesQ(ctx, s.ev, eventID, 0) if err != nil { return nil, err } bonus, err := loadRecordBonusQ(ctx, s.ev) if err != nil { return nil, err } breaks, err := loadRecordBreaksQ(ctx, s.ev, eventID) if err != nil { return nil, err } results := make([]domain.Result, 0, len(snapshot)) for _, r := range snapshot { results = append(results, resultDTOToDomain(r)) } for _, cat := range categories { score, err := domain.ScoreEvent(domain.EventScoreInput{ Event: ev, Category: cat, Channels: channels, Profiles: profiles, Entries: entries, Results: results, RecordBonus: &bonus, RecordBreaks: breaks, }) if err != nil { return nil, mapDomainError(err) } for _, l := range score.Ledger { out[l.TeamID] += l.Points } } return out, nil } func resultDTOToDomain(r resultDTO) domain.Result { out := domain.Result{ ID: r.ID, EventID: r.EventID, EntryID: r.EntryID, ResultText: r.ResultText, ResultValue: r.ResultValue, Unit: r.Unit, IsTie: r.IsTie, PointsOverride: r.PointsOverride, Status: domain.ResultStatus(r.Status), State: domain.ResultState(r.State), } if r.Rank != nil { out.Rank = *r.Rank } if r.Note != nil { out.Note = *r.Note } return out } func (s *server) entryTeams(ctx context.Context, ids []int64) (map[int64]int64, error) { out := make(map[int64]int64) if len(ids) == 0 { return out, nil } args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } rows, err := s.ev.QueryContext(ctx, `SELECT id, team_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() for rows.Next() { var entryID, teamID int64 if err := rows.Scan(&entryID, &teamID); err != nil { return nil, internal(err) } out[entryID] = teamID } if err := rows.Err(); err != nil { return nil, internal(err) } return out, nil } // userNames 从系统库取用户显示名(result_revisions.actor_id 跨库,不能 JOIN)。 func (s *server) userNames(ctx context.Context, ids []int64) (map[int64]string, error) { out := make(map[int64]string, len(ids)) if len(ids) == 0 { return out, nil } args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } rows, err := s.sys.QueryContext(ctx, `SELECT id, display_name FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() for rows.Next() { var id int64 var name string if err := rows.Scan(&id, &name); err != nil { return nil, internal(err) } out[id] = name } if err := rows.Err(); err != nil { return nil, internal(err) } return out, nil } func placeholders(n int) string { return strings.TrimSuffix(strings.Repeat("?,", n), ",") }