- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
761 lines
24 KiB
Go
761 lines
24 KiB
Go
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) (domain.StandingsInput, error) {
|
|
teams, err := s.domainTeams(ctx)
|
|
if err != nil {
|
|
return domain.StandingsInput{}, err
|
|
}
|
|
athletes, err := s.domainAthletes(ctx)
|
|
if err != nil {
|
|
return domain.StandingsInput{}, err
|
|
}
|
|
ledger, err := s.activeLedger(ctx)
|
|
if err != nil {
|
|
return domain.StandingsInput{}, err
|
|
}
|
|
ranked, err := s.buildRanked(ctx)
|
|
if err != nil {
|
|
return domain.StandingsInput{}, err
|
|
}
|
|
values, err := loadSettingsMap(ctx, s.ev)
|
|
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
|
|
}
|
|
|
|
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
|
|
rows, err := s.ev.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) ([]domain.Athlete, error) {
|
|
rows, err := s.ev.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) ([]domain.RankedResult, error) {
|
|
rows, err := s.ev.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
|
|
var eventID int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); 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
|
|
}
|
|
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, 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)
|
|
}
|
|
|
|
// ---------- 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 := 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 := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, 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
|
|
}
|