Files
Scoreboard/server/internal/httpapi/repo_misc.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

1138 lines
39 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 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致;
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径。
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
rows, err := q.QueryContext(ctx,
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN events ev ON ev.id = r.event_id AND ev.scored = 1
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RankedResult, 0)
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, internal(err)
}
rr.Rank = rank
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
}
return out, rows.Err()
}
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
team, err := s.getTeam(ctx, teamID)
if err != nil {
return teamBreakdownDTO{}, err
}
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
// 其中仅记单位的直加,记到个人的乘折算系数。
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
}
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
FROM score_ledger l
LEFT JOIN events ev ON ev.id = l.event_id
LEFT JOIN entries e ON e.id = l.entry_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN results r ON r.id = l.result_id
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
ORDER BY l.id DESC`, teamID)
if err != nil {
return teamBreakdownDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
eventCode, eventName sql.NullString
categoryName, athleteName sql.NullString
rank sql.NullInt64
resultText sql.NullString
)
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
return teamBreakdownDTO{}, internal(err)
}
item.EventID = i64Ptr(eventID)
item.EventCode = strPtr(eventCode)
item.EventName = strPtr(eventName)
item.CategoryName = strPtr(categoryName)
item.AthleteID = i64Ptr(athleteID)
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
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 ----------
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
holder_name, holder_team_id, achieved_at, is_current, source
FROM records WHERE is_current = 1`
args := []any{}
if eventID > 0 {
query += ` AND event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY event_id, level`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]recordEntryDTO, 0)
for rows.Next() {
var (
r recordEntryDTO
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return nil, internal(err)
}
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) {
if in.EventID == 0 || in.EntryID == 0 || in.Level == "" {
return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil)
}
switch in.Level {
case "meet", "city", "province", "national":
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
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) {
var (
b recordBreakDTO
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
FROM record_breaks WHERE id = ?`, id).
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
b.ApprovedBy = i64Ptr(approvedBy)
b.ApprovedAt = strPtr(approvedAt)
return b, nil
}
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordBreakDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
state string
eventID int64
entryID int64
level string
resultVal sql.NullFloat64
resultText sql.NullString
)
if err := tx.QueryRowContext(ctx,
`SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id).
Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
return recordBreakDTO{}, internal(err)
}
if state != "pending" {
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return recordBreakDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
return recordBreakDTO{}, err
}
// 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。
if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil {
return recordBreakDTO{}, err
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET bonus_points = COALESCE((
SELECT SUM(points) FROM score_ledger
WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0)
WHERE id = ?`, eventID, entryID, id); err != nil {
return recordBreakDTO{}, internal(err)
}
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return recordBreakDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordBreakDTO{}, internal(err)
}
s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"})
return s.getRecordBreak(ctx, id)
}
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
var (
teamID int64
athleteID sql.NullInt64
athleteName sql.NullString
teamName sql.NullString
)
if err := tx.QueryRowContext(ctx,
`SELECT e.team_id, e.athlete_id, a.name, t.name
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
LEFT JOIN teams t ON t.id = e.team_id
WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("报名项不存在")
}
return internal(err)
}
holder := teamName.String
if athleteID.Valid && athleteName.String != "" {
holder = athleteName.String
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
eventID, level); err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`,
eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil {
return internal(err)
}
return nil
}
// ---------- adjustments ----------
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if teamID > 0 {
where = append(where, "team_id = ?")
args = append(args, teamID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]adjustmentDTO, 0)
for rows.Next() {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return adjustmentDTO{}, err
}
if !boolDefault(values["manual_adjustment.enabled"], false) {
return adjustmentDTO{}, stateInvalid("人工调整当前未启用")
}
if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" {
return adjustmentDTO{}, badRequest("reason 必填", nil)
}
switch in.Category {
case "penalty", "bonus", "correction":
default:
return adjustmentDTO{}, badRequest("category 非法", in.Category)
}
if in.TeamID == 0 {
return adjustmentDTO{}, badRequest("teamId 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID))
if isForeignKeyViolation(err) {
return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil)
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by)
VALUES (?, ?, 'adjustment', ?, ?, ?)`,
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
return adjustmentDTO{}, internal(err)
}
// 调整分进积分榜,必须写版本号,否则大屏不会刷新。
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.create", "adjustment", id, nil, in)
return s.getAdjustment(ctx, id)
}
func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments WHERE id = ?`, id).
Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
return a, nil
}
func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var voided bool
if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
return adjustmentDTO{}, internal(err)
}
if voided {
return adjustmentDTO{}, stateInvalid("该人工调整已作废")
}
if _, err := tx.ExecContext(ctx,
`UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`,
nullID(userID), reason, id); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason})
return s.getAdjustment(ctx, id)
}
// ---------- audit logs ----------
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if entityType != "" {
where = append(where, "entity_type = ?")
args = append(args, entityType)
}
if userID > 0 {
where = append(where, "user_id = ?")
args = append(args, userID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at
FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]auditLogDTO, 0)
for rows.Next() {
var (
a auditLogDTO
uid, eid sql.NullInt64
before sql.NullString
after sql.NullString
ip, reqID sql.NullString
)
if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
a.UserID = i64Ptr(uid)
a.EntityID = i64Ptr(eid)
a.BeforeJSON = strPtr(before)
a.AfterJSON = strPtr(after)
a.IP = strPtr(ip)
a.RequestID = strPtr(reqID)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// ---------- backup ----------
// backup 生成系统库与赛事库的一致性快照(见 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
}