feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -0,0 +1,554 @@
|
||||
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), ",")
|
||||
}
|
||||
Reference in new issue
Block a user