// Package dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。 // // 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置; // 本包只依赖 domain 与标准库,不做任何 IO。 package dto import ( "encoding/json" "strconv" "strings" "unicode/utf8" ) // 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"` // Schedule 是赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。 // 库里以 display.schedule 存一份 JSON,由大屏指令写入;读不出来(缺键 / 坏 JSON)时是 null, // 大屏按「没有这个功能」处理 —— 一条脏设置绝不能让大屏整块不显示。 Schedule *ScheduleConfig `json:"schedule"` } // ---------- 赛事日程侧边栏 ---------- // SettingKeySchedule 是整份日程侧边栏配置在 settings 表里的键,值是一份 JSON。 // // 与 display.announce_text 同一套机制:由 POST /api/display/command 的 schedule 指令写入 // (PUT /api/settings 是 admin-only,而现场要求录入员也能切),随 snapshot.settings 下发大屏。 // 不新建表、不写迁移。 const SettingKeySchedule = "display.schedule" // 日程的取值上限。长度一律按**字符**(rune)算:中文标题按字节算会被切出半个字。 const ( // MaxScheduleItems 是日程条目数上限:一场赛事的日程排不下更多,也挡住「整屏全是条目」这种 // 把大屏刷爆的输入。 MaxScheduleItems = 40 // MaxScheduleTimeChars / TitleChars / NoteChars 是单项三个文本字段的字符数上限。 MaxScheduleTimeChars = 20 MaxScheduleTitleChars = 40 MaxScheduleNoteChars = 60 // MaxScheduleOverrides 是单屏覆盖的条数上限(键是实例 id)。 MaxScheduleOverrides = 50 // MaxScheduleOverrideKeyChars 是实例 id 的字符数上限,与设备自报标签同一量级。 MaxScheduleOverrideKeyChars = 120 // 侧边栏宽度按「占屏百分比」存,夹在这个区间:更窄放不下字,更宽会盖住大屏正文。 MinScheduleWidthPercent = 15 MaxScheduleWidthPercent = 40 DefaultScheduleWidthPercent = 22 ) // 侧边栏贴哪一边。认不出的取值一律回落 ScheduleSideRight(与默认口径一致)。 const ( ScheduleSideLeft = "left" ScheduleSideRight = "right" ) // 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。 const ( ScheduleOverrideInherit = "inherit" ScheduleOverrideOn = "on" ScheduleOverrideOff = "off" ) // ScheduleItem 是日程里的一条。**手填**,与小项、报名无关,不参与算分。 // // Time 是自由文本(「08:30」「上午」「待定」都行),**允许为空**(先排标题、时间回头补): // 服务端不解析、不比较、不排序,列表顺序就是排定的顺序(「当前项」= 第一个 done=false 的项, // 由大屏算)。空的只是不上屏显示时间那一格。管理端 scheduleEditor.ts 的 validateSchedule // 同一口径:时间只卡长度,标题才必填。 // note / done 是可缺字段,缺省即「没有备注」「未完成」。 type ScheduleItem struct { Time string `json:"time"` Title string `json:"title"` Note string `json:"note,omitempty"` Done bool `json:"done,omitempty"` } // ScheduleConfig 是整份日程侧边栏配置。保存是**整份替换**,服务端不做局部合并 // (客户端手上就有整份配置,合并只会让两边不一致)。 // // InstanceOverrides 按**实例 id**(GET /api/display/instances 的 id)覆盖总开关; // 缺省即「全部 inherit」,且 `inherit` 不进 map(它就是「没有这条设置」,见下面两处解析)。 // 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。 type ScheduleConfig struct { Items []ScheduleItem `json:"items"` Visible bool `json:"visible"` Side string `json:"side"` WidthPercent float64 `json:"widthPercent"` // ShowNote 缺省 = **true**(备注默认上屏,与管理端表单、大屏解析两边一致)。 // 缺键时不能吃 Go 的零值 false,收口在 ScheduleShowNote。 ShowNote bool `json:"showNote"` InstanceOverrides map[string]string `json:"instanceOverrides,omitempty"` } // ScheduleShowNote 从原始 JSON 对象里读 showNote,缺省回落 **true**(备注默认上屏)。 // // 为什么单独解一次而不是直接吃 ScheduleConfig 的 bool:**bool 的零值分不出「键不存在」与 // 「显式 false」**,而这两者的语义完全不同 —— 库里的 JSON 只要没带这个键(别的客户端写的、 // 手工改库、老数据),零值就会让服务端下发 false,备注在没有任何人关过它的前提下莫名不上屏。 // 管理端默认 true、大屏解析 `showNote !== false`(也是 true),服务端必须同口径。 // // 值为 `null` 或不是布尔时同样回落 true(与前端两边的「不是 false 就当开」一致)。 // 解成 `*bool` 而不是 `bool` 正是为了这个:Go 把 JSON `null` 解进 `bool` 是**静默不动**、 // 也不报错,用值类型接就会把它当 false。 func ScheduleShowNote(raw []byte) bool { var obj map[string]json.RawMessage if err := json.Unmarshal(raw, &obj); err != nil { return true } value, ok := obj["showNote"] if !ok { return true } var b *bool if err := json.Unmarshal(value, &b); err != nil || b == nil { return true } return *b } // NormalizeScheduleSide 归一左右取值:只认 left / right,其它(含空串)回落 right。 func NormalizeScheduleSide(raw string) string { if strings.ToLower(strings.TrimSpace(raw)) == ScheduleSideLeft { return ScheduleSideLeft } return ScheduleSideRight } // NormalizeScheduleWidth 把宽度夹到 [MinScheduleWidthPercent, MaxScheduleWidthPercent]; // 缺省(非正数)回落 DefaultScheduleWidthPercent。 // // 夹取而不是报错:宽度越界多半是前端算错了一个数,为它让整份日程存不下去不划算, // 而且夹到一个「能看」的值没有信息损失。 func NormalizeScheduleWidth(percent float64) float64 { switch { case percent <= 0: return DefaultScheduleWidthPercent case percent < MinScheduleWidthPercent: return MinScheduleWidthPercent case percent > MaxScheduleWidthPercent: return MaxScheduleWidthPercent } return percent } // ParseScheduleValue 解析 settings 里的 display.schedule(**读入口,永不报错**)。 // // 这个键可能被手工改过、被别的版本写过、或者写到一半断电,所以口径与 record_bonus.levels、 // ParseLiveEventIDs 一致:坏 JSON 整个当「没有这个功能」(返回 nil),结构上不成立的那几条 // (**标题**缺失的条目、非三态的覆盖、认不出的左右)丢掉,条数取前 MaxScheduleItems / // MaxScheduleOverrides 条。**一条脏设置绝不能让大屏整块不显示**。 // // 与写入口(httpapi 的 scheduleFromPayload)刻意不对称:那边越界报 400 而不是静默截断, // 因为那是人在操作、要立刻知道哪一项不合规;这边面对的是已经躺在库里的数据,报错没有出口。 // // 两处「缺省不等于零值」的收口都在这里:showNote 缺键当 **true**(见 ScheduleShowNote), // `inherit` 的覆盖当「没有这条设置」丢掉(前端两边解析也是这么做的)。 // // 文本只去首尾空白、不剥控制字符:写入口已经清洗过,老数据里万一还留着换行, // 前端按普通空白渲染,不足以炸大屏;而读入口反向改写内容会掩盖数据被谁改坏过。 func ParseScheduleValue(raw string) *ScheduleConfig { if strings.TrimSpace(raw) == "" { return nil } var cfg ScheduleConfig if err := json.Unmarshal([]byte(raw), &cfg); err != nil { return nil } items := make([]ScheduleItem, 0, len(cfg.Items)) for _, it := range cfg.Items { it.Time = strings.TrimSpace(it.Time) it.Title = strings.TrimSpace(it.Title) it.Note = strings.TrimSpace(it.Note) // 只认标题必填:标题是列表主行,缺了就是一条空白线,整条丢掉。**时间可以为空** // (写入口允许,管理端也允许),空串照样保留这条,大屏只是不画时间那一格。 if it.Title == "" { continue } items = append(items, it) if len(items) == MaxScheduleItems { break } } var overrides map[string]string if len(cfg.InstanceOverrides) > 0 { overrides = make(map[string]string, len(cfg.InstanceOverrides)) for id, value := range cfg.InstanceOverrides { id = strings.TrimSpace(id) if id == "" || utf8.RuneCountInString(id) > MaxScheduleOverrideKeyChars { continue } // inherit 是「没有这条设置」,缺省就是它:存着只会白占 MaxScheduleOverrides // 的条数预算(管理端与大屏解析都会把这种条目丢掉,两边不会照原样再写回来)。 switch value { case ScheduleOverrideOn, ScheduleOverrideOff: default: continue } overrides[id] = value if len(overrides) == MaxScheduleOverrides { break } } if len(overrides) == 0 { overrides = nil } } return &ScheduleConfig{ Items: items, Visible: cfg.Visible, Side: NormalizeScheduleSide(cfg.Side), WidthPercent: NormalizeScheduleWidth(cfg.WidthPercent), ShowNote: ScheduleShowNote([]byte(raw)), InstanceOverrides: overrides, } } // 「大屏实时赛况」的两个设置键与取值约束。 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]) // 赛事日程侧边栏:由大屏指令写入(recorder 也能推),缺键 / 坏 JSON 一律 null。 out.Display.Schedule = ParseScheduleValue(values[SettingKeySchedule]) 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 } }