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

+12
View File
@@ -14,13 +14,24 @@ import (
type Config struct {
Server Server `yaml:"server"`
DataDir string `yaml:"dataDir"`
Backup Backup `yaml:"backup"`
Log Log `yaml:"log"`
}
// Backup 是自动备份配置。
type Backup struct {
// IntervalMinutes 是自动备份间隔(分钟);<=0 表示关闭自动备份。
IntervalMinutes int `yaml:"intervalMinutes"`
// Keep 是每个库滚动保留的份数;<=0 时用 export.DefaultBackupKeep。
Keep int `yaml:"keep"`
}
// Server 是 HTTP 监听配置。
type Server struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
// AllowedOrigins 是 WebSocket 跨站 Origin 白名单;为空时只放行同源与不带 Origin 的客户端。
AllowedOrigins []string `yaml:"allowedOrigins"`
}
// Log 是日志配置。
@@ -33,6 +44,7 @@ func Default() *Config {
return &Config{
Server: Server{Host: "0.0.0.0", Port: 19540},
DataDir: "data",
Backup: Backup{IntervalMinutes: 5, Keep: 20},
Log: Log{Level: "info"},
}
}
+31
View File
@@ -0,0 +1,31 @@
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDefaultAllowedOrigins(t *testing.T) {
if got := Default().Server.AllowedOrigins; len(got) != 0 {
t.Fatalf("默认白名单应为空: %v", got)
}
}
func TestLoadAllowedOrigins(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
raw := "server:\n allowedOrigins:\n - http://a.example\n - https://b.example\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
want := []string{"http://a.example", "https://b.example"}
if !reflect.DeepEqual(cfg.Server.AllowedOrigins, want) {
t.Fatalf("白名单解析错误: got %v want %v", cfg.Server.AllowedOrigins, want)
}
}
+14
View File
@@ -43,6 +43,18 @@ 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 表的点号键聚合成嵌套对象。
@@ -74,8 +86,10 @@ func ParseEventSettings(values map[string]string) EventSettings {
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
+132 -7
View File
@@ -1,13 +1,16 @@
package export
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
@@ -48,7 +51,7 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
}
dir := strings.TrimSpace(opts.Dir)
if dir == "" {
return BackupInfo{}, errors.New("export: 备份目录为空")
return BackupInfo{}, ErrNoDir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
@@ -100,6 +103,113 @@ func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
return Backup(db, opts)
}
// Service 收拢「系统库 + 赛事库」的备份,并串行化手动、发布前、定时三类触发:
// 同一时刻只允许一个 VACUUM INTO 在跑,避免并发快照互相拖慢或写入同一文件。
type Service struct {
system *sql.DB
event *sql.DB
dir string
keep int
logger *slog.Logger
mu sync.Mutex
}
// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。
func NewService(system, event *sql.DB, dir string, keep int, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{system: system, event: event, dir: dir, keep: keep, logger: logger}
}
// Run 备份系统库与赛事库各一份(手动备份、定时备份用)。
func (s *Service) Run(reason string) ([]BackupInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotAll(reason, true)
}
// RunEvent 只备份赛事库(发布前备份用);与 Run 共用同一把锁。
func (s *Service) RunEvent(reason string) ([]BackupInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotAll(reason, false)
}
// StartAuto 跟随 ctx 定时备份:ctx 取消即停止,间隔 <=0 表示关闭。
//
// 定时备份不写审计:一天下来会产生几百条 system.backup,把审计表刷满;
// 手动备份与发布前备份照旧写审计。
func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
if interval <= 0 {
s.logger.Info("自动备份已关闭", "interval", interval.String())
return
}
if strings.TrimSpace(s.dir) == "" {
s.logger.Warn("未配置数据目录,跳过自动备份")
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if ctx.Err() != nil {
return
}
infos, err := s.Run("auto")
if err != nil {
s.logger.Error("自动备份失败", "err", err)
continue
}
for _, info := range infos {
s.logger.Info("自动备份完成", "path", info.Path, "size", info.Size)
}
}
}
}()
}
func (s *Service) snapshotAll(reason string, withSystem bool) ([]BackupInfo, error) {
if strings.TrimSpace(s.dir) == "" {
return nil, ErrNoDir
}
out := make([]BackupInfo, 0, 2)
if withSystem && s.system != nil {
info, err := s.snapshot(s.system, "system", reason)
if err != nil {
return out, err
}
out = append(out, info)
}
if s.event != nil {
info, err := s.snapshot(s.event, "event", reason)
if err != nil {
return out, err
}
out = append(out, info)
}
return out, nil
}
func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error) {
info, err := Backup(db, BackupOptions{Dir: s.dir, Prefix: prefix, Reason: reason, Keep: s.keep})
if err == nil {
return info, nil
}
// 快照已生成、只是滚动清理旧备份失败时 Backup 仍返回有效 info:
// 删不掉旧文件不算本次备份失败,记日志即可(下次备份会再试)。
if info.Path != "" {
s.logger.Warn("清理旧备份失败", "prefix", prefix, "err", err)
return info, nil
}
return BackupInfo{}, err
}
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
@@ -150,7 +260,8 @@ func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
return list[0], true, nil
}
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径;
// 个别文件删不掉只汇总到返回的错误里,不影响继续清理其余旧备份。
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
if keep <= 0 {
keep = DefaultBackupKeep
@@ -164,13 +275,15 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) {
}
removed := make([]string, 0, len(list)-keep)
var errs []error
for _, b := range list[keep:] {
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err)
errs = append(errs, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err))
continue
}
removed = append(removed, b.Path)
}
return removed, nil
return removed, errors.Join(errs...)
}
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
@@ -184,15 +297,27 @@ func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
}
}
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>.db 里取回原因。
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].db 里取回原因;
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
func reasonFromName(name, prefix string) string {
base := strings.TrimSuffix(name, filepath.Ext(name))
if prefix != "" {
base = strings.TrimPrefix(base, prefix+"-")
}
parts := strings.Split(base, "-")
if len(parts) >= 3 {
return strings.Join(parts[:len(parts)-2], "-")
for i := len(parts) - 2; i >= 0; i-- {
if len(parts[i]) == 8 && len(parts[i+1]) == 6 && allDigits(parts[i]) && allDigits(parts[i+1]) {
return strings.Join(parts[:i], "-")
}
}
return ""
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
+133
View File
@@ -1,6 +1,9 @@
package export
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
@@ -121,3 +124,133 @@ func TestListBackupsMissingDir(t *testing.T) {
t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err)
}
}
// 间隔 <=0 关闭任务;未配置备份目录时安全跳过(不 panic、不生成文件)。
func TestStartAutoDisabled(t *testing.T) {
dir := t.TempDir()
svc := NewService(nil, nil, dir, 20, testLogger())
ctx := context.Background()
svc.StartAuto(ctx, 0)
svc.StartAuto(ctx, -time.Minute)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读取备份目录: %v", err)
}
if len(entries) != 0 {
t.Fatalf("间隔 <=0 不应生成备份,实际 %d 个文件", len(entries))
}
// 未配置备份目录:只记日志并跳过,不能 panic。
NewService(nil, nil, "", 20, testLogger()).StartAuto(ctx, time.Minute)
}
// 定时任务按间隔生成 auto 备份。
func TestStartAutoBacksUp(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, 10*time.Millisecond)
deadline := time.Now().Add(10 * time.Second)
for {
list, err := ListBackups(dir, "event")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
if len(list) > 0 {
if list[0].Reason != "auto" {
t.Fatalf("定时备份原因 = %q,期望 auto", list[0].Reason)
}
return
}
if time.Now().After(deadline) {
t.Fatal("10 秒内未生成定时备份")
}
time.Sleep(10 * time.Millisecond)
}
}
// 超过保留份数时删除最旧的备份。
func TestServiceRunPrunesOldBackups(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local)
for i := 0; i < 3; i++ {
if _, err := Backup(db, BackupOptions{
Dir: dir, Prefix: "event", Reason: "auto", SkipPrune: true,
Now: base.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("预置第 %d 份备份: %v", i, err)
}
}
svc := NewService(nil, db, dir, 2, testLogger())
if _, err := svc.RunEvent("auto"); err != nil {
t.Fatalf("RunEvent: %v", err)
}
list, err := ListBackups(dir, "event")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
if len(list) != 2 {
t.Fatalf("保留份数 = %d,期望 2", len(list))
}
if _, err := os.Stat(filepath.Join(dir, "event-auto-20260101-000000.db")); !os.IsNotExist(err) {
t.Errorf("最旧的备份应被删除,err=%v", err)
}
}
// 并发触发时必须等待同一把锁,保证同一时刻只有一个 VACUUM INTO 在跑。
func TestServiceRunMutualExclusion(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
svc := NewService(nil, db, t.TempDir(), 20, testLogger())
svc.mu.Lock()
done := make(chan error, 1)
go func() {
_, err := svc.RunEvent("auto")
done <- err
}()
select {
case err := <-done:
t.Fatalf("RunEvent 未等待互斥锁就返回: %v", err)
case <-time.After(100 * time.Millisecond):
}
svc.mu.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("RunEvent: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("RunEvent 超时未完成")
}
}
// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。
func TestReasonFromNameWithSequence(t *testing.T) {
cases := []struct{ name, prefix, want string }{
{"event-auto-20260909-213614.db", "event", "auto"},
{"event-auto-20260909-213614-2.db", "event", "auto"},
{"event-publish-20260909-213614-10.db", "event", "publish"},
{"system-manual-20260909-213614.db", "system", "manual"},
}
for _, c := range cases {
if got := reasonFromName(c.name, c.prefix); got != c.want {
t.Errorf("reasonFromName(%q) = %q,期望 %q", c.name, got, c.want)
}
}
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
+2
View File
@@ -14,6 +14,8 @@ import "errors"
var (
// ErrNoDatabase 表示调用方传入了空的数据库连接。
ErrNoDatabase = errors.New("export: 数据库连接为空")
// ErrNoDir 表示未配置备份目录。
ErrNoDir = errors.New("export: 备份目录为空")
// ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。
ErrInvalidPackage = errors.New("export: 赛事项目包非法")
// ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。
+1 -84
View File
@@ -1,7 +1,5 @@
package httpapi
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。
// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。
@@ -135,22 +133,6 @@ type resultDTO struct {
Points *float64 `json:"points,omitempty"`
}
type standingRowDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
Color *string `json:"color"`
LogoPath *string `json:"logoPath"`
Rank int `json:"rank"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
MedalTotal int `json:"medalTotal"`
TotalPoints float64 `json:"totalPoints"`
RankChange *int `json:"rankChange"`
}
type athleteStandingDTO struct {
Rank int `json:"rank"`
AthleteID int64 `json:"athleteId"`
@@ -254,37 +236,7 @@ type backupResultDTO struct {
CreatedAt string `json:"createdAt"`
}
// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。
type eventSettingsDTO struct {
Event eventSettingsEvent `json:"event"`
Display eventSettingsDisplay `json:"display"`
Standings eventSettingsStandings `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval bool `json:"requireApproval"`
}
type eventSettingsEvent struct {
Name string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
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"`
}
type eventSettingsStandings struct {
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
RankingMode string `json:"rankingMode"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
}
// GET/PUT /api/settings 的线上结构见 internal/dto.EventSettings(与 snapshot.settings 共用)。
// ---------- 用户管理 ----------
@@ -362,41 +314,6 @@ type revisionTeamDeltaDTO struct {
// ---------- domain → DTO ----------
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
out := make([]standingRowDTO, 0, len(rows))
for _, r := range rows {
t := teams[r.TeamID]
dto := standingRowDTO{
TeamID: r.TeamID,
TeamCode: r.Code,
TeamName: r.Name,
ShortName: nilIfEmpty(r.ShortName),
Rank: r.Rank,
Gold: r.Gold,
Silver: r.Silver,
Bronze: r.Bronze,
MedalTotal: r.Medals,
TotalPoints: r.Points,
RankChange: nil,
}
if t.ID != 0 {
dto.Color = t.Color
dto.LogoPath = t.LogoPath
if dto.TeamCode == "" {
dto.TeamCode = t.Code
}
if dto.TeamName == "" {
dto.TeamName = t.Name
}
if dto.ShortName == nil {
dto.ShortName = t.ShortName
}
}
out = append(out, dto)
}
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
+48 -1
View File
@@ -1,16 +1,25 @@
package httpapi
import (
"crypto/subtle"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// systemRoutes 挂载系统级只读信息。
// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。
const shutdownTokenHeader = "X-Shutdown-Token"
// systemRoutes 挂载系统级只读信息与优雅关闭握手。
func (s *server) systemRoutes(mux *http.ServeMux) {
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
// 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。
mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler))
}
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
@@ -18,3 +27,41 @@ func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error
"internalSignature": version.InternalSignature,
}, nil
}
// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。
func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
method, ok := s.shutdownAuth(r)
if !ok {
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
}
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
}
if s.shutdown == nil {
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
}
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
// 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。
go s.shutdown()
return nil, errHandled
}
// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。
// 环境变量为空时只认 admin 会话。
func (s *server) shutdownAuth(r *http.Request) (string, bool) {
if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return "session", true
}
expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN"))
if expected == "" {
return "", false
}
got := r.Header.Get(shutdownTokenHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
return "", false
}
return "token", true
}
+1
View File
@@ -97,6 +97,7 @@ type settingsStandingsPatch struct {
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
RankingMode *string `json:"rankingMode"`
ShowIndividualBoard *bool `json:"showIndividualBoard"`
ShowIndividualMedals *bool `json:"showIndividualMedals"`
}
type categoriesPayload struct {
+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
}
+20
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
@@ -13,6 +14,25 @@ import (
func (s *server) rulesRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
// 录入员要判断破纪录加分/人工调整是否启用,但无权读 /api/settings,故单开只读开关。
mux.HandleFunc("GET /api/rules/switches", s.read(auth.RoleRecorder, s.ruleSwitches))
}
// ruleSwitches 只返回布尔开关,不下发规则内容、模板明细或文件路径。
func (s *server) ruleSwitches(w http.ResponseWriter, r *http.Request) (any, error) {
snap, err := rules.Load(s.ev)
if err != nil {
return nil, internal(err)
}
pack := snap.Pack
return dto.RuleSwitches{
RecordBonusEnabled: pack.RecordBonus.Enabled,
AdjustmentsEnabled: pack.ManualAdjustment.Enabled,
AdjustmentReasonRequired: pack.ManualAdjustment.ReasonRequired,
MedalCountsTeamEvents: pack.Standings.MedalCountsTeamEventsEnabled(),
ShowIndividualBoard: pack.Standings.ShowIndividualBoardEnabled(),
ShowIndividualMedals: pack.Standings.ShowIndividualMedalsEnabled(),
}, nil
}
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
+8 -11
View File
@@ -5,8 +5,6 @@ import (
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
@@ -454,20 +452,19 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
func (s *server) backupBeforePublish(ctx context.Context) {
if strings.TrimSpace(s.dataDir) == "" {
return
}
info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{
Dir: filepath.Join(s.dataDir, "backups"),
Prefix: "event",
Reason: "publish",
})
infos, err := s.backups.RunEvent("publish")
if err != nil {
if errors.Is(err, export.ErrNoDir) {
return // 未配置数据目录:静默跳过,不写失败审计
}
s.logger.Error("发布前备份失败", "err", err)
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
return
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path})
if len(infos) == 0 {
return
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": infos[0].Path})
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
+19
View File
@@ -6,10 +6,12 @@ import (
"io/fs"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
@@ -31,6 +33,11 @@ type Options struct {
Auth *auth.Service
// DataDir 用于备份目录 data/backups。
DataDir string
// Backup 可选,手动/发布前备份共用的服务;为空时按 System/Event/DataDir 自动构建。
Backup *export.Service
// Shutdown 可选,由 main 注入:触发与 SIGINT/SIGTERM 相同的优雅关闭流程。
// 为空时 POST /api/shutdown 返回 503,避免把「没接关闭」误报成已受理。
Shutdown func()
// Display 可选,实时层接入后传入。
Display DisplaySender
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
@@ -51,6 +58,8 @@ type server struct {
auth *auth.Service
dataDir string
projectDir string
backups *export.Service
shutdown func()
display DisplaySender
realtime Realtime
idem *idempotencyStore
@@ -66,6 +75,14 @@ func New(opts Options) http.Handler {
if svc == nil {
svc = auth.New(opts.System, auth.Options{Logger: logger})
}
backups := opts.Backup
if backups == nil {
dir := ""
if strings.TrimSpace(opts.DataDir) != "" {
dir = filepath.Join(opts.DataDir, "backups")
}
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
}
s := &server{
logger: logger,
@@ -75,6 +92,8 @@ func New(opts Options) http.Handler {
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
backups: backups,
shutdown: opts.Shutdown,
display: opts.Display,
realtime: opts.Realtime,
idem: newIdempotencyStore(10 * time.Minute),
+301
View File
@@ -0,0 +1,301 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// waitClosed 等待关闭回调触发;没触发就报错。
func waitClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("等待关闭回调超时")
}
}
func assertNotClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
t.Fatal("鉴权失败时不应触发关闭")
default:
}
}
// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。
func waitAuditAction(t *testing.T, env *testEnv, action string) int {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var count int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil {
t.Fatalf("查询审计: %v", err)
}
if count > 0 {
return count
}
if time.Now().After(deadline) {
t.Fatalf("等待审计 %s 超时", action)
}
time.Sleep(10 * time.Millisecond)
}
}
func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) {
t.Helper()
done := make(chan struct{})
var once sync.Once
return newTestEnvWith(t, func(o *httpapi.Options) {
o.Shutdown = func() { once.Do(func() { close(done) }) }
}), done
}
// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。
func TestShutdownByAdminSession(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "")
env, done := newShutdownEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
// 未登录:401,不触发关闭。
if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized ||
env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// recorder:403,不触发关闭。
env.createUser("recorder", "recorder-pass", "recorder")
rec, _ := env.client()
env.login(rec, "recorder", "recorder-pass")
if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden ||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。
env.login(client, "admin", "admin-pass")
status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true)
if status != http.StatusAccepted {
t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw)
}
if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` {
t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got)
}
waitClosed(t, done)
if count := waitAuditAction(t, env, "system.shutdown"); count != 1 {
t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count)
}
var after string
if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil {
t.Fatalf("读取关闭审计: %v", err)
}
if !strings.Contains(after, `"session"`) {
t.Fatalf("关闭审计应标明凭据来源,得到 %s", after)
}
}
// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。
func TestShutdownByToken(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token")
env, done := newShutdownEnv(t)
client, _ := env.client()
if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden {
t.Fatalf("错误 token 应 403,得到 %d", status)
}
assertNotClosed(t, done)
status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"})
if status != http.StatusAccepted {
t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error)
}
waitClosed(t, done)
// 没注入关闭回调时应拒绝受理,避免谎报成功。
bare := newTestEnv(t)
bareClient, _ := bare.client()
if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable {
t.Fatalf("未接入关闭回调应 503,得到 %d", status)
}
}
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读规则开关应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error)
}
var switches map[string]any
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v (%s)", err, env2.Data)
}
want := []string{
"recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired",
"medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals",
}
if len(switches) != len(want) {
t.Fatalf("只应返回布尔开关,得到 %v", switches)
}
for _, key := range want {
v, ok := switches[key]
if !ok {
t.Fatalf("缺少开关 %s: %v", key, switches)
}
if _, isBool := v.(bool); !isBool {
t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v)
}
}
// 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。
if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false ||
switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true ||
switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true {
t.Fatalf("默认开关取值不符: %v", switches)
}
// 规则导入/配置改动后开关随之变化。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`)
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
_, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v", err)
}
if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true {
t.Fatalf("开关应随 settings 变化: %v", switches)
}
}
// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。
func TestSettingsAndStandingsJSONContract(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
_, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
var settings map[string]any
if err := json.Unmarshal(env2.Data, &settings); err != nil {
t.Fatalf("解析 settings: %v", err)
}
assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"})
assertKeys(t, "settings.event", asMap(t, settings["event"]),
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds"})
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
var rows []map[string]any
if err := json.Unmarshal(env2.Data, &rows); err != nil {
t.Fatalf("解析 standings: %v", err)
}
if len(rows) == 0 {
t.Fatal("榜单不应为空")
}
assertKeys(t, "standings[0]", rows[0], []string{
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
})
}
func asMap(t *testing.T, v any) map[string]any {
t.Helper()
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("期望对象,得到 %T", v)
}
return m
}
func assertKeys(t *testing.T, name string, got map[string]any, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got)
}
for _, key := range want {
if _, ok := got[key]; !ok {
t.Fatalf("%s 缺少字段 %s:%v", name, key, got)
}
}
}
type settingsStandings struct {
Standings struct {
ShowIndividualBoard bool `json:"showIndividualBoard"`
ShowIndividualMedals bool `json:"showIndividualMedals"`
} `json:"standings"`
}
// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。
func TestSettingsShowIndividualMedals(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读配置应 200,得到 %d", status)
}
settings := decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings)
}
// 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings)
}
// 两个开关可以独立配置:board=true + medals=false。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("两个开关应可独立配置: %+v", settings.Standings)
}
// 落库并复读。
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("复读配置不符: %+v", settings.Standings)
}
}
+19 -4
View File
@@ -54,6 +54,7 @@ type Hub struct {
slowLag int64
helloTO time.Duration
writeTO time.Duration
allowedOrigins []string
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
@@ -160,6 +161,14 @@ func (h *Hub) SetSlowLag(n int64) {
h.mu.Unlock()
}
// SetAllowedOrigins 设置 WebSocket 跨站 Origin 白名单(path.Match 模式);
// 同源与不带 Origin 的握手始终放行,不受此设置影响。
func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Lock()
h.allowedOrigins = append([]string(nil), origins...)
h.mu.Unlock()
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -190,16 +199,22 @@ func (h *Hub) writeTimeout() time.Duration {
return h.writeTO
}
func (h *Hub) allowedOriginsValue() []string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.allowedOrigins
}
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
func (h *Hub) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。
InsecureSkipVerify: true,
CompressionMode: websocket.CompressionDisabled,
// 同源与不带 Origin 的握手由库放行;跨站需命中 server.allowedOrigins 白名单。
OriginPatterns: h.allowedOriginsValue(),
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err)
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "origin", r.Header.Get("Origin"), "err", err)
return
}
+66
View File
@@ -0,0 +1,66 @@
package realtime
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// dialOrigin 以指定 Origin 发起握手;origin 为空表示不带 Origin 头(非浏览器客户端)。
func dialOrigin(t *testing.T, srv *httptest.Server, origin string) error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opts := &websocket.DialOptions{}
if origin != "" {
opts.HTTPHeader = http.Header{"Origin": []string{origin}}
}
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", opts)
if err != nil {
return err
}
_ = conn.CloseNow()
return nil
}
func TestHandlerOrigin(t *testing.T) {
t.Run("同源放行", func(t *testing.T) {
e := newTestEnv(t)
// httptest.Server 的 URL 即页面 Origin:scheme + host 与请求 Host 同源。
if err := dialOrigin(t, e.srv, e.srv.URL); err != nil {
t.Fatalf("同源 Origin 应放行: %v", err)
}
})
t.Run("空 Origin 放行", func(t *testing.T) {
e := newTestEnv(t)
if err := dialOrigin(t, e.srv, ""); err != nil {
t.Fatalf("不带 Origin 的客户端应放行: %v", err)
}
})
t.Run("跨站拒绝", func(t *testing.T) {
e := newTestEnv(t)
if err := dialOrigin(t, e.srv, "http://evil.example"); err == nil {
t.Fatal("跨站 Origin 应拒绝")
}
})
t.Run("白名单放行", func(t *testing.T) {
e := newTestEnv(t)
e.hub.SetAllowedOrigins([]string{"http://evil.example"})
if err := dialOrigin(t, e.srv, "http://evil.example"); err != nil {
t.Fatalf("白名单内 Origin 应放行: %v", err)
}
if err := dialOrigin(t, e.srv, "http://other.example"); err == nil {
t.Fatal("白名单外的跨站 Origin 应拒绝")
}
})
}
+17 -7
View File
@@ -85,6 +85,7 @@ func main() {
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
hub := realtime.NewHub(eventDB, logger)
hub.SetAllowedOrigins(cfg.Server.AllowedOrigins)
defer func() { _ = hub.Close() }()
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
@@ -110,15 +111,20 @@ func main() {
logger.Warn("已创建默认管理员", "username", username, "password", password)
}
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
srv := &http.Server{
Addr: cfg.Server.Addr(),
Handler: httpapi.New(httpapi.Options{
Logger: logger,
Version: version,
System: st.DB(),
Event: eventDB,
Auth: authSvc,
DataDir: cfg.DataDir,
Logger: logger,
Version: version,
System: st.DB(),
Event: eventDB,
Auth: authSvc,
DataDir: cfg.DataDir,
Backup: backupSvc,
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
Shutdown: stop,
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
Display: displaySender{hub: hub},
Realtime: hub,
@@ -127,6 +133,9 @@ func main() {
ReadHeaderTimeout: 10 * time.Second,
}
// 定时备份跟随服务端生命周期:收到退出信号(ctx 取消)即停止。
backupSvc.StartAuto(ctx, time.Duration(cfg.Backup.IntervalMinutes)*time.Minute)
go func() {
logger.Info("服务启动", "addr", srv.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -138,7 +147,8 @@ func main() {
<-ctx.Done()
logger.Info("正在关闭…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// Console 的关闭握手要求 3 秒内完成,超时后仍会关库并退出(defer 顺序保证)。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("关闭超时", "err", err)