package httpapi import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // ---------- settings ---------- func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) { rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make(map[string]string) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, internal(err) } out[k] = v } return out, rows.Err() } func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) { values, err := loadSettingsMap(ctx, s.ev) if err != nil { return eventSettingsDTO{}, err } var out eventSettingsDTO out.Event.Name = orDefault(values["event.name"], "未命名赛事") out.Event.Subtitle = nilIfEmpty(values["event.subtitle"]) out.Event.Host = nilIfEmpty(values["event.organizer"]) out.Event.LogoPath = nilIfEmpty(values["event.logo_path"]) out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"]) out.Display.Background = nilIfEmpty(values["display.background"]) out.Display.Logo = nilIfEmpty(values["display.logo"]) if out.Display.Logo == nil { out.Display.Logo = out.Event.LogoPath } out.Display.Title = orDefault(values["display.title"], out.Event.Name) out.Display.Subtitle = nilIfEmpty(values["display.subtitle"]) if out.Display.Subtitle == nil { out.Display.Subtitle = out.Event.Subtitle } out.Display.PrimaryColor = out.Event.PrimaryColor out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15) out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) out.Standings.RankingMode = orDefault(values["standings.mode"], "points") out.Standings.ShowIndividualBoard = boolDefault( firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true) out.RequireApproval = boolDefault(values["results.review_required"], false) if code := values["scoring.default_profile"]; code != "" { var id int64 err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id) switch { case errors.Is(err, sql.ErrNoRows): // 配置引用的模板已被删除:返回 null,由管理端提示重新选择。 case err != nil: return eventSettingsDTO{}, internal(err) default: out.DefaultProfileID = &id } } return out, nil } func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return eventSettingsDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() set := func(key, value string) error { _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, key, value) if err != nil { return internal(err) } return nil } setPtr := func(key string, v *string) error { if v == nil { return nil } return set(key, *v) } if p := patch.Event; p != nil { if err := setPtr("event.name", p.Name); err != nil { return eventSettingsDTO{}, err } if err := setPtr("event.subtitle", p.Subtitle); err != nil { return eventSettingsDTO{}, err } if err := setPtr("event.organizer", p.Host); err != nil { return eventSettingsDTO{}, err } if err := setPtr("event.logo_path", p.LogoPath); err != nil { return eventSettingsDTO{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { return eventSettingsDTO{}, err } } if p := patch.Display; p != nil { if err := setPtr("display.background", p.Background); err != nil { return eventSettingsDTO{}, err } if err := setPtr("display.logo", p.Logo); err != nil { return eventSettingsDTO{}, err } if err := setPtr("display.title", p.Title); err != nil { return eventSettingsDTO{}, err } if err := setPtr("display.subtitle", p.Subtitle); err != nil { return eventSettingsDTO{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { return eventSettingsDTO{}, err } if p.RotateSeconds != nil { if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil { return eventSettingsDTO{}, err } } } if p := patch.Standings; p != nil { if p.IndividualToTeamWeight != nil { if err := set("standings.individual_to_team_weight", strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil { return eventSettingsDTO{}, err } } if err := setPtr("standings.mode", p.RankingMode); err != nil { return eventSettingsDTO{}, err } if p.ShowIndividualBoard != nil { if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil { return eventSettingsDTO{}, err } } } if patch.DefaultProfileID != nil { code := "" if *patch.DefaultProfileID != 0 { if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil { if errors.Is(err, sql.ErrNoRows) { return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil) } return eventSettingsDTO{}, internal(err) } } if err := set("scoring.default_profile", code); err != nil { return eventSettingsDTO{}, err } } if patch.RequireApproval != nil { if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil { return eventSettingsDTO{}, err } } if err := tx.Commit(); err != nil { return eventSettingsDTO{}, internal(err) } return s.getSettings(ctx) } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } func atoiDefault(v string, def int) int { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return def } return n } func atofDefault(v string, def float64) float64 { n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil { return def } return n } func boolDefault(v string, def bool) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes": return true case "false", "0", "no": return false default: return def } } // ---------- standings ---------- 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, q) if err != nil { return domain.StandingsInput{}, err } ledger, err := s.activeLedger(ctx, q) if err != nil { return domain.StandingsInput{}, err } ranked, err := s.buildRanked(ctx, q) if err != nil { return domain.StandingsInput{}, err } values, err := loadSettingsMap(ctx, q) if err != nil { return domain.StandingsInput{}, err } weight := atofDefault(values["standings.individual_to_team_weight"], 1) exclude := !boolDefault(values["standings.medal_counts_team_events"], true) return domain.StandingsInput{ Teams: teams, Athletes: athletes, Ledger: ledger, Ranked: ranked, IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, }, nil } // 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) } defer func() { _ = rows.Close() }() out := make([]domain.Team, 0) for rows.Next() { var ( t domain.Team shortName sql.NullString color sql.NullString ) if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil { return nil, internal(err) } t.ShortName = shortName.String t.Color = color.String out = append(out, t) } return out, rows.Err() } 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) } defer func() { _ = rows.Close() }() out := make([]domain.Athlete, 0) for rows.Next() { var ( a domain.Athlete gender sql.NullString teamID sql.NullInt64 ) if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil { return nil, internal(err) } a.Gender = gender.String a.TeamID = teamID.Int64 out = append(out, a) } return out, rows.Err() } // buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。 // 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。 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 JOIN entries e ON e.id = r.entry_id JOIN event_score_channels c ON c.event_id = r.event_id LEFT JOIN athletes a ON a.id = e.athlete_id WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok' AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.RankedResult, 0) for rows.Next() { var ( rr domain.RankedResult value sql.NullFloat64 athleteID sql.NullInt64 rank int ) if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status, &rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil { return nil, internal(err) } rr.Rank = rank if value.Valid { v := value.Float64 rr.ResultValue = &v } rr.AthleteID = athleteID.Int64 rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} out = append(out, rr) } return out, rows.Err() } func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) { team, err := s.getTeam(ctx, teamID) if err != nil { return teamBreakdownDTO{}, err } rows, err := s.ev.QueryContext(ctx, `SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name, l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '') FROM score_ledger l LEFT JOIN events ev ON ev.id = l.event_id LEFT JOIN entries e ON e.id = l.entry_id LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN athletes a ON a.id = l.athlete_id LEFT JOIN results r ON r.id = l.result_id WHERE l.team_id = ? AND l.voided = 0 ORDER BY l.id DESC`, teamID) if err != nil { return teamBreakdownDTO{}, internal(err) } defer func() { _ = rows.Close() }() out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}} for rows.Next() { var ( item breakdownItemDTO eventID, athleteID sql.NullInt64 eventCode, eventName sql.NullString categoryName, athleteName sql.NullString rank sql.NullInt64 resultText sql.NullString ) if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName, &categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil { return teamBreakdownDTO{}, internal(err) } item.EventID = i64Ptr(eventID) item.EventCode = strPtr(eventCode) item.EventName = strPtr(eventName) item.CategoryName = strPtr(categoryName) item.AthleteID = i64Ptr(athleteID) item.AthleteName = strPtr(athleteName) item.Rank = intPtr(rank) item.ResultText = strPtr(resultText) out.Items = append(out.Items, item) out.TotalPoints += item.Points } return out, rows.Err() } // ---------- records ---------- func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) { query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''), holder_name, holder_team_id, achieved_at, is_current, source FROM records WHERE is_current = 1` args := []any{} if eventID > 0 { query += ` AND event_id = ?` args = append(args, eventID) } query += ` ORDER BY event_id, level` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]recordEntryDTO, 0) for rows.Next() { var ( r recordEntryDTO holderName sql.NullString holderTeamID sql.NullInt64 achievedAt sql.NullString ) if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText, &holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil { return nil, internal(err) } r.HolderName = strPtr(holderName) r.HolderTeamID = i64Ptr(holderTeamID) r.AchievedAt = strPtr(achievedAt) out = append(out, r) } return out, rows.Err() } func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) { if in.EventID == 0 || in.EntryID == 0 || in.Level == "" { return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil) } switch in.Level { case "meet", "city", "province", "national": default: return recordBreakDTO{}, badRequest("level 非法", in.Level) } res, err := s.ev.ExecContext(ctx, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state) VALUES (?, ?, ?, ?, ?, 0, 'pending')`, in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText) if isForeignKeyViolation(err) { return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil) } if err != nil { return recordBreakDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return recordBreakDTO{}, internal(err) } return s.getRecordBreak(ctx, id) } func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) { var ( b recordBreakDTO value sql.NullFloat64 text sql.NullString approvedBy sql.NullInt64 approvedAt sql.NullString ) err := s.ev.QueryRowContext(ctx, `SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at FROM record_breaks WHERE id = ?`, id). Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt) if errors.Is(err, sql.ErrNoRows) { return recordBreakDTO{}, notFound("破纪录登记不存在") } if err != nil { return recordBreakDTO{}, internal(err) } b.ResultValue = value.Float64 b.ResultText = text.String b.ApprovedBy = i64Ptr(approvedBy) b.ApprovedAt = strPtr(approvedAt) return b, nil } func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return recordBreakDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() 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("破纪录登记不存在") } return recordBreakDTO{}, internal(err) } if state != "pending" { return recordBreakDTO{}, stateInvalid("该破纪录登记已处理") } if _, err := tx.ExecContext(ctx, `UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`, nullID(userID), id); err != nil { return recordBreakDTO{}, internal(err) } 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) } s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"}) 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) { where := []string{"1 = 1"} args := []any{} if teamID > 0 { where = append(where, "team_id = ?") args = append(args, teamID) } clause := " WHERE " + joinAnd(where) var total int if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, size, (page-1)*size) rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return pageResult[adjustmentDTO]{}, internal(err) } defer func() { _ = rows.Close() }() items := make([]adjustmentDTO, 0) for rows.Next() { var ( a adjustmentDTO eventID, createdBy sql.NullInt64 ) if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } a.EventID = i64Ptr(eventID) a.CreatedBy = i64Ptr(createdBy) items = append(items, a) } if err := rows.Err(); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil } func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) { values, err := loadSettingsMap(ctx, s.ev) if err != nil { return adjustmentDTO{}, err } if !boolDefault(values["manual_adjustment.enabled"], false) { return adjustmentDTO{}, stateInvalid("人工调整当前未启用") } if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" { return adjustmentDTO{}, badRequest("reason 必填", nil) } switch in.Category { case "penalty", "bonus", "correction": default: return adjustmentDTO{}, badRequest("category 非法", in.Category) } if in.TeamID == 0 { return adjustmentDTO{}, badRequest("teamId 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return adjustmentDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by) VALUES (?, ?, ?, ?, ?, ?)`, in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID)) if isForeignKeyViolation(err) { return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil) } if err != nil { return adjustmentDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return adjustmentDTO{}, internal(err) } if _, err := tx.ExecContext(ctx, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by) VALUES (?, ?, 'adjustment', ?, ?, ?)`, 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) } s.audit(ctx, "adjustment.create", "adjustment", id, nil, in) return s.getAdjustment(ctx, id) } func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) { var ( a adjustmentDTO eventID, createdBy sql.NullInt64 ) err := s.ev.QueryRowContext(ctx, `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at FROM point_adjustments WHERE id = ?`, id). Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return adjustmentDTO{}, notFound("人工调整不存在") } if err != nil { return adjustmentDTO{}, internal(err) } a.EventID = i64Ptr(eventID) a.CreatedBy = i64Ptr(createdBy) return a, nil } func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return adjustmentDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var voided bool if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil { if errors.Is(err, sql.ErrNoRows) { return adjustmentDTO{}, notFound("人工调整不存在") } return adjustmentDTO{}, internal(err) } if voided { return adjustmentDTO{}, stateInvalid("该人工调整已作废") } if _, err := tx.ExecContext(ctx, `UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`, nullID(userID), id); err != nil { return adjustmentDTO{}, internal(err) } if _, err := tx.ExecContext(ctx, `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`, nullID(userID), reason, id); 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) } s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason}) return s.getAdjustment(ctx, id) } // ---------- audit logs ---------- func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) { where := []string{"1 = 1"} args := []any{} if entityType != "" { where = append(where, "entity_type = ?") args = append(args, entityType) } if userID > 0 { where = append(where, "user_id = ?") args = append(args, userID) } clause := " WHERE " + joinAnd(where) var total int if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil { return pageResult[auditLogDTO]{}, internal(err) } query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, size, (page-1)*size) rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return pageResult[auditLogDTO]{}, internal(err) } defer func() { _ = rows.Close() }() items := make([]auditLogDTO, 0) for rows.Next() { var ( a auditLogDTO uid, eid sql.NullInt64 before sql.NullString after sql.NullString ip, reqID sql.NullString ) if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil { return pageResult[auditLogDTO]{}, internal(err) } a.UserID = i64Ptr(uid) a.EntityID = i64Ptr(eid) a.BeforeJSON = strPtr(before) a.AfterJSON = strPtr(after) a.IP = strPtr(ip) a.RequestID = strPtr(reqID) items = append(items, a) } if err := rows.Err(); err != nil { return pageResult[auditLogDTO]{}, internal(err) } return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil } // ---------- backup ---------- // backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。 func (s *server) backup(ctx context.Context) (backupResultDTO, error) { if strings.TrimSpace(s.dataDir) == "" { return backupResultDTO{}, internal(errors.New("未配置数据目录")) } dir := filepath.Join(s.dataDir, "backups") if err := os.MkdirAll(dir, 0o755); err != nil { return backupResultDTO{}, internal(err) } now := time.Now() stamp := now.Format("20060102-150405") path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp)) if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil { return backupResultDTO{}, internal(err) } eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp)) if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil { return backupResultDTO{}, internal(err) } s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path}) return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil }