Files
AskaEth 2d207fd654 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 文件通过。
2026-09-10 09:47:06 +08:00

277 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}