// 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"` } // EventSettingsStandings 是榜单口径配置。 type EventSettingsStandings struct { IndividualToTeamWeight float64 `json:"individualToTeamWeight"` RankingMode string `json:"rankingMode"` ShowIndividualBoard bool `json:"showIndividualBoard"` ShowIndividualMedals bool `json:"showIndividualMedals"` } // WithoutAdminFields 返回非 admin 会话可见的配置子集。 // // GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder // 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算 // 系数属于系统配置,必须置空/清零后再上线。 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, }, } } // 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) 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.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 } }