Files
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

984 lines
38 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 export
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/revisions"
)
// Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。
type Meta struct {
EventName string
Subtitle string
Organizer string
GeneratedAt time.Time
// GeneratedBy 是「导出人 XXX」那一行的取值。当前没有任何生产写入口(所有调用方都只填 EventName 等),
// 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。
// 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。
GeneratedBy string
// Note 是附在副标题后面的口径说明,由 Load* 按数据内容补(当前只有「本赛事含联队」时才会写:
// 奖牌每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数)。
// 留空时副标题与改动前逐字一致——无联队的老导出件不能因为加了这个字段而变形。
Note string
}
func (m Meta) normalized() Meta {
if m.GeneratedAt.IsZero() {
m.GeneratedAt = time.Now()
}
return m
}
// Options 是导出选项;零值即「正式成绩」口径。
type Options struct {
Meta Meta
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
//
// 当前没有生产写入口:HTTP 层所有导出 handler 都传 Options{},只有单测(load_test.go 的
// TestEventRowsIncludeDraftsKeepsDraftValues)设过 true,所以这个分支在生产不可达(代码卫生项 C30)。
// 保留它是因为「草稿值不外露」的口径必须有对照面才能被测住;真要接线请先想清楚
// 「谁能看到别人的草稿值」这个权限问题(现在连只读接口都不外露草稿)。
IncludeDrafts bool
}
// resolveMeta 读一次 settings,把留空的抬头字段补齐;顺带把设置表返回给调用方复用。
func resolveMeta(db *sql.DB, meta Meta) (Meta, map[string]string, error) {
settings, err := loadSettings(db)
if err != nil {
return Meta{}, nil, err
}
if meta.EventName == "" {
meta.EventName = settings["event.name"]
}
if meta.Subtitle == "" {
meta.Subtitle = settings["event.subtitle"]
}
if meta.Organizer == "" {
meta.Organizer = settings["event.organizer"]
}
return meta.normalized(), settings, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("export: 读取赛事设置: %w", 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, fmt.Errorf("export: 扫描赛事设置: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历赛事设置: %w", err)
}
return out, nil
}
// LoadStandingsWorkbook 读取单位、运动员、积分流水与名次,交给 domain 算出三个榜。
// 排名规则只在 domain 里有一份,这里不做任何二次计算。
func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error) {
if db == nil {
return StandingsWorkbook{}, ErrNoDatabase
}
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return StandingsWorkbook{}, err
}
// 排名输入只含启用单位,与 REST 一致。
teams, err := loadTeams(db, true)
if err != nil {
return StandingsWorkbook{}, err
}
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
allTeams, err := loadTeams(db, false)
if err != nil {
return StandingsWorkbook{}, err
}
athletes, err := loadAthletes(db)
if err != nil {
return StandingsWorkbook{}, err
}
ledger, err := loadLedger(db)
if err != nil {
return StandingsWorkbook{}, err
}
ranked, err := loadRanked(db)
if err != nil {
return StandingsWorkbook{}, err
}
in := domain.StandingsInput{
Teams: teams,
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitSettingList(settings["standings.tie_break"]),
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
}
team := domain.BuildTeamStandings(in)
medal := domain.BuildMedalStandings(in)
athlete := domain.BuildAthleteStandings(in)
fillTeamDisplay(team, medal, athlete, allTeams)
// 含联队时在副标题上写清奖牌口径:每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数。
// 合计行本身不改(那是定稿口径),只把那个数字解释清楚。
joints, err := loadJointTeams(db)
if err != nil {
return StandingsWorkbook{}, err
}
if len(joints) > 0 {
meta.Note = jointMedalNote
}
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
query := `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams`
if activeOnly {
query += ` WHERE active = 1`
}
query += ` ORDER BY sort_order, code`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Team
for rows.Next() {
var t domain.Team
var active int
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
return nil, fmt.Errorf("export: 扫描单位: %w", err)
}
t.Active = active != 0
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历单位: %w", err)
}
return out, nil
}
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
byID := make(map[int64]domain.Team, len(teams))
for _, t := range teams {
byID[t.ID] = t
}
for i := range team {
if t, ok := byID[team[i].TeamID]; ok {
if team[i].Code == "" {
team[i].Code = t.Code
}
if team[i].Name == "" {
team[i].Name = t.Name
}
if team[i].ShortName == "" {
team[i].ShortName = t.ShortName
}
}
}
for i := range medal {
if t, ok := byID[medal[i].TeamID]; ok {
if medal[i].Code == "" {
medal[i].Code = t.Code
}
if medal[i].Name == "" {
medal[i].Name = t.Name
}
if medal[i].ShortName == "" {
medal[i].ShortName = t.ShortName
}
}
}
for i := range athlete {
if t, ok := byID[athlete[i].TeamID]; ok {
if athlete[i].TeamCode == "" {
athlete[i].TeamCode = t.Code
}
if athlete[i].TeamName == "" {
athlete[i].TeamName = t.Name
}
}
}
}
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
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
}
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
rows, err := db.Query(`
SELECT id, name, COALESCE(gender, ''), COALESCE(team_id, 0), COALESCE(employee_no, ''), active
FROM athletes ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取运动员: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Athlete
for rows.Next() {
var a domain.Athlete
var active int
if err := rows.Scan(&a.ID, &a.Name, &a.Gender, &a.TeamID, &a.EmployeeNo, &active); err != nil {
return nil, fmt.Errorf("export: 扫描运动员: %w", err)
}
a.Active = active != 0
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历运动员: %w", err)
}
return out, nil
}
func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
rows, err := db.Query(`
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0),
source_type, source_id, points, voided, COALESCE(void_reason, ''), count_to_team
FROM score_ledger ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取积分流水: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.LedgerEntry
for rows.Next() {
var (
e domain.LedgerEntry
voided int
countToTeam int
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID,
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason, &countToTeam); err != nil {
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
}
e.Voided = voided != 0
e.CountToTeam = countToTeam != 0
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分流水: %w", err)
}
return out, nil
}
// loadRanked 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。
// 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次,
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。
// 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals),
// domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
rows, err := db.Query(`
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
JOIN event_score_channels c ON c.id = cr.channel_id
LEFT JOIN athletes a ON a.id = cr.athlete_id
WHERE ev.scored = 1 AND 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, fmt.Errorf("export: 读取名次: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.RankedResult
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
status sql.NullString
awardMedals int
countToMedals int
countToTeamMed 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, &countToTeamMed); err != nil {
return nil, fmt.Errorf("export: 扫描名次: %w", 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 = countToTeamMed != 0
out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
}
// 归属份额按报名项配置补(联队:一条成绩归多个单位):与 REST 的 buildRanked、
// 快照的 loadRanked 同口径。没有报名项的名次与没配份额的老数据都退回「主单位独占 100%」,
// team_id 为空时不造份额,免得给 0 号单位凭空记牌。
shares, err := loadEntryTeamShares(db)
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
}
// loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。
// 与 HTTP 侧的 loadEntryTeamSharesQ 同一份表、同一套排序:展示与流水顺序要稳定。
//
// 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库
// 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。
func loadEntryTeamShares(db *sql.DB) (map[int64][]domain.TeamShare, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return nil, fmt.Errorf("export: 查询报名份额表: %w", err)
}
if n == 0 {
return nil, nil
}
rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share
FROM entry_team_shares
ORDER BY entry_id, sort_order, team_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名份额: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, fmt.Errorf("export: 扫描报名份额: %w", err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
sportName string
name string
}
// LoadEventWorkbook 读取一个小项(eventID=0 表示全部)的成绩,按组别拆成多个 sheet。
func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, error) {
if db == nil {
return EventsWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return EventsWorkbook{}, err
}
events, err := loadEvents(db, eventID)
if err != nil {
return EventsWorkbook{}, err
}
if eventID != 0 && len(events) == 0 {
return EventsWorkbook{}, fmt.Errorf("export: 小项 %d 不存在", eventID)
}
sheets := make([]EventSheet, 0, len(events))
// 联队配置与队员关联各读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。
joints, err := loadJointTeams(db)
if err != nil {
return EventsWorkbook{}, err
}
members, err := loadEntryMembers(db)
if err != nil {
return EventsWorkbook{}, err
}
for _, ev := range events {
cats, err := loadCategories(db, ev.id)
if err != nil {
return EventsWorkbook{}, err
}
if len(cats) == 0 {
rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{SportName: ev.sportName, EventName: ev.name, Rows: rows})
continue
}
for _, cat := range cats {
rows, err := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{
SportName: ev.sportName,
EventName: ev.name,
Category: cat.name,
Rows: rows,
})
}
}
return EventsWorkbook{Meta: meta, Sheets: sheets}, nil
}
func loadEvents(db *sql.DB, eventID int64) ([]eventInfo, error) {
query := `
SELECT e.id, COALESCE(s.name, ''), e.name
FROM events e
LEFT JOIN sports s ON s.id = e.sport_id`
args := []any{}
if eventID != 0 {
query += ` WHERE e.id = ?`
args = append(args, eventID)
}
query += ` ORDER BY s.sort_order, e.sort_order, e.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
var out []eventInfo
for rows.Next() {
var ev eventInfo
if err := rows.Scan(&ev.id, &ev.sportName, &ev.name); err != nil {
return nil, fmt.Errorf("export: 扫描小项: %w", err)
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历小项: %w", err)
}
return out, nil
}
type categoryInfo struct {
id int64
name string
}
func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
rows, err := db.Query(`
SELECT id, name FROM event_categories
WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, fmt.Errorf("export: 读取组别: %w", err)
}
defer func() { _ = rows.Close() }()
var out []categoryInfo
for rows.Next() {
var c categoryInfo
if err := rows.Scan(&c.id, &c.name); err != nil {
return nil, fmt.Errorf("export: 扫描组别: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历组别: %w", err)
}
return out, nil
}
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
//
// 队员关联在这里自己读一次。LoadEventWorkbook 逐张小项 / 组别表地调它,所以那边走的是
// loadEventRowsWithMembers(读一次给全部表复用),这条只是给单表调用与既有用例留的入口。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) {
members, err := loadEntryMembers(db)
if err != nil {
return nil, err
}
return loadEventRowsWithMembers(db, eventID, categoryID, includeDrafts, joints, members)
}
// loadEventRowsWithMembers 是 loadEventRows 的本体:队员关联由调用方读好传进来。
func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam, members map[int64][]entryMember) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。
//
// ⚠️ 这段子查询与 realtime/snapshot.go 的 mainChannelRankJoin、httpapi/live_events.go 的同名常量
// 同源(大屏两个读者),改一处要改三处。优先级**故意不同**:导出这里是 cr 优先(既有口径,
// 由 TestEventRowsUseMainChannelRank 钉住),大屏那边是 results.rank 优先——「只更新名次」的发布
// 只写 results.rank、不重建 channel_ranks,大屏 cr 一优先就会显示上一次的旧名次。
//
// 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN:
// 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份
// 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。
// 取最小 id = 报名项那一份(引擎先产出它),名次值与升级前完全一致;
// 老数据每 (通道, 组别, 报名项) 最多一行,两种写法逐字节等价。
//
// gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源:
// 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩),
// 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。
// 延迟结算(standings.live_on_publish=false)下两者不等就说明这一组还没结算,
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。
query := `
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''),
r.result_value IS NOT NULL,
COALESCE(gs.required, 0), COALESCE(gs.published, 0), en.id
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
LEFT JOIN results r ON r.entry_id = en.id
LEFT JOIN event_score_channels mc ON mc.id = (
SELECT c2.id FROM event_score_channels c2
WHERE c2.event_id = en.event_id
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.id = (
SELECT MIN(cr2.id) FROM channel_ranks cr2
WHERE cr2.channel_id = mc.id AND cr2.entry_id = en.id AND cr2.category_id = en.category_id)
LEFT JOIN (
SELECT e2.event_id, e2.category_id, COUNT(*) AS required,
SUM(CASE WHEN r2.state = 'published'
AND (r2.result_value IS NOT NULL OR r2.rank IS NOT NULL OR r2.status <> 'ok')
THEN 1 ELSE 0 END) AS published
FROM entries e2
LEFT JOIN results r2 ON r2.entry_id = e2.id
WHERE e2.event_id = ? AND e2.status = 'registered'
GROUP BY e2.event_id, e2.category_id
) gs ON gs.event_id = en.event_id AND gs.category_id = en.category_id
WHERE en.event_id = ?`
// 子查询里那个 ? 是同一项(先给 gs 的 event_id,再给外层 WHERE)。
args := []any{eventID, eventID}
if categoryID != 0 {
query += ` AND en.category_id = ?`
args = append(args, categoryID)
}
// 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。
// 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行,
// 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取成绩: %w", err)
}
defer func() { _ = rows.Close() }()
var out []EventResultRow
for rows.Next() {
var (
row EventResultRow
rank, isTie int
memberJSON, athleteName string
squad string
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
hasValue int
required, published int
entryID int64
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &squad, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantLabel(athleteName, memberJSON, members[entryID])
row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote)
// 联队:单位列写全成员单位(「第一分公司+第二分公司联队」),备注里写清分给了谁、各多少。
// 普通报名(含迁移回填的单条独占份额)不走这里,单位名与备注与改动前逐字一致。
if joint, ok := joints[entryID]; ok {
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareNote())
}
// 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名,
// 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。
row.TeamName = withSquad(row.TeamName, squad)
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
row.NoResult = !resultState.Valid
row.Unpublished = !includeDrafts && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStateDraft
// 已发布但整组还没结算:成绩上屏了,名次与积分要等结算(见 Unsettled 的说明)。
// 这里的条件缺一不可,少任何一个都会误伤——
// published < required 且都非 0:这一组确实还没结算(与 /api/results/settlement 同判据);
// **state 是 published**:草稿行不算(它没有对外,未发布口径已经把它划走了;
// includeDrafts=true 时草稿行连值一起导出,也必须按草稿呈现,不能被这一分支抢走);
// 有成绩值、状态是 ok:这才是「等名次」的那种行;犯规 / 弃权行本来就不占名次,
// 把它们的「犯规」改写成「未结算」会把现场明确的判罚结果弄丢;
// 名次与积分都空:实时结算(默认)下分批发布也会出现 published < required,
// 但那一批已经结算过、名次与积分都在,不能标成未结算。
row.Unsettled = published > 0 && published < required &&
!row.NoResult && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStatePublished &&
hasValue != 0 && rank <= 0 && row.Points == 0 &&
domain.ResultStatus(resultStat.String) == domain.ResultStatusOK
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历成绩: %w", err)
}
return out, nil
}
// LoadTeamDetailWorkbook 读取某单位未被作废的积分流水,逐条列出得分来源。
func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailWorkbook, error) {
if db == nil {
return TeamDetailWorkbook{}, ErrNoDatabase
}
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return TeamDetailWorkbook{}, err
}
team, err := loadTeam(db, teamID)
if err != nil {
return TeamDetailWorkbook{}, err
}
joints, err := loadJointTeams(db)
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID, joints)
if err != nil {
return TeamDetailWorkbook{}, err
}
return TeamDetailWorkbook{
Meta: meta,
Team: team,
Rows: rows,
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
}, nil
}
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
var t domain.Team
var active int
err := db.QueryRow(`
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE id = ?`, teamID).
Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active)
if err == sql.ErrNoRows {
return domain.Team{}, fmt.Errorf("%w: %d", ErrTeamNotFound, teamID)
}
if err != nil {
return domain.Team{}, fmt.Errorf("export: 读取单位 %d: %w", teamID, err)
}
t.Active = active != 0
return t, nil
}
// teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
//
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
// - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id,
// 就拿它 + 主体(单位 / 运动员)关联。
//
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
const teamDetailRankExpr = `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 cr.category_id = COALESCE(c.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), COALESCE(r.rank, 0))`
func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([]TeamDetailRow, error) {
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
// 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。
// 参加了多个组别时回推不出来(同一 (小项, 单位) 有多条无法区分的聚合流水),宁可留空也不猜。
// 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
COALESCE(r.unit, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0),
COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0), COALESCE(en.squad, '')
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
LEFT JOIN entries en ON en.id = l.entry_id
LEFT JOIN event_categories c ON c.id = en.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 results r ON r.id = l.result_id
LEFT JOIN athletes a ON a.id = l.athlete_id
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order,
COALESCE(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`,
teamID, teamID)
if err != nil {
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
}
defer func() { _ = rows.Close() }()
var out []TeamDetailRow
// 每条流水背后的成绩身份((小项, 报名项),口径见 internal/revisions),用来补「备注」列的修订痕迹。
keys := make([]revisions.Key, 0, len(out))
for rows.Next() {
var r TeamDetailRow
var source string
var athleteID, eventID, entryID int64
var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID,
&r.squad); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
// 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
// 这条流水来自联队时说明份额:本单位拿到的是拆给它的那一份(积分列已经是拆后的值),
// 备注里再把「分给了哪几个单位、各多少」一次写全,核对时不必回头查另一张表。
if joint, ok := joints[entryID]; ok {
r.Remark = appendNote(r.Remark, joint.shareNote())
}
r.individual = athleteID != 0
keys = append(keys, revisions.KeyFor(source, eventID, entryID))
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分明细: %w", err)
}
attachRevisionRemarks(out, keys, loadRevisionTrails(db, keys))
return out, nil
}
// loadRevisionTrails 取这些成绩的修订痕迹;查不出来只留空,导出照旧出。
//
// 导出件与屏幕上写的必须是同一句话(口径见 internal/revisions):明细在屏幕上挂着
// 「已修订 ×2」,xlsx 里一个字没有,事后拿表格对账的人就不知道该信哪份。
func loadRevisionTrails(db *sql.DB, keys []revisions.Key) map[revisions.Key]revisions.Trail {
trails, err := revisions.Load(context.Background(), db, keys)
if err != nil {
return nil
}
return trails
}
// attachRevisionRemarks 把修订痕迹写进「备注」列:`已修订 ×2;最后一次 10:42 成绩录入笔误`。
//
// 没改过(或追不出来)的行备注留空 —— 这个列本来只写「作废理由」,而作废的流水不会出现在这里,
// 所以正常行本来就是空白,只有真被改过的成绩才会多出一句话。
func attachRevisionRemarks(rows []TeamDetailRow, keys []revisions.Key, trails map[revisions.Key]revisions.Trail) {
for i, key := range keys {
if i >= len(rows) {
return
}
trail, ok := trails[key]
if !ok || trail.Count == 0 {
continue
}
text := fmt.Sprintf("已修订 ×%d", trail.Count)
if clock := revisionClock(trail.At); clock != "" {
text += ";最后一次 " + clock
if trail.Reason != "" {
text += " " + trail.Reason
}
} else if trail.Reason != "" {
text += ";" + trail.Reason
}
if rows[i].Remark == "" {
rows[i].Remark = text
} else {
rows[i].Remark += ";" + text
}
}
}
// revisionClock 把 result_revisions.created_at 转成 `HH:mm`(本地时区)。
//
// 库里存的是 datetime('now') 的 UTC 但不带时区,按 UTC 解析再转本地 —— 与前端
// shared/format.ts 的 toDate 同一口径,否则导出件上的 10:42 会比大屏早 8 小时。
func revisionClock(at string) string {
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(at), time.UTC)
if err != nil {
return ""
}
return parsed.Local().Format("15:04")
}
func rankLabel(rank int, isTie bool) string {
if rank <= 0 {
return ""
}
if isTie {
return strconv.Itoa(rank) + "(并列)"
}
return strconv.Itoa(rank)
}
// statusLabel 把成绩状态翻成中文;没有成绩行时退回报名状态。
func statusLabel(resultStatus, resultState sql.NullString, entryStatus string) string {
if !resultStatus.Valid {
return entryStatusLabel(entryStatus)
}
if resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft {
return "草稿"
}
switch domain.ResultStatus(resultStatus.String) {
case domain.ResultStatusOK, "":
return "有效"
case domain.ResultStatusDQ:
return "犯规"
case domain.ResultStatusDNS:
return "弃权"
case domain.ResultStatusDNF:
return "未完成"
case domain.ResultStatusWithdrawn:
return "退赛"
default:
return resultStatus.String
}
}
// joinRemark 合并报名备注与成绩备注,两条都有时用分号隔开。
func joinRemark(entryRemark, resultNote string) string {
entryRemark = strings.TrimSpace(entryRemark)
resultNote = strings.TrimSpace(resultNote)
switch {
case entryRemark == "":
return resultNote
case resultNote == "":
return entryRemark
default:
return entryRemark + ";" + resultNote
}
}
func entryStatusLabel(status string) string {
switch domain.EntryStatus(status) {
case domain.EntryStatusRegistered, "":
return "未录入"
case domain.EntryStatusScratched:
return "弃权"
case domain.EntryStatusDQ:
return "犯规"
case domain.EntryStatusDNS:
return "弃权"
default:
return status
}
}
func sourceLabel(source string) string {
switch domain.SourceType(source) {
case domain.SourceResult:
return "成绩"
case domain.SourceRecordBonus:
return "破纪录加分"
case domain.SourceAdjustment:
return "人工调整"
default:
return source
}
}
func parseFloatDefault(raw string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return def
}
return v
}
// participantName 取个人姓名;团体项目则把成员 JSON 数组拼成顿号列表。
func participantName(athleteName, memberJSON string) string {
if strings.TrimSpace(athleteName) != "" {
return athleteName
}
if names := memberNamesOf(memberJSON); names != nil {
return strings.Join(names, "、")
}
// 空值或坏 JSON:原样写出来(历史上 member_names 被手工写成别的样子也不能丢字)。
return strings.TrimSpace(memberJSON)
}
// memberNamesOf 解析 member_names(库里是 JSON 数组文本);空值、坏 JSON、空数组都返回 nil。
//
// **每一项原样返回**(不去空白、不丢空串):它直接写进老数据的导出件,改动它等于改动老导出件
// 的每一个字。新的队员路径在合并文字名单时自己决定怎么去空白(见 participantLabel)。
func memberNamesOf(memberJSON string) []string {
memberJSON = strings.TrimSpace(memberJSON)
if memberJSON == "" {
return nil
}
var raw []string
if err := json.Unmarshal([]byte(memberJSON), &raw); err != nil || len(raw) == 0 {
return nil
}
return raw
}