- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
147 lines
5.0 KiB
Go
147 lines
5.0 KiB
Go
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|