Files
AskaEth 9c52dc4b38 feat(display): 大屏「赛事日程侧边栏」(手填日程 + 竖向进度轴 + 热开关 + 每屏覆盖);版本 dev_0.5.16
主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。

设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 id**。

服务端(走大屏公告那条路 —— PUT /api/settings 是 admin-only,会与「录入员可改」冲突)
- 新增 `schedule` 动作 + 白名单;`POST /api/display/command` 同一个 handler:**先校验 → 再转发 → 最后落库**
  (校验排在转发前,非法配置不会在大屏上闪一帧)
- 落库到赛事库 settings 的 `display.schedule`(**不新建表、不写迁移**,随赛事包走);
  与公告共用那份「写设置键 + bumpVersion」的实现
- **读写不对称容错**:写侧严格 400(含中文提示与第几条),读侧坏数据一律静默当空 —— 老数据 / 手改库不会炸大屏
- 上限:40 条;time ≤20 / title ≤40 / note ≤60(按字符,清洗控制字符);宽度夹 15–40;覆盖 ≤50 条
- 口径收口三处:**时间允许为空**(与管理端同口径)、**showNote 缺键 = true**(区分「键不存在」与「显式 false」)、
  **`inherit` 不落库**(不再白占覆盖预算)

大屏端
- 新增 `ScheduleSidebar.vue` + `schedulePanel.ts`(纯函数:开窗 / 三态 / 整体进度 / 字号与行数 / 按显示宽度截断 /
  单屏覆盖 / 抑制 / 指纹去重)
- 字号是**舞台设计坐标**(1920×1080),靠 `.stage` 的 `transform: scale()` 整体缩放,**不做二次缩放**
  (wiring 断言禁止出现 `window.`/`innerWidth`/`vh`);复用既有的 `fitRowFontPx`(**16–56px 硬上下限**)与 `textEm`;
  固定点迭代 ≤4 轮;**贴下限时宁可行数少也不缩字**;标题 ≤3 行、备注 ≤2 行,超出截断
- 容器 `position:absolute; inset:0; z-index:50`(**不用 fixed** —— `.stage` 带 transform,是 fixed 后代的包含块);
  **独立于 detail 状态机**(goto 会关明细、明细会停轮播,侧边栏必须不受影响)
- 深色半透明 + `backdrop-filter`,并带 `@supports not (backdrop-filter: blur(1px))` 退化为更不透明的深底
- 关着时**不留任何 DOM**(逐字节等同于没有这个功能);`prefers-reduced-motion` 下不做动效

管理端
- 大屏控制页(左列、公告之后)新增「赛事日程」卡片:条目表格(时间/标题/备注/已完成 + 上移/下移/删除)、
  总开关、备注上屏、位置与宽度、**每块屏三态覆盖**(跟随 / 强制开 / 强制关;切回「跟随」会真删掉那条键)
- 保存走 `adminApi.displayCommand`(**不走 admin-only 的设置接口**);本地校验不过**一个请求都不发**
- 排序只有上移 / 下移(该页面禁止挂 pointerdown/mousedown 监听,有既有测试钉着)

测试
- 服务端 16 个包全绿;前端 **1174 例**全过(基线 1096,本批 +78:大屏端 44 + 管理端 34)
- 大屏端另有 **40 项 playwright 实机**(用 `page.routeWebSocket` 假造实时通道):开/关后 DOM 无残留、
  五个页面下都在、`lock=0` 真等轮播翻页且侧边栏不掉、明细与公告期间自动收起并自动回来、
  **1080p 与 4K 字号完全相同**、`reducedMotion` 下无动画;并钉住两条硬指标(真机列表高度 ≥ 纯函数预算、
  容量×行高 ≤ 真机高度)

文档:`docs/升级说明` 新增 `dev_0.5.16`;`使用手册` 9.2 + 17.6;`常见问题` Q108–Q111;`上线检查清单`/`部署指南`
各一条;`devdocs/05·07·13` 同步。

⚠️ 一条**既有边界**(本版故意没改,按主人「兼容性最大化」的要求):导出的名次口径是「通道名次优先」、
屏幕是「成绩行名次优先」,只在「先按决赛计分发布、之后又只更新名次发布一次」这种序列下分叉
(导出显示上次计分的名次)。正常赛程遇不到;遇到时对该组再「按决赛成绩发布」一次即恢复一致。
已写进《升级说明》④ 与《常见问题》Q111。
2026-09-17 20:04:50 +08:00

1597 lines
58 KiB
Go
Raw Permalink 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"
"encoding/json"
"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"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// ---------- 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,公告的持久化只能走指令这一侧。
// 赛事日程侧边栏走的是同一条路(见下面的 saveSchedule)。
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.writeSettingValues(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
}
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
return s.writeSettingValues(ctx, actorID, announceTextKey, "")
}
// writeSettingValues 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到变化。
//
// 名字里不绑具体的键:公告与赛事日程都从这条路落库,事务与 change_log 的写法只此一份。
func (s *server) writeSettingValues(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
}
// ---------- 赛事日程侧边栏 ----------
// saveSchedule 把整份日程配置落库到 display.schedule(读侧见 dto.ParseScheduleValue,
// 线上形状见 dto.ScheduleConfig)。
//
// 整份替换:服务端不做局部合并 —— 客户端手上就有整份配置,合并只会让两边不一致。
// 校验与清洗在 scheduleFromPayload 里做完,这里只管序列化与落库。
//
// ⚠️ 每次保存都会往 change_log 落**整份设置快照**、并让全场大屏做一次全量 resync,
// 所以这是**低频**动作(开赛前配一次、现场偶尔勾一下「已完成」),别拿它做高频轮询。
func (s *server) saveSchedule(ctx context.Context, cfg dto.ScheduleConfig, actorID int64) error {
raw, err := json.Marshal(cfg)
if err != nil {
return internal(err)
}
return s.writeSettingValues(ctx, actorID, dto.SettingKeySchedule, string(raw))
}
// ---------- 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 是榜单数组)。
// 总积分榜与单位奖牌榜都要算:前者的顺序是数组顺序,后者的名次由 medalRank 带走。
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),
domain.BuildMedalStandings(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
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 归属份额按报名项的配置补:channel_ranks 只存名次与主体(entry_id 有外键、可回查),
// 份额是报名项上的配置,引擎与榜单两边都必须从同一处读——
// 没有报名项的名次(聚合 / 团体手工)与没配份额的老数据都退回「主单位独占 100%」,
// 与 domain.ResolveShares 同一口径(老数据因此一分一牌都不变)。
shares, err := loadEntryTeamSharesQ(ctx, q, 0)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// 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,读旧列会让下钻的名次列整列变空。
// 组别与导出口径一致(export.loadTeamDetailRows 的 COALESCE(c.name, ca.name, '')):
// 「按队内平均」产出的聚合流水 entry_id 为空,取不到报名项的组别,得用 ca 回推的那个。
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, COALESCE(cat.name, ca.name),
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, ''),
l.entry_id
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{}}
// 每条流水背后那条成绩的版本身份;聚合流水 / 人工调整没有报名项,留零值(追不了就不追)。
keys := make([]revisions.Key, 0, 16)
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
entryID 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,
&entryID); 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
}
// 只有成绩流水本身带得上修订痕迹(见 revisions.KeyFor):破纪录加分 / 人工调整 / 聚合流水留零值。
key := revisions.Key{}
if item.EventID != nil {
key = revisions.KeyFor(item.SourceType, *item.EventID, entryID.Int64)
}
keys = append(keys, key)
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
if err := rows.Err(); err != nil {
return teamBreakdownDTO{}, internal(err)
}
s.attachBreakdownRevisions(ctx, out.Items, keys)
return out, nil
}
// attachBreakdownRevisions 给明细行补上修订痕迹(谁在什么时候因为什么改过这条成绩)。
//
// 查不出来只记日志、不报错:修订痕迹是「解释这一分怎么来的」的补充信息,追溯失败不该让
// 整张明细打不开 —— 现场要看的是分,不是角标。此时字段保持 nil,前端显示为空。
//
// 操作人姓名只发给 recorder 及以上:这个接口是公开的(大屏也读它),而「谁点错的」
// 属于裁判自己要看的账,不该跟着 JSON 一起下发给未登录的访客 —— 与 /api/revisions
// 只对 recorder 开放是同一个口径。时间与原因照发:那是给观众的解释,不是人名的账。
func (s *server) attachBreakdownRevisions(ctx context.Context, items []breakdownItemDTO, keys []revisions.Key) {
if len(keys) == 0 {
return
}
trails, err := revisions.Load(ctx, s.ev, keys)
if err != nil {
s.logger.Warn("读取成绩修订痕迹失败,明细按「未修订」展示", "err", err)
return
}
showActor := middleware.User(ctx).Role.Allows(auth.RoleRecorder)
actorIDs := make([]int64, 0, 4)
byActor := make(map[int]int64)
for i, key := range keys {
trail, ok := trails[key]
if !ok {
continue
}
count := trail.Count
items[i].RevisionCount = &count
if count == 0 {
continue
}
at := trail.At
items[i].LastRevisionAt = &at
if trail.Reason != "" {
reason := trail.Reason
items[i].LastRevisionReason = &reason
}
if trail.ActorID != nil && showActor {
actorIDs = append(actorIDs, *trail.ActorID)
byActor[i] = *trail.ActorID
}
}
if len(actorIDs) == 0 || s.sys == nil {
return
}
names, err := s.userNames(ctx, actorIDs)
if err != nil {
s.logger.Warn("读取修订操作人姓名失败,只显示时间与原因", "err", err)
return
}
for i, id := range byActor {
if name := names[id]; name != "" {
items[i].LastRevisedBy = &name
}
}
}
// ---------- 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
}