feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)

根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。

- 服务端 -project 缺省改为空串;解析顺序:显式 flag(并写回记忆)> <dataDir>/active_project.txt
  > default;backup/passwd 口径一致;启动日志打印生效项目与来源
- GET /api/projects(admin)列出各赛事(是否当前/是否有 event.db/大小/更新时间)
- POST /api/projects/activate(admin):校验后原子写记忆文件 → 审计 → 返回
  {ok,name,restarting:true} → 触发优雅关闭;Console 对非用户发起的退出会自动拉起,
  重启后即打开新赛事(无需改 Console)。本来就是当前赛事则 restarting:false 不重启
- 管理端「导出」页新增赛事项目列表与「切换为当前赛事」;导入成功后提示「已导入为 X,
  但还不是当前赛事」并给「立即切换并重启服务端」;切换后轮询 /api/health 等它回来再刷新
- 文档:使用手册(导入≠切换、生效四步)、常见问题(导入后看不到数据)

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:47:06 +08:00
1 parent 005b51b2ec
commit 2d207fd654
14 files changed
+1363 -24

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
package httpapi
import (
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// ActiveProjectFile 是数据目录下记录「上次打开的赛事项目」的记忆文件名。
// 服务端启动时按 显式 -project → 本文件 → default 的顺序解析当前赛事;
// 切换赛事就是改这个文件再重启进程(Console 会代劳重启)。
const ActiveProjectFile = "active_project.txt"
// ReadActiveProject 读记忆文件里的赛事项目名:文件缺失、内容为空或其项目名不安全时返回空串,
// 由调用方回落到 default。多行内容会被 SafeProjectName 当成控制字符拒绝。
func ReadActiveProject(dataDir string) string {
if strings.TrimSpace(dataDir) == "" {
return ""
}
raw, err := os.ReadFile(activeProjectPath(dataDir))
if err != nil {
return ""
}
name, err := export.SafeProjectName(string(raw))
if err != nil {
return ""
}
return name
}
// WriteActiveProject 把当前赛事项目名写进记忆文件。
// 先写同目录的临时文件再改名:改名是原子的,读取方(下次启动)不会看到半截项目名。
func WriteActiveProject(dataDir, name string) error {
safe, err := export.SafeProjectName(name)
if err != nil {
return err
}
if strings.TrimSpace(dataDir) == "" {
return errors.New("httpapi: 数据目录为空,无法写入赛事项目记忆文件")
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return fmt.Errorf("httpapi: 创建数据目录 %s: %w", dataDir, err)
}
tmp, err := os.CreateTemp(dataDir, ActiveProjectFile+".tmp-*")
if err != nil {
return fmt.Errorf("httpapi: 创建临时文件: %w", err)
}
path := tmp.Name()
if _, err := tmp.WriteString(safe + "\n"); err != nil {
_ = tmp.Close()
_ = os.Remove(path)
return fmt.Errorf("httpapi: 写入 %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return fmt.Errorf("httpapi: 关闭 %s: %w", path, err)
}
if err := os.Rename(path, activeProjectPath(dataDir)); err != nil {
_ = os.Remove(path)
return fmt.Errorf("httpapi: 重命名记忆文件: %w", err)
}
return nil
}
func activeProjectPath(dataDir string) string {
return filepath.Join(dataDir, ActiveProjectFile)
}
// projectInfo 是 GET /api/projects 里的一项赛事项目。
type projectInfo struct {
Name string `json:"name"`
// Active 表示这是服务端当前打开的赛事(与启动时解析出的项目名比较)。
Active bool `json:"active"`
HasEvent bool `json:"hasEvent"`
// SizeBytes 是整个项目目录占用的字节数(含 event.db 与 uploads)。
SizeBytes int64 `json:"sizeBytes"`
// UpdatedAt 取 event.db 的修改时间(RFC3339);没有 event.db 时为空串。
UpdatedAt string `json:"updatedAt"`
}
// projectActivateResult 是切换赛事的结果;restarting 为 true 表示已写记忆文件并触发重启流程。
type projectActivateResult struct {
Name string `json:"name"`
Restarting bool `json:"restarting"`
}
// projectRoutes 挂载赛事项目列表与切换(均为 admin)。
func (s *server) projectRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/projects", s.read(auth.RoleAdmin, s.listProjects))
// 切赛事要走写路由:写记忆文件属于变更操作,需要 CSRF 与幂等保护。
mux.HandleFunc("POST /api/projects/activate", s.write(auth.RoleAdmin, s.activateProject))
}
// projectsRoot 是赛事项目根目录 <dataDir>/event_projs。
func (s *server) projectsRoot() string {
return filepath.Join(s.dataDir, "event_projs")
}
// listProjects 列出可用的赛事项目:导入的包都落在 <dataDir>/event_projs/<项目名>/,
// 管理端据此显示「哪些赛事导进来过」以及当前打开的是哪一个。
func (s *server) listProjects(w http.ResponseWriter, r *http.Request) (any, error) {
entries, err := os.ReadDir(s.projectsRoot())
if err != nil {
// 全新安装还没有 event_projs 目录:回空列表,别让管理端看到一个 500。
if os.IsNotExist(err) {
return map[string]any{"projects": []projectInfo{}}, nil
}
return nil, internal(err)
}
active := currentProjectName(s.projectDir)
items := make([]projectInfo, 0, len(entries))
for _, e := range entries {
// 单个条目读不到就跳过:损坏或半截的目录不该让整个列表失败。
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
// 点开头的是导入过程中的 .import-* 临时目录,不进列表。
continue
}
name, err := export.SafeProjectName(e.Name())
if err != nil {
// 被手工改坏的名字也不能当成可切换的项目。
continue
}
dir := filepath.Join(s.projectsRoot(), name)
item := projectInfo{Name: name, Active: name == active, SizeBytes: dirSize(dir)}
if info, err := os.Stat(filepath.Join(dir, "event.db")); err == nil && !info.IsDir() {
item.HasEvent = true
item.UpdatedAt = info.ModTime().UTC().Format(time.RFC3339)
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return map[string]any{"projects": items}, nil
}
// activateProject 切换当前赛事:校验后写记忆文件,再触发优雅关闭,等 Console 自动拉起服务端。
// 重启后服务端按记忆文件打开新项目——服务端自己不重启,避免中途丢掉正在运行的库连接。
func (s *server) activateProject(w http.ResponseWriter, r *http.Request) (any, error) {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
return nil, err
}
name, err := export.SafeProjectName(req.Name)
if err != nil {
return nil, badRequest(projectNameMessage(err), nil)
}
dir := filepath.Join(s.projectsRoot(), name)
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return nil, badRequest("赛事项目 "+name+" 不存在,请先导入赛事包", nil)
}
if info, err := os.Stat(filepath.Join(dir, "event.db")); err != nil || info.IsDir() {
return nil, badRequest("赛事项目 "+name+" 缺少 event.db,无法切换", nil)
}
active := currentProjectName(s.projectDir)
if active == name {
// 已经是当前赛事:不写文件也不重启,重复点击不会反复重启服务端。
return projectActivateResult{Name: name, Restarting: false}, nil
}
if err := WriteActiveProject(s.dataDir, name); err != nil {
return nil, internal(err)
}
s.audit(r.Context(), "project.activate", "project", 0, map[string]any{"from": active}, map[string]any{"to": name})
// 先写响应再触发关闭:srv.Shutdown 会等本次请求(含上面的审计落库)结束,不会截断响应。
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"data": projectActivateResult{Name: name, Restarting: true},
})
if s.shutdown == nil {
// 没接入优雅关闭(例如手工跑的裸服务端):记忆文件已写好,下次重启即生效。
s.logger.Warn("服务端未接入优雅关闭,赛事切换将在下次重启后生效", "project", name)
} else {
go s.shutdown()
}
return nil, errHandled
}
// projectNameMessage 把 export.SafeProjectName 的错误整理成面向管理端的中文提示,
// 去掉包名前缀与重复的「赛事项目名非法」。
func projectNameMessage(err error) string {
detail := strings.TrimPrefix(err.Error(), "export: ")
// ErrUnsafeName 会被包成「赛事项目名非法: <细节>」,这里只留细节,避免同一句提示出现两遍。
detail = strings.TrimPrefix(detail, strings.TrimPrefix(export.ErrUnsafeName.Error(), "export: ")+": ")
return "赛事项目名非法:" + detail
}
// dirSize 统计目录下普通文件的总字节数;读不到的条目直接跳过,不影响列表返回。
func dirSize(root string) int64 {
var total int64
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, err := d.Info(); err == nil {
total += info.Size()
}
return nil
})
return total
}
@@ -0,0 +1,91 @@
package httpapi
import (
"os"
"path/filepath"
"testing"
)
// 记忆文件是「下次启动打开哪个赛事」的唯一依据:必须能原子写入、能被读回,
// 且绝不能因为半截或非法内容把服务端指到一个奇怪的目录(P0:导入后页面空白)。
func TestActiveProjectFileRoundTrip(t *testing.T) {
dir := t.TempDir()
if got := ReadActiveProject(dir); got != "" {
t.Fatalf("没有记忆文件时应返回空串,得到 %q", got)
}
if got := ReadActiveProject(""); got != "" {
t.Fatalf("数据目录为空时应返回空串,得到 %q", got)
}
if err := WriteActiveProject(dir, "秋季赛"); err != nil {
t.Fatalf("写记忆文件: %v", err)
}
if got := ReadActiveProject(dir); got != "秋季赛" {
t.Fatalf("读回的项目名应为 秋季赛,得到 %q", got)
}
raw, err := os.ReadFile(filepath.Join(dir, ActiveProjectFile))
if err != nil {
t.Fatalf("读记忆文件: %v", err)
}
if string(raw) != "秋季赛\n" {
t.Fatalf("记忆文件应是单行纯文本,得到 %q", raw)
}
// 临时文件必须已经改名掉,不能在数据目录里堆积。
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读取数据目录: %v", err)
}
if len(entries) != 1 || entries[0].Name() != ActiveProjectFile {
t.Fatalf("数据目录里应只剩记忆文件,得到 %v", entries)
}
// 覆盖写:旧项目名必须被整体替换,不能留下半截。
if err := WriteActiveProject(dir, "冬季赛"); err != nil {
t.Fatalf("覆盖写记忆文件: %v", err)
}
if got := ReadActiveProject(dir); got != "冬季赛" {
t.Fatalf("覆盖后应读到 冬季赛,得到 %q", got)
}
}
func TestActiveProjectFileRejectsUnsafeContent(t *testing.T) {
for _, tc := range []struct{ name, content string }{
{"路径穿越", "../逃逸\n"},
{"多行", "好名字\n坏名字\n"},
{"只有空白", " \n"},
{"保留名", "CON\n"},
{"分隔符", "a/b\n"},
{"含控制字符", "好\x00名字\n"},
} {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ActiveProjectFile), []byte(tc.content), 0o644); err != nil {
t.Fatalf("写记忆文件: %v", err)
}
if got := ReadActiveProject(dir); got != "" {
t.Errorf("%s:非法内容应回空串交由调用方回落 default,得到 %q", tc.name, got)
}
}
// 两侧空白要 trim:手工编辑或 Windows 记事本都会留下换行与空格。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ActiveProjectFile), []byte(" 秋季赛 \r\n"), 0o644); err != nil {
t.Fatalf("写记忆文件: %v", err)
}
if got := ReadActiveProject(dir); got != "秋季赛" {
t.Fatalf("应 trim 掉空白后读回 秋季赛,得到 %q", got)
}
}
func TestWriteActiveProjectRejectsBadInput(t *testing.T) {
dir := t.TempDir()
if err := WriteActiveProject(dir, "../逃逸"); err == nil {
t.Fatal("非法项目名不该被写进记忆文件")
}
if err := WriteActiveProject("", "秋季赛"); err == nil {
t.Fatal("数据目录为空时不该静默成功")
}
if _, err := os.Stat(filepath.Join(dir, ActiveProjectFile)); !os.IsNotExist(err) {
t.Fatalf("非法输入不该留下记忆文件: %v", err)
}
}
+276
View File
@@ -0,0 +1,276 @@
package httpapi_test
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// projectItem 对应 GET /api/projects 的一项(字段名是前后端共用的契约)。
type projectItem struct {
Name string `json:"name"`
Active bool `json:"active"`
HasEvent bool `json:"hasEvent"`
SizeBytes int64 `json:"sizeBytes"`
UpdatedAt string `json:"updatedAt"`
}
type projectList struct {
Projects []projectItem `json:"projects"`
}
type projectActivateResult struct {
Name string `json:"name"`
Restarting bool `json:"restarting"`
}
// newProjectEnv 建一个「当前打开 active 项目」的测试环境,并返回数据目录与关闭回调的通知通道。
func newProjectEnv(t *testing.T, active string) (*testEnv, string, chan struct{}) {
t.Helper()
var dataDir string
shutdown := make(chan struct{}, 1)
env := newTestEnvWith(t, func(o *httpapi.Options) {
dataDir = o.DataDir
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", active)
o.Shutdown = func() { shutdown <- struct{}{} }
})
return env, dataDir, shutdown
}
// writeProjectDir 造一个赛事项目目录;hasEvent=false 时只有目录,模拟导入了一半的项目。
func writeProjectDir(t *testing.T, dataDir, name string, hasEvent bool) {
t.Helper()
dir := filepath.Join(dataDir, "event_projs", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("创建项目目录 %s: %v", name, err)
}
if !hasEvent {
return
}
if err := os.WriteFile(filepath.Join(dir, "event.db"), []byte("sqlite-bytes"), 0o644); err != nil {
t.Fatalf("写项目 %s 的赛事库: %v", name, err)
}
}
func readActiveProjectFile(t *testing.T, dataDir string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(dataDir, httpapi.ActiveProjectFile))
if err != nil {
t.Fatalf("读取记忆文件: %v", err)
}
return strings.TrimSpace(string(raw))
}
func projectByName(t *testing.T, env *testEnv, client *http.Client) map[string]projectItem {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/projects", nil, false)
if status != http.StatusOK {
t.Fatalf("列出赛事项目应 200,得到 %d %+v", status, body.Error)
}
out := map[string]projectItem{}
for _, p := range decodeData[projectList](t, body).Projects {
out[p.Name] = p
}
return out
}
// 导入的赛事包落在 event_projs/<名字>/,管理端必须先看得到这些项目才可能切过去(P0:导入后页面空白)。
func TestListProjectsReportsAvailability(t *testing.T) {
env, dataDir, _ := newProjectEnv(t, "test")
writeProjectDir(t, dataDir, "秋季赛", true)
writeProjectDir(t, dataDir, "缺库的赛", false)
// 导入中途的临时目录不该出现在列表里。
if err := os.MkdirAll(filepath.Join(dataDir, "event_projs", ".import-abc"), 0o755); err != nil {
t.Fatalf("创建导入临时目录: %v", err)
}
// 与赛事无关的文件也不该被当成项目。
if err := os.WriteFile(filepath.Join(dataDir, "event_projs", "README.txt"), []byte("x"), 0o644); err != nil {
t.Fatalf("写无关文件: %v", err)
}
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
projects := projectByName(t, env, client)
if len(projects) != 3 {
t.Fatalf("应列出 test / 秋季赛 / 缺库的赛 三个项目,得到 %+v", projects)
}
if !projects["test"].Active || !projects["test"].HasEvent {
t.Errorf("test 应是当前项目且有赛事库,得到 %+v", projects["test"])
}
if projects["秋季赛"].Active {
t.Errorf("秋季赛 不是当前项目,不应标 active:%+v", projects["秋季赛"])
}
if !projects["秋季赛"].HasEvent || projects["秋季赛"].UpdatedAt == "" {
t.Errorf("秋季赛 有 event.db,hasEvent/updatedAt 应有效:%+v", projects["秋季赛"])
}
if _, err := time.Parse(time.RFC3339, projects["秋季赛"].UpdatedAt); err != nil {
t.Errorf("updatedAt 应是 RFC3339,得到 %q", projects["秋季赛"].UpdatedAt)
}
if projects["秋季赛"].SizeBytes < int64(len("sqlite-bytes")) {
t.Errorf("sizeBytes 应统计目录内文件,得到 %d", projects["秋季赛"].SizeBytes)
}
if projects["缺库的赛"].HasEvent || projects["缺库的赛"].UpdatedAt != "" {
t.Errorf("没有 event.db 的项目 hasEvent=false、updatedAt 为空:%+v", projects["缺库的赛"])
}
}
// event_projs 还不存在(全新安装)时回空列表而不是 500。
func TestListProjectsMissingRoot(t *testing.T) {
env, dataDir, _ := newProjectEnv(t, "test")
if err := os.RemoveAll(filepath.Join(dataDir, "event_projs")); err != nil {
t.Fatalf("删除项目根目录: %v", err)
}
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if projects := projectByName(t, env, client); len(projects) != 0 {
t.Fatalf("项目根目录不存在时应回空列表,得到 %+v", projects)
}
}
// 列表是 admin 接口:没登录 403、低权限角色也 403。
func TestListProjectsRequiresAdmin(t *testing.T) {
env := newTestEnv(t)
env.createUser("rec", "rec-pass", "recorder")
client, _ := env.client()
if status, _ := env.do(client, http.MethodGet, "/api/projects", nil, false); status != http.StatusForbidden {
t.Fatalf("未登录应 403,得到 %d", status)
}
env.login(client, "rec", "rec-pass")
if status, _ := env.do(client, http.MethodGet, "/api/projects", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 应 403,得到 %d", status)
}
}
// 切换赛事:写记忆文件 + 返回 restarting=true + 触发优雅关闭(Console 据此自动拉起新赛事)。
func TestActivateProjectWritesMemoryAndRestarts(t *testing.T) {
env, dataDir, shutdown := newProjectEnv(t, "test")
writeProjectDir(t, dataDir, "秋季赛", true)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, body := env.do(client, http.MethodPost, "/api/projects/activate",
map[string]any{"name": "秋季赛"}, true)
if status != http.StatusOK {
t.Fatalf("切换赛事应 200,得到 %d %+v", status, body.Error)
}
res := decodeData[projectActivateResult](t, body)
if res.Name != "秋季赛" || !res.Restarting {
t.Fatalf("应返回 {name:秋季赛, restarting:true},得到 %+v", res)
}
if got := readActiveProjectFile(t, dataDir); got != "秋季赛" {
t.Fatalf("记忆文件应记住新项目,得到 %q", got)
}
select {
case <-shutdown:
case <-time.After(3 * time.Second):
t.Fatal("切换赛事后应触发优雅关闭,等 Console 自动重启")
}
}
// 切到自己身上不能重启:管理端重复点击会把现场反复断服。
func TestActivateCurrentProjectDoesNotRestart(t *testing.T) {
env, dataDir, shutdown := newProjectEnv(t, "test")
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, body := env.do(client, http.MethodPost, "/api/projects/activate",
map[string]any{"name": "test"}, true)
if status != http.StatusOK {
t.Fatalf("切到当前项目应 200,得到 %d %+v", status, body.Error)
}
if res := decodeData[projectActivateResult](t, body); res.Restarting {
t.Fatalf("当前项目不该返回 restarting=true:%+v", res)
}
if _, err := os.Stat(filepath.Join(dataDir, httpapi.ActiveProjectFile)); !os.IsNotExist(err) {
t.Fatalf("切到当前项目不该写记忆文件: %v", err)
}
select {
case <-shutdown:
t.Fatal("切到当前项目不该触发关闭")
case <-time.After(200 * time.Millisecond):
}
}
// 校验失败一律 400 + 中文提示,且不能留下记忆文件、更不能重启(写文件的目录是脏的)。
func TestActivateProjectValidation(t *testing.T) {
env, dataDir, shutdown := newProjectEnv(t, "test")
writeProjectDir(t, dataDir, "缺库的赛", false)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
for _, tc := range []struct{ name, want, exact string }{
{"../逃逸", "非法", `赛事项目名非法:"../逃逸"`},
{"", "非法", "赛事项目名非法:名称为空"},
{"没导入过", "不存在", ""},
{"缺库的赛", "event.db", ""},
} {
status, body := env.do(client, http.MethodPost, "/api/projects/activate",
map[string]any{"name": tc.name}, true)
if status != http.StatusBadRequest {
t.Fatalf("名字 %q 应 400,得到 %d %+v", tc.name, status, body.Error)
}
if body.Error == nil || !strings.Contains(body.Error.Message, tc.want) {
t.Fatalf("名字 %q 的提示应含 %q,得到 %+v", tc.name, tc.want, body.Error)
}
if tc.exact != "" && body.Error.Message != tc.exact {
t.Fatalf("名字 %q 的提示应是 %q,得到 %q", tc.name, tc.exact, body.Error.Message)
}
}
if _, err := os.Stat(filepath.Join(dataDir, httpapi.ActiveProjectFile)); !os.IsNotExist(err) {
t.Fatalf("校验失败不该写记忆文件: %v", err)
}
select {
case <-shutdown:
t.Fatal("校验失败不该触发关闭")
case <-time.After(200 * time.Millisecond):
}
}
// recorder 能看到的项目也不能自己切:切换是 admin 操作。
func TestActivateProjectRequiresAdmin(t *testing.T) {
env := newTestEnv(t)
env.createUser("rec", "rec-pass", "recorder")
client, _ := env.client()
env.login(client, "rec", "rec-pass")
status, _ := env.do(client, http.MethodPost, "/api/projects/activate",
map[string]any{"name": "test"}, true)
if status != http.StatusForbidden {
t.Fatalf("recorder 切换赛事应 403,得到 %d", status)
}
}
// 没接入优雅关闭(手工跑的裸服务端)时也不能 500:记忆文件写好,下次重启生效。
func TestActivateProjectWithoutShutdownHook(t *testing.T) {
var dataDir string
env := newTestEnvWith(t, func(o *httpapi.Options) {
dataDir = o.DataDir
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
o.Shutdown = nil
})
writeProjectDir(t, dataDir, "秋季赛", true)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, body := env.do(client, http.MethodPost, "/api/projects/activate",
map[string]any{"name": "秋季赛"}, true)
if status != http.StatusOK {
t.Fatalf("未接入关闭回调也应 200,得到 %d %+v", status, body.Error)
}
if res := decodeData[projectActivateResult](t, body); !res.Restarting {
t.Fatalf("记忆文件已写好,仍应返回 restarting=true:%+v", res)
}
}
+1
View File
@@ -102,6 +102,7 @@ func New(opts Options) http.Handler {
mux := http.NewServeMux()
s.routes(mux)
s.exportRoutes(mux)
s.projectRoutes(mux)
s.rulesRoutes(mux)
s.systemRoutes(mux)
if opts.WS != nil {
+50 -6
View File
@@ -47,7 +47,9 @@ func main() {
}
configPath := flag.String("config", "config.yaml", "配置文件路径")
project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)")
// 留空表示「按记忆文件里记住的上一次赛事」:Console 拉起服务端时不传 -project,
// 管理端切换赛事后靠这里读回新项目,否则导入的赛事包永远不会被打开。
project := flag.String("project", "", "赛事项目名(data/event_projs/<项目名>/event.db);留空则用记住的上一次赛事,再没有就用 default")
dataDir := flag.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
flag.Parse()
@@ -67,6 +69,19 @@ func main() {
logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir)
}
projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project)
if err != nil {
logger.Error("解析赛事项目失败", "err", err)
os.Exit(1)
}
// 显式指定过就写回记忆文件:下次不带 -project 启动(Console 就是这样拉起的)仍打开同一个赛事。
if projectSource == projectSourceFlag {
if err := httpapi.WriteActiveProject(cfg.DataDir, projectName); err != nil {
logger.Warn("写回赛事项目记忆失败,本次仍按 -project 打开", "err", err)
}
}
logger.Info("当前赛事项目", "project", projectName, "source", projectSource)
st, err := store.Open(cfg.DataDir)
if err != nil {
logger.Error("打开系统库失败", "err", err)
@@ -79,7 +94,7 @@ func main() {
os.Exit(1)
}
eventDB, err := openEventDB(cfg.DataDir, *project)
eventDB, err := openEventDB(cfg.DataDir, projectName)
if err != nil {
logger.Error("打开赛事库失败", "err", err)
os.Exit(1)
@@ -89,7 +104,7 @@ func main() {
logger.Error("赛事库迁移失败", "err", err)
os.Exit(1)
}
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", *project)
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", projectName)
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
hub := realtime.NewHub(eventDB, logger)
@@ -130,7 +145,7 @@ func main() {
Backup: backupSvc,
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
Shutdown: stop,
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", projectName),
Display: displaySender{hub: hub},
Realtime: hub,
WS: hub.Handler(),
@@ -181,11 +196,35 @@ func (d displaySender) SendDisplayCommand(action string, payload any) error {
return nil
}
// 赛事项目名的来源,只用于启动日志:现场据此确认这次打开的是哪一个赛事。
const (
defaultProjectName = "default"
projectSourceFlag = "命令行 -project"
projectSourceFile = "记忆文件 " + httpapi.ActiveProjectFile
projectSourceDefault = "默认"
)
// resolveProjectName 按「显式 -project → 数据目录里的记忆文件 → default」确定当前赛事项目名。
// 只解析与校验、不落盘:写回记忆文件是服务端启动路径自己的事,backup / passwd 不该顺手改掉它。
func resolveProjectName(dataDir, flagName string) (name, source string, err error) {
if v := strings.TrimSpace(flagName); v != "" {
safe, err := export.SafeProjectName(v)
if err != nil {
return "", "", fmt.Errorf("赛事项目名 %q 非法: %w", v, err)
}
return safe, projectSourceFlag, nil
}
if remembered := httpapi.ReadActiveProject(dataDir); remembered != "" {
return remembered, projectSourceFile, nil
}
return defaultProjectName, projectSourceDefault, nil
}
// runBackup 实现 `scoreboard backup` 子命令:内置 VACUUM INTO,不依赖外部 sqlite3.exe。
func runBackup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
configPath := fs.String("config", "config.yaml", "配置文件路径")
project := fs.String("project", "default", "赛事项目名")
project := fs.String("project", "", "赛事项目名;留空则用记住的上一次赛事,再没有就用 default")
reason := fs.String("reason", "manual", "备份原因:manual / publish / auto")
dataDir := fs.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
if err := fs.Parse(args); err != nil {
@@ -198,6 +237,11 @@ func runBackup(args []string) error {
}
// 与服务端主命令同一语义:备份的必须是 Console 实际在用的那个数据目录。
cfg.ApplyDataDir(*dataDir)
// 项目名也按同一顺序解析:备份的必须是服务端当前打开的那个赛事库。
projectName, _, err := resolveProjectName(cfg.DataDir, *project)
if err != nil {
return err
}
dir := filepath.Join(cfg.DataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
@@ -212,7 +256,7 @@ func runBackup(args []string) error {
return err
}
evPath := filepath.Join(cfg.DataDir, "event_projs", *project, "event.db")
evPath := filepath.Join(cfg.DataDir, "event_projs", projectName, "event.db")
if _, err := os.Stat(evPath); err == nil {
evDB, err := openSQLite(evPath)
if err != nil {
+138 -1
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 <prefix>-* 清理不到,
@@ -81,7 +83,13 @@ func TestRunBackupEmptyDataFlagKeepsConfig(t *testing.T) {
// writeEventDB 造一个空的赛事库;runBackup 只在 event.db 存在时备份赛事库,空库也走同一条 VACUUM INTO 路径。
func writeEventDB(t *testing.T, dataDir string) {
t.Helper()
projDir := filepath.Join(dataDir, "event_projs", "default")
writeProjectEventDB(t, dataDir, defaultProjectName)
}
// writeProjectEventDB 在指定项目下造一个空的赛事库。
func writeProjectEventDB(t *testing.T, dataDir, project string) {
t.Helper()
projDir := filepath.Join(dataDir, "event_projs", project)
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
@@ -90,6 +98,135 @@ func writeEventDB(t *testing.T, dataDir string) {
}
}
// writeActiveProjectFile 写记忆文件,模拟「上次打开过的赛事」。
func writeActiveProjectFile(t *testing.T, dataDir, name string) {
t.Helper()
writeActiveProjectRaw(t, dataDir, name+"\n")
}
// writeActiveProjectRaw 直接写记忆文件原文,用于构造被改坏的记忆文件。
func writeActiveProjectRaw(t *testing.T, dataDir, content string) {
t.Helper()
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("创建数据目录: %v", err)
}
if err := os.WriteFile(filepath.Join(dataDir, httpapi.ActiveProjectFile), []byte(content), 0o644); err != nil {
t.Fatalf("写记忆文件: %v", err)
}
}
// 启动解析顺序:显式 -project > 记忆文件里的上一次赛事 > default。
// 这正是「导入赛事包后管理面板还是空的」的修复点:Console 不传 -project,只能靠记忆文件。
func TestResolveProjectNamePriority(t *testing.T) {
dataDir := t.TempDir()
name, source, err := resolveProjectName(dataDir, "")
if err != nil || name != defaultProjectName || source != projectSourceDefault {
t.Fatalf("没有记忆文件时应回落 default,得到 %q/%q/%v", name, source, err)
}
name, source, err = resolveProjectName(dataDir, " ")
if err != nil || name != defaultProjectName || source != projectSourceDefault {
t.Fatalf("空白 -project 等于没传,得到 %q/%q/%v", name, source, err)
}
writeActiveProjectFile(t, dataDir, "秋季赛")
name, source, err = resolveProjectName(dataDir, "")
if err != nil || name != "秋季赛" || source != projectSourceFile {
t.Fatalf("有记忆文件时应用它,得到 %q/%q/%v", name, source, err)
}
name, source, err = resolveProjectName(dataDir, "冬季赛")
if err != nil || name != "冬季赛" || source != projectSourceFlag {
t.Fatalf("显式 -project 应压过记忆文件,得到 %q/%q/%v", name, source, err)
}
// 显式传了非法名字必须报错退出,不能静默回落到别的赛事——那会让现场打开错的库。
if _, _, err := resolveProjectName(dataDir, "../逃逸"); err == nil {
t.Fatal("非法 -project 应报错")
}
if _, _, err := resolveProjectName(dataDir, "a/b"); err == nil {
t.Fatal("含分隔符的 -project 应报错")
}
}
// 记忆文件被手工改坏时回落 default,而不是打开一个非法或半截的目录。
func TestResolveProjectNameInvalidMemoryFile(t *testing.T) {
for _, tc := range []struct{ name, content string }{
{"路径穿越", "../逃逸\n"},
{"多行", "好名字\n坏名字\n"},
{"只有空白", " \n"},
{"末尾点号", "赛事.\n"},
} {
dataDir := t.TempDir()
writeActiveProjectRaw(t, dataDir, tc.content)
name, source, err := resolveProjectName(dataDir, "")
if err != nil || name != defaultProjectName || source != projectSourceDefault {
t.Errorf("%s:非法记忆文件应回落 default,得到 %q/%q/%v", tc.name, name, source, err)
}
}
// 数据目录还不存在(首次启动)同样回落 default,不能报错拦启动。
name, source, err := resolveProjectName(filepath.Join(t.TempDir(), "还没有"), "")
if err != nil || name != defaultProjectName || source != projectSourceDefault {
t.Fatalf("数据目录不存在时应回落 default,得到 %q/%q/%v", name, source, err)
}
}
// CLI 备份必须跟服务端打开同一个赛事库:项目名同样从记忆文件解析。
func TestRunBackupUsesRememberedProject(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
writeProjectEventDB(t, dataDir, "秋季赛")
writeActiveProjectFile(t, dataDir, "秋季赛")
cfgPath := writeConfig(t, root, dataDir)
if err := runBackup([]string{"-config", cfgPath, "-reason", "manual"}); err != nil {
t.Fatalf("runBackup: %v", err)
}
entries, err := os.ReadDir(filepath.Join(dataDir, "backups"))
if err != nil {
t.Fatalf("读取备份目录: %v", err)
}
eventBackup := false
for _, e := range entries {
if strings.HasPrefix(e.Name(), "event-manual-") {
eventBackup = true
}
}
if !eventBackup {
t.Fatalf("应按记忆文件里的 秋季赛 备份赛事库,实际文件: %v", entries)
}
// 备份是只读操作,绝不能顺手改掉「下次打开哪个赛事」。
raw, err := os.ReadFile(filepath.Join(dataDir, httpapi.ActiveProjectFile))
if err != nil || strings.TrimSpace(string(raw)) != "秋季赛" {
t.Fatalf("记忆文件不该被备份改写,得到 %q / %v", raw, err)
}
}
// 显式 -project 优先于记忆文件:指到没有 event.db 的项目时就不备份赛事库。
func TestRunBackupExplicitProjectBeatsMemory(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
writeProjectEventDB(t, dataDir, "秋季赛")
writeActiveProjectFile(t, dataDir, "秋季赛")
cfgPath := writeConfig(t, root, dataDir)
if err := runBackup([]string{"-config", cfgPath, "-project", "没导入过", "-reason", "manual"}); err != nil {
t.Fatalf("runBackup: %v", err)
}
entries, err := os.ReadDir(filepath.Join(dataDir, "backups"))
if err != nil {
t.Fatalf("读取备份目录: %v", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), "event-") {
t.Fatalf("显式 -project 指到空项目时不该备份赛事库,实际文件: %s", e.Name())
}
}
}
// writeConfig 写一份只指定数据目录的最小配置,返回配置路径。
func writeConfig(t *testing.T, dir, dataDir string) string {
t.Helper()
+6
View File
@@ -16,6 +16,8 @@ func runPasswd(args []string) error {
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
configPath := fs.String("config", "config.yaml", "配置文件路径")
dataDir := fs.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
// passwd 不涉及赛事项目,参数只为与 server / backup 的命令行口径一致(解析顺序、校验规则相同)。
project := fs.String("project", "", "赛事项目名;留空则用记住的上一次赛事,再没有就用 default")
username := fs.String("user", "admin", "用户名")
newUsername := fs.String("new-username", "", "同时把用户名改成这个(留空则不改)")
password := fs.String("password", "", "新密码;留空则随机生成并打印")
@@ -30,6 +32,10 @@ func runPasswd(args []string) error {
// Console 拉起服务端时用 -data 把数据指到安装目录之外;这里必须用同一份,
// 否则改的是安装目录下那个空库里的账号。
cfg.ApplyDataDir(*dataDir)
// 解析一次只为与另外两个命令同名同规则;passwd 不需要赛事项目名,解析结果不参与后续逻辑。
if _, _, err := resolveProjectName(cfg.DataDir, *project); err != nil {
return err
}
st, err := store.Open(cfg.DataDir)
if err != nil {