555 lines
15 KiB
Go
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), ",")
|
|
}
|