Files
Scoreboard/server/internal/httpapi/repo_misc.go
T
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

1487 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- 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) (dto.EventSettings, error) {
return s.settingsSnapshotQ(ctx, s.ev)
}
// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, q)
if err != nil {
return dto.EventSettings{}, err
}
out := dto.ParseEventSettings(values)
if code := values["scoring.default_profile"]; code != "" {
var id int64
err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
case err != nil:
return dto.EventSettings{}, internal(err)
default:
out.DefaultProfileID = &id
}
}
return out, nil
}
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return dto.EventSettings{}, 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 dto.EventSettings{}, err
}
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("event.organizer", p.Host); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return dto.EventSettings{}, err
}
}
if p := patch.Display; p != nil {
if err := setPtr("display.background", p.Background); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("display.logo", p.Logo); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("display.title", p.Title); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
return dto.EventSettings{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return dto.EventSettings{}, err
}
if p.RotateSeconds != nil {
// 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
}
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
}
}
// 字体 id 必须在写入时就校验:引用一个不存在的字体,大屏只会静默回落成黑体,
// 现场看到的「配置没生效」比直接报错难排查得多。空串表示用默认字体。
if p.FontTitle != nil {
if err := s.checkDisplayFont("display.fontTitle", *p.FontTitle); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_title", strings.TrimSpace(*p.FontTitle)); err != nil {
return dto.EventSettings{}, err
}
}
if p.FontBody != nil {
if err := s.checkDisplayFont("display.fontBody", *p.FontBody); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_body", strings.TrimSpace(*p.FontBody)); err != nil {
return dto.EventSettings{}, err
}
}
// 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。
// 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。
if p.LiveEvents != nil {
if len(*p.LiveEvents) > dto.MaxLiveEvents {
return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents))
}
ids := dedupeIDs(*p.LiveEvents)
for _, id := range ids {
var exists int
err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id)
}
if err != nil {
return dto.EventSettings{}, internal(err)
}
}
if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil {
return dto.EventSettings{}, err
}
}
if p.LiveSource != nil {
source := dto.NormalizeLiveSource(*p.LiveSource)
if source == "" {
return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource)
}
if err := set(dto.SettingKeyLiveSource, source); err != nil {
return dto.EventSettings{}, err
}
}
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
// 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
if *p.IndividualToTeamWeight <= 0 {
return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
}
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
}
}
if err := setPtr("standings.mode", p.RankingMode); err != nil {
return dto.EventSettings{}, err
}
if p.ShowIndividualBoard != nil {
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
return dto.EventSettings{}, err
}
}
if p.ShowIndividualMedals != nil {
if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil {
return dto.EventSettings{}, err
}
}
if p.LiveOnPublish != nil {
if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil {
return dto.EventSettings{}, 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 dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil)
}
return dto.EventSettings{}, internal(err)
}
}
if err := set("scoring.default_profile", code); err != nil {
return dto.EventSettings{}, err
}
}
if patch.RequireApproval != nil {
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
return dto.EventSettings{}, err
}
}
// 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
snapshot, err := s.settingsSnapshotQ(ctx, tx)
if err != nil {
return dto.EventSettings{}, err
}
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
return dto.EventSettings{}, err
}
if err := tx.Commit(); err != nil {
return dto.EventSettings{}, internal(err)
}
return snapshot, nil
}
// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
const (
minRotateSeconds = 5
maxRotateSeconds = 120
)
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
}
}
// ---------- 系统级设置:登录有效期 ----------
//
// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的
// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性,
// 切换到另一个赛事项目时不该跟着变。
func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.sessionSettings(r)
}
func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in sessionSettingsPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if in.SessionHours == nil {
return nil, badRequest("登录有效期 sessionHours 必填", nil)
}
hours := *in.SessionHours
if hours < auth.MinSessionHours || hours > auth.MaxSessionHours {
return nil, badRequest(
fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours)
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
if err := s.writeSessionHours(r.Context(), hours); err != nil {
return nil, err
}
s.audit(r.Context(), "settings.session_hours", "system", 0, nil,
map[string]any{"sessionHours": hours})
return s.sessionSettings(r)
})
}
// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。
func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) {
values, err := loadSettingsMap(r.Context(), s.sys)
if err != nil {
return dto.SessionSettings{}, err
}
return dto.SessionSettings{
SessionHours: auth.EffectiveSessionHours(values),
DefaultHours: auth.DefaultSessionHours,
MinHours: auth.MinSessionHours,
MaxHours: auth.MaxSessionHours,
ExpiresAt: s.sessionExpiresAt(r),
}, nil
}
// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。
// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。
func (s *server) sessionExpiresAt(r *http.Request) *string {
c, err := r.Cookie(auth.CookieSession)
if err != nil || c.Value == "" {
return nil
}
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
if err != nil || sess == nil {
return nil
}
v := sess.ExpiresAt.UTC().Format(time.RFC3339)
return &v
}
// writeSessionHours 落库登录有效期并立刻在进程内生效。
// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。
func (s *server) writeSessionHours(ctx context.Context, hours int) error {
tx, err := s.sys.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
upsert := 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
}
if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil {
return err
}
// 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值,
// 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。
if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
// 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。
s.auth.SetSessionTTL(time.Duration(hours) * time.Hour)
return nil
}
// ---------- 大屏公告 ----------
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
const (
announceTextKey = "display.announce_text"
announceSecondsKey = "display.announce_seconds"
defaultAnnounceSeconds = 20
)
// saveAnnouncement 落库当前公告正文与全屏推送时长;text 为空串即「无公告」。
func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int, actorID int64) error {
if seconds <= 0 {
seconds = defaultAnnounceSeconds
}
return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
}
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
return s.writeAnnouncement(ctx, actorID, announceTextKey, "")
}
// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
for i := 0; i+1 < len(kv); i += 2 {
if _, 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')`,
kv[i], kv[i+1]); err != nil {
return internal(err)
}
}
snapshot, err := s.settingsSnapshotQ(ctx, tx)
if err != nil {
return err
}
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, actorID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
// ---------- 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,
TieBreak: splitSettingList(values["standings.tie_break"]),
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
func splitSettingList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, 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 dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
}
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
// 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。
func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team {
out := make([]domain.Team, 0, len(teams))
for _, t := range teams {
out = append(out, domain.Team{
ID: t.ID,
Code: t.Code,
Name: t.Name,
ShortName: strValue(t.ShortName),
LogoPath: strValue(t.LogoPath),
Color: strValue(t.Color),
})
}
return out
}
func strValue(s *string) string {
if s == nil {
return ""
}
return *s
}
// 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 从 channel_ranks 读名次明细(用于奖牌统计与个人榜)。
//
// 数据源是「发布/重算时按通道全量重建」的 channel_ranks,而不是 results.rank:
// 后者只是个人名次(见 applyComputedRanks),团体名次与聚合名次根本没有对应的成绩行。
// 三个奖牌开关随行携带(events.award_medals + 通道两个开关),domain 的榜单函数不必回查库。
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径(export/load.loadRanked)。
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
rows, err := q.QueryContext(ctx,
`SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
ev.award_medals, c.count_to_medals, c.count_to_team_medals
FROM channel_ranks cr
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
JOIN event_score_channels c ON c.id = cr.channel_id
LEFT JOIN athletes a ON a.id = cr.athlete_id
WHERE cr.rank > 0
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
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
status sql.NullString
awardMedals int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
rr.Status = domain.ResultStatus(status.String)
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
// 归属份额沿用「主单位独占 100%」:team_id 为空(理论上不会)时不造份额,
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
return out, rows.Err()
}
// breakdownRankExpr 取一条单位流水的名次(devdocs 17 §11.3):优先按流水对应的通道与主体
// 关联 channel_ranks,关联不上时回落到 results.rank。
//
// score_ledger 上没有通道列(LedgerEntry.ChannelID 没有落库),只能按流水自身能确定的主体找:
// - 有 entry_id 的成绩流水:按报名项关联,多通道命中时取「主通道」(scope=athlete 优先,其次 sort_order);
// - entry_id / result_id 都为空的聚合 / 团体手工名次流水:写入侧把 source_id 退化为通道 id,就用它 + 主体关联。
//
// 组别用该行能唯一确定的那一个:报名项的组别(cat)优先;聚合流水没有报名项,
// 靠 ca 回推(该单位在本小项只参加了一个组别时那个组别才是答案,见下方子查询)。
// 回推不出来也不按「碰巧撞上的另一组」报数:放宽到不限组别取最优名次,宁可留空也不张冠李戴。
const breakdownRankExpr = `COALESCE((
SELECT cr.rank
FROM channel_ranks cr
JOIN event_score_channels ch ON ch.id = cr.channel_id
WHERE cr.event_id = l.event_id
AND ((cat.id IS NOT NULL AND cr.category_id = cat.id)
OR (cat.id IS NULL AND (ca.id IS NULL OR cr.category_id = ca.id)))
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
AND cr.channel_id = l.source_id
AND ((cr.scope = 'team' AND cr.team_id = l.team_id)
OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))))
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
LIMIT 1), r.rank)`
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
team, err := s.getTeam(ctx, teamID)
if err != nil {
return teamBreakdownDTO{}, err
}
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
// 其中仅记单位的直加,记到个人的乘折算系数。
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return teamBreakdownDTO{}, err
}
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
if weight <= 0 {
weight = 1
}
// 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks),
// 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。
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, `+breakdownRankExpr+`, 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 (
SELECT en2.event_id, MIN(en2.category_id) AS category_id
FROM entries en2
WHERE en2.team_id = ?
GROUP BY en2.event_id
HAVING COUNT(DISTINCT en2.category_id) = 1
) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL
AND l.source_type = 'result'
LEFT JOIN event_categories ca ON ca.id = ac.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 AND l.count_to_team = 1
ORDER BY l.id DESC`, teamID, 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)
if item.AthleteID != nil && *item.AthleteID != 0 {
item.Points *= weight
}
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
return out, rows.Err()
}
// ---------- records ----------
// recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。
// 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。
const recordSourceHistory = "history"
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
query := `SELECT ` + recordEntryColumns + ` 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() {
r, err := scanRecordEntry(rows)
if err != nil {
return nil, internal(err)
}
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)
}
if err := s.validateRecordBreakEntry(ctx, in); err != nil {
return recordBreakDTO{}, err
}
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)
}
// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
var (
entryEvent int64
recordEligible bool
)
err := s.ev.QueryRowContext(ctx,
`SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
in.EntryID).Scan(&entryEvent, &recordEligible)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("entryId 不存在", in.EntryID)
}
if err != nil {
return internal(err)
}
if entryEvent != in.EventID {
return badRequest("entryId 不属于该小项", in.EntryID)
}
if !recordEligible {
return badRequest("该小项未开启破纪录登记", in.EventID)
}
var (
state string
status string
)
err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
}
if err != nil {
return internal(err)
}
if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
}
return nil
}
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
b, err := scanRecordBreak(s.ev.QueryRowContext(ctx,
`SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
return b, nil
}
// recordBreakColumns 是 recordBreakDTO 的列清单;note 来自迁移 0021(可空)。
const recordBreakColumns = `id, event_id, entry_id, level, result_value, result_text, bonus_points,
state, approved_by, approved_at, note`
func scanRecordBreak(row interface{ Scan(...any) error }) (recordBreakDTO, error) {
var (
b recordBreakDTO
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
note sql.NullString
)
if err := row.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints,
&b.State, &approvedBy, &approvedAt, &note); err != nil {
return b, err
}
b.ResultValue = value.Float64
b.ResultText = text.String
b.ApprovedBy = i64Ptr(approvedBy)
b.ApprovedAt = strPtr(approvedAt)
b.Note = strPtr(note)
return b, nil
}
// listRecordBreaks 列出破纪录登记(最新在前)。state 为空表示不过滤;
// 传了就按它过滤,非法状态由 handler 拦在更外层(避免一个拼错的状态静默回空列表)。
func (s *server) listRecordBreaks(ctx context.Context, state string) ([]recordBreakDTO, error) {
query := `SELECT ` + recordBreakColumns + ` FROM record_breaks`
args := []any{}
if state != "" {
query += ` WHERE state = ?`
args = append(args, state)
}
query += ` ORDER BY id DESC`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]recordBreakDTO, 0)
for rows.Next() {
b, err := scanRecordBreak(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, b)
}
return out, rows.Err()
}
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" && state != "rejected" {
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
}
// 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由:
// note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now'), note = NULL
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)
}
// rejectRecordBreak 驳回 / 作废一条破纪录登记(A41)。
//
// 原本是 approved 的登记意味着它已经给该小项加过分,所以驳回不只是改状态:
//
// ① 状态改 rejected 并把理由写进 note;
// ② 重算该小项(引擎只认 approved 的登记),加分与随之变动的名次流水一起撤掉,再推榜;
// ③ 把「确认破纪录」写进纪录库的那条 current 纪录撤下来,并恢复该级别上一条纪录。
//
// pending 直接驳回没有加过分,②③ 都不用做——这也是本文件里唯一区分「从哪个状态驳回」的地方。
func (s *server) rejectRecordBreak(ctx context.Context, id, userID int64, reason string) (recordBreakDTO, error) {
if strings.TrimSpace(reason) == "" {
return recordBreakDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordBreakDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
state string
eventID int64
level string
)
if err := tx.QueryRowContext(ctx,
`SELECT state, event_id, level FROM record_breaks WHERE id = ?`, id).
Scan(&state, &eventID, &level); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
return recordBreakDTO{}, internal(err)
}
if state == "rejected" {
return recordBreakDTO{}, badRequest("该破纪录登记已被驳回/作废", nil)
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'rejected', note = ? WHERE id = ?`, reason, id); err != nil {
return recordBreakDTO{}, internal(err)
}
if state == "approved" {
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "驳回破纪录:"+reason); err != nil {
return recordBreakDTO{}, err
}
if err := revertCurrentRecord(ctx, tx, eventID, level); err != nil {
return recordBreakDTO{}, err
}
}
// 登记表不进实时快照,但「破纪录登记」这一块要跟着刷新,所以照 approve 的口径写一条变更。
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
// 只有真的重算过才推榜:pending 驳回没动过任何分数,凭空推一次榜单只会让大屏白刷。
if state == "approved" {
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.reject", "record_break", id,
map[string]any{"state": state}, map[string]any{"state": "rejected", "reason": reason})
return s.getRecordBreak(ctx, id)
}
// revertCurrentRecord 撤下「确认破纪录」写进纪录库的那条当前纪录,并恢复该档位上一条纪录。
//
// 只能按 (小项, 档位) 定位:records 上没有指回 record_breaks 的列,0021 也只加了一列理由,
// 没有为此重建表。同一 (小项, 档位) 的当前纪录本来就只有一条(upsertCurrentRecord 每次都先
// 把旧的置 0 再插新的),所以「当前那条」就是这次批准写下的那条。
// 恢复对象取剩下 id 最大的一条;一条都没有就把该档位留空——「无纪录」是事实,
// 留一条刚被撤掉的纪录反而会让纪录库自相矛盾。
func revertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID int64, level string) error {
var currentID sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT id FROM records WHERE event_id = ? AND level = ? AND is_current = 1 ORDER BY id DESC LIMIT 1`,
eventID, level).Scan(&currentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return internal(err)
}
if !currentID.Valid {
return nil
}
if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE id = ?`, currentID.Int64); err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 1
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? AND id <> ?
ORDER BY id DESC LIMIT 1)`,
eventID, level, currentID.Int64); err != nil {
return internal(err)
}
return nil
}
// 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
}
// recordEntryColumns 是 recordEntryDTO 的列清单。
const recordEntryColumns = `id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
holder_name, holder_team_id, achieved_at, is_current, source`
func scanRecordEntry(row interface{ Scan(...any) error }) (recordEntryDTO, error) {
var (
r recordEntryDTO
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return r, err
}
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
return r, nil
}
func (s *server) getRecordEntry(ctx context.Context, id int64) (recordEntryDTO, error) {
r, err := scanRecordEntry(s.ev.QueryRowContext(ctx,
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return recordEntryDTO{}, notFound("纪录不存在")
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
return r, nil
}
// createRecordEntry 手工登记一条历史纪录(C11):同 (小项, 档位) 的旧 current 让位,
// 新行成为该档位的当前纪录(与 upsertCurrentRecord 同口径,只是 source 记 history)。
//
// holder 名称 / 单位都可以空:很多历史纪录只留下了一个成绩数字,人是谁已经查不到了;
// 逼着录入员编一个名字,只会往库里塞假数据。
func (s *server) createRecordEntry(ctx context.Context, in recordEntryInput, userID int64) (recordEntryDTO, error) {
if in.EventID == 0 {
return recordEntryDTO{}, badRequest("eventId 必填", nil)
}
switch in.Level {
case "meet", "city", "province", "national":
default:
return recordEntryDTO{}, badRequest("level 非法", in.Level)
}
if in.ResultValue == nil && in.ResultText == nil {
return recordEntryDTO{}, badRequest("resultValue 与 resultText 至少填一个", nil)
}
// 只收 history:source='current' 是「确认破纪录」的产物,客户端造出来的 current
// 会把一条没人批准过的成绩伪装成本届刚破的纪录,还会被后续批准当成上一条纪录恢复。
if in.Source != "" && in.Source != recordSourceHistory {
return recordEntryDTO{}, badRequest("source 只能是 history", in.Source)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordEntryDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var exists int
err = tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, in.EventID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return recordEntryDTO{}, badRequest("eventId 不存在", in.EventID)
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
in.EventID, in.Level); err != nil {
return recordEntryDTO{}, internal(err)
}
res, 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 (?, ?, ?, ?, ?, ?, ?, 1, 'history')`,
in.EventID, in.Level, nullF64(in.ResultValue), nullStr(in.ResultText),
nullStr(in.HolderName), nullI64(in.HolderTeamID), nullStr(in.AchievedAt))
if isForeignKeyViolation(err) {
return recordEntryDTO{}, badRequest("holderTeamId 不存在", in.HolderTeamID)
}
if err != nil {
return recordEntryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return recordEntryDTO{}, internal(err)
}
// 纪录库进了实时快照(realtime.snapshot 读 records),不写变更大屏上的纪录页不会刷新。
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordEntryDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordEntryDTO{}, internal(err)
}
created, err := s.getRecordEntry(ctx, id)
if err != nil {
return recordEntryDTO{}, err
}
s.audit(ctx, "record.create", "record", id, nil, created)
return created, nil
}
// deleteRecordEntry 删除一条纪录;删掉的若是当前纪录,恢复同 (小项, 档位) 上一条为当前,
// 一条都没有就把该档位留空(与 revertCurrentRecord 同一口径)。
func (s *server) deleteRecordEntry(ctx context.Context, id, userID int64) error {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
eventID int64
level string
isCurrent bool
)
if err := tx.QueryRowContext(ctx,
`SELECT event_id, level, is_current FROM records WHERE id = ?`, id).
Scan(&eventID, &level, &isCurrent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("纪录不存在")
}
return internal(err)
}
before, err := scanRecordEntry(tx.QueryRowContext(ctx,
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
if err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil {
return internal(err)
}
if isCurrent {
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 1
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ?
ORDER BY id DESC LIMIT 1)`,
eventID, level); err != nil {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "record", id, "void", map[string]any{"id": id}, userID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
s.audit(ctx, "record.delete", "record", id, before, nil)
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 ----------
// listAuditLogs 支持 entityType / userId / action 三个过滤条件,任意组合。
// action 按子串模糊匹配:现场找的是「某类动作」(如 team.、result.publish),
// 精确等值会逼操作员背全名。三个条件都进同一条 WHERE,组合时是 AND。
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64, action string) (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)
}
if action != "" {
// 转义 % / _ / \ 让它们按字面量匹配,避免用户输入被当成通配符。
where = append(where, `action LIKE ? ESCAPE '\'`)
args = append(args, "%"+escapeLike(action)+"%")
}
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
}
// escapeLike 把 LIKE 的通配符转义为字面量,配合 ESCAPE '\' 使用。
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// ---------- backup ----------
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
// 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
infos, err := s.backups.Run("manual")
if err != nil {
if errors.Is(err, export.ErrNoDir) {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
return backupResultDTO{}, internal(err)
}
if len(infos) == 0 {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path})
return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil
}