Files
Scoreboard/server/internal/httpapi/handlers_revisions.go
T

555 lines
15 KiB
Go

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