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:
1 parent
4d56c73ae6
commit
2c9ff65fc4
42 files changed
+1271
-275
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user