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

+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)
}
}