feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)

- 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 配置的说明与实现对齐
This commit is contained in:
AskaEth committed 2026-09-09 21:41:08 +08:00
1 parent 4d56c73ae6
commit 2c9ff65fc4
42 files changed
+1271 -275

No files matched your search

+70 -90
View File
@@ -4,14 +4,13 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// ---------- settings ----------
@@ -34,38 +33,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
return out, rows.Err()
}
func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
var out eventSettingsDTO
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")
out.Standings.ShowIndividualBoard = boolDefault(
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
out.RequireApproval = boolDefault(values["results.review_required"], false)
out := dto.ParseEventSettings(values)
if code := values["scoring.default_profile"]; code != "" {
var id int64
@@ -74,7 +48,7 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
case err != nil:
return eventSettingsDTO{}, internal(err)
return dto.EventSettings{}, internal(err)
default:
out.DefaultProfileID = &id
}
@@ -82,10 +56,10 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
return out, nil
}
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) {
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventSettingsDTO{}, internal(err)
return dto.EventSettings{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
@@ -108,40 +82,40 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
if p := patch.Event; p != nil {
if err := setPtr("event.name", p.Name); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("event.organizer", p.Host); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
if p := patch.Display; p != nil {
if err := setPtr("display.background", p.Background); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("display.logo", p.Logo); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("display.title", p.Title); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if p.RotateSeconds != nil {
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
}
@@ -149,15 +123,20 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
if p.IndividualToTeamWeight != nil {
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
if err := setPtr("standings.mode", p.RankingMode); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
if p.ShowIndividualBoard != nil {
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
if p.ShowIndividualMedals != nil {
if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil {
return dto.EventSettings{}, err
}
}
}
@@ -166,44 +145,27 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
if *patch.DefaultProfileID != 0 {
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil)
return dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil)
}
return eventSettingsDTO{}, internal(err)
return dto.EventSettings{}, internal(err)
}
}
if err := set("scoring.default_profile", code); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
if patch.RequireApproval != nil {
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
return eventSettingsDTO{}, err
return dto.EventSettings{}, err
}
}
if err := tx.Commit(); err != nil {
return eventSettingsDTO{}, internal(err)
return dto.EventSettings{}, internal(err)
}
return s.getSettings(ctx)
}
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 {
@@ -261,7 +223,7 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) {
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
in, err := s.loadStandingsInput(ctx, q)
if err != nil {
return nil, err
@@ -270,7 +232,31 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRo
if err != nil {
return nil, err
}
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
}
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
// 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。
func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team {
out := make([]domain.Team, 0, len(teams))
for _, t := range teams {
out = append(out, domain.Team{
ID: t.ID,
Code: t.Code,
Name: t.Name,
ShortName: strValue(t.ShortName),
LogoPath: strValue(t.LogoPath),
Color: strValue(t.Color),
})
}
return out
}
func strValue(s *string) string {
if s == nil {
return ""
}
return *s
}
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
@@ -823,25 +809,19 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
// ---------- backup ----------
// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
// 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
if strings.TrimSpace(s.dataDir) == "" {
infos, err := s.backups.Run("manual")
if err != nil {
if errors.Is(err, export.ErrNoDir) {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
return backupResultDTO{}, internal(err)
}
if len(infos) == 0 {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
dir := filepath.Join(s.dataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return backupResultDTO{}, internal(err)
}
now := time.Now()
stamp := now.Format("20060102-150405")
path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp))
if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
return backupResultDTO{}, internal(err)
}
eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp))
if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil {
return backupResultDTO{}, internal(err)
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path})
return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path})
return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil
}