Files
Scoreboard/server/internal/dto/settings.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

270 lines
11 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 dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
//
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
// 本包只依赖 domain 与标准库,不做任何 IO。
package dto
import (
"strconv"
"strings"
)
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
// 与 web/src/shared/types.ts 的 EventSettings 一一对应。
type EventSettings struct {
Event EventSettingsEvent `json:"event"`
Display EventSettingsDisplay `json:"display"`
Standings EventSettingsStandings `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval bool `json:"requireApproval"`
}
// EventSettingsEvent 是赛事基本信息。
type EventSettingsEvent struct {
Name string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
// EventSettingsDisplay 是大屏展示配置。
type EventSettingsDisplay struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSecond int `json:"rotateSeconds"`
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
Announce *string `json:"announce"`
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
// LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。
// 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。
LiveEvents []int64 `json:"liveEvents"`
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
LiveSource string `json:"liveSource"`
}
// 「大屏实时赛况」的两个设置键与取值约束。
const (
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
SettingKeyLiveEvents = "display.live_events"
SettingKeyLiveSource = "display.live_source"
// SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。
// 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。
// 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。
SettingKeyLiveOnPublish = "standings.live_on_publish"
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
MaxLiveEvents = 2
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
LiveSourceAuto = "auto"
LiveSourceManual = "manual"
)
// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。
//
// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过,
// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。
func ParseLiveEventIDs(raw string) []int64 {
out := make([]int64, 0, MaxLiveEvents)
seen := make(map[int64]bool)
for _, part := range strings.Split(raw, ",") {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id <= 0 || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
if len(out) == MaxLiveEvents {
break
}
}
return out
}
// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。
func FormatLiveEventIDs(ids []int64) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
parts = append(parts, strconv.FormatInt(id, 10))
if len(parts) == MaxLiveEvents {
break
}
}
return strings.Join(parts, ",")
}
// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。
func NormalizeLiveSource(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", LiveSourceAuto:
return LiveSourceAuto
case LiveSourceManual:
return LiveSourceManual
default:
return ""
}
}
// EventSettingsStandings 是榜单口径配置。
type EventSettingsStandings struct {
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
RankingMode string `json:"rankingMode"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
ShowIndividualMedals bool `json:"showIndividualMedals"`
// LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。
LiveOnPublish bool `json:"liveOnPublish"`
}
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
//
// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
// 系数属于系统配置,必须置空/清零后再上线。
// Display 整块保留:其中的公告(announce)就是给大屏底栏用的展示字段,裁剪掉它会丢公告。
func (s EventSettings) WithoutAdminFields() EventSettings {
return EventSettings{
Event: s.Event,
Display: s.Display,
Standings: EventSettingsStandings{
RankingMode: s.Standings.RankingMode,
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
// 结算方式不是系统机密,而是录入员必须知道的现场行为:
// 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。
LiveOnPublish: s.Standings.LiveOnPublish,
},
}
}
// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。
//
// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings
// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。
type SessionSettings struct {
SessionHours int `json:"sessionHours"`
// DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。
DefaultHours int `json:"defaultSessionHours"`
MinHours int `json:"minHours"`
MaxHours int `json:"maxHours"`
// ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。
ExpiresAt *string `json:"expiresAt"`
}
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
type RuleSwitches struct {
RecordBonusEnabled bool `json:"recordBonusEnabled"`
AdjustmentsEnabled bool `json:"adjustmentsEnabled"`
AdjustmentReasonRequired bool `json:"adjustmentReasonRequired"`
MedalCountsTeamEvents bool `json:"medalCountsTeamEvents"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
ShowIndividualMedals bool `json:"showIndividualMedals"`
}
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
//
// 默认值必须与 REST 的 getSettings 逐条一致:大屏首包走 snapshot、管理端走 /api/settings,
// 两边口径不同会让同一块屏刷新前后显示不一样的标题。
// defaultProfileId 需要查 scoring_profiles 反查 code,属于 IO,由调用方补填。
func ParseEventSettings(values map[string]string) EventSettings {
var out EventSettings
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
out.Event.Host = nilIfEmpty(values["event.organizer"])
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
out.Display.Background = nilIfEmpty(values["display.background"])
out.Display.Logo = nilIfEmpty(values["display.logo"])
if out.Display.Logo == nil {
out.Display.Logo = out.Event.LogoPath
}
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
if out.Display.Subtitle == nil {
out.Display.Subtitle = out.Event.Subtitle
}
out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
// 字体按 id 字符串原样下发,前端解析成字体栈;空串与缺键都表示用内置默认字体。
out.Display.FontTitle = nilIfEmpty(values["display.font_title"])
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
// showIndividualBoard 是历史别名:未单独配置时沿用旧的 show_individual_medals 取值。
out.Standings.ShowIndividualBoard = boolDefault(
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
// 缺键 = 实时结算:老赛事升级后行为必须与升级前完全一致。
out.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], true)
out.RequireApproval = boolDefault(values["results.review_required"], false)
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func atoiDefault(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return def
}
return n
}
func boolDefault(v string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return def
}
}