diff --git a/docs/使用手册.md b/docs/使用手册.md index d8a4419..8211d3b 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -354,8 +354,27 @@ ### 11.2 赛事项目包 -- **导出 zip**:包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`,整包拷走即可在另一台机器复现整场赛事; -- **导入 zip**(仅管理员):选择 `.zip`,可填「重命名」,勾选「覆盖同名项目」会先二次确认;导入完成后需重启服务端并指定项目名才会切换到该赛事(详见部署指南第 8.5 节)。 +服务端一次只打开一个赛事项目,页面上看到的数据全部来自「当前」的那一项,其余项目只是躺在数据目录里的数据。 + +**赛事项目列表** + +- 列出数据目录下的全部项目,含项目名、更新时间与占用大小,当前打开的那一项带「当前」徽标; +- 每行的「切换为当前赛事」用于切换(仅管理员),切换过程见下面「导入后怎样才算生效」; +- 列表里出现「缺 event.db」的项目不可切换,说明该目录不是完整的赛事包。 + +**导出 zip**:包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`,整包拷走即可在另一台机器复现整场赛事。 + +**导入 zip**(仅管理员):选择 `.zip`,可填「重命名」,勾选「覆盖同名项目」会先二次确认。导入只把数据解到数据目录,**不会自动切换当前赛事**,所以导入完成后页面会提示「已导入为 xxx,但它还不是当前赛事」。 + +**导入后怎样才算生效** + +1. 在导入完成的结果框里点「立即切换并重启服务端」(也可以稍后在赛事项目列表里点该项目的「切换为当前赛事」); +2. 服务端把项目名记进赛事数据目录下的 `active_project.txt` 后退出,由服务管理窗口自动拉起,并打开刚切换的赛事;下次启动即使不带启动参数,也仍然是这个赛事; +3. 期间页面显示「正在重启服务端…」,每 1.5 秒探测一次服务端是否回来,恢复后自动刷新页面(最多等 60 秒); +4. 超过 60 秒仍未恢复时,页面会提示打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。 + +> 切换会让服务端重启,正在录入的成绩页会短暂无法提交,请避开比赛进行中操作。 +> 账号是存在各个赛事库里的:切换后当前登录状态会失效,刷新后回到只读访客视图属正常,重新登录即可;若该赛事包里的管理员密码与本地不同而登不上,用 `scoreboard.exe passwd` 重置(见常见问题「忘记密码怎么办」)。 ### 11.3 规则包 @@ -512,7 +531,7 @@ 在「大屏控制 → 公告」输入文字,设置滚动时长(5–120 秒)后点「推送公告」;到时后自动回到之前的页面。 **Q:怎么切换到另一个赛事项目?** -当前服务端一次只运行一个赛事项目(默认 `default`)。导入赛事项目包后,需要重启服务端并指定 `-project <项目名>` 才能切换,详见部署指南第 8.5 节。 +在「导出 → 赛事项目包」的**赛事项目列表**里找到目标项目,点「切换为当前赛事」:服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新。也可以用 Console 的 `config.json` 给 `server.args` 加 `-project <项目名>` 后重启,详见部署指南第 8.5 节;两种方式都会记下「上次打开的赛事」,下次启动没带参数时仍打开它。 **Q:导出的文件在哪里?** 结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 5ee74c8..a36e35f 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -100,7 +100,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 把整个部署目录(含 `data\`)拷到新机器,启动 Console 即可。 **Q28:怎么切换赛事项目?** -服务端一次只运行一个项目(默认 `default`)。导入赛事包后,需要在 Console 的 `config.json` 里给 `server.args` 加 `-project <项目名>` 并重启,详见部署指南第 8.5 节。 +在管理面板「导出 → 赛事项目包」的**赛事项目列表**里找到目标项目,点「切换为当前赛事」:服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新。也可以在 Console 的 `config.json` 里给 `server.args` 加 `-project <项目名>` 后重启,详见部署指南第 8.5 节。两种方式都会记下「上次打开的赛事」,之后启动没带参数时仍打开它。 --- @@ -115,19 +115,26 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 **Q31:谁能导入规则包 / 赛事项目包?** 只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。 -**Q32:导入规则包后积分变了,能撤销吗?** +**Q32:导入赛事包后为什么看不到数据?** +因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。 + +处理办法:在「导出 → 赛事项目包」的**赛事项目列表**里找到刚导入的项目(导入完成的结果框里也直接给了「立即切换并重启服务端」按钮),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新,数据就出来了。超过 60 秒仍未恢复时,请打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。 + +> 切换会让服务端重启,比赛进行中请避开操作;账号存在各个赛事库里,切换后登录状态会失效,重新登录即可,若原密码登不上就用 `scoreboard.exe passwd` 重置。 + +**Q33:导入规则包后积分变了,能撤销吗?** 不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 -**Q33:导入时提示证件号重复怎么办?** +**Q34:导入时提示证件号重复怎么办?** 证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 - **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。 - **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 -**Q34:人员没有身份证,只有号码布,能报名吗?** +**Q35:人员没有身份证,只有号码布,能报名吗?** 可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。 -**Q35:号码布重复了怎么办?** +**Q36:号码布重复了怎么办?** 号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。 - **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。 @@ -137,16 +144,16 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ## 其他 -**Q36:系统需要联网吗?** +**Q37:系统需要联网吗?** 不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 -**Q37:页面提示「示例数据」是什么意思?** +**Q38:页面提示「示例数据」是什么意思?** 表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 > 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 -**Q38:为什么登录 / 写操作偶尔被拒绝?** +**Q39:为什么登录 / 写操作偶尔被拒绝?** 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 -**Q39:数据目录在哪?** +**Q40:数据目录在哪?** 由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 diff --git a/server/internal/httpapi/projects.go b/server/internal/httpapi/projects.go new file mode 100644 index 0000000..0d0a3a1 --- /dev/null +++ b/server/internal/httpapi/projects.go @@ -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 是赛事项目根目录 /event_projs。 +func (s *server) projectsRoot() string { + return filepath.Join(s.dataDir, "event_projs") +} + +// listProjects 列出可用的赛事项目:导入的包都落在 /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 +} diff --git a/server/internal/httpapi/projects_internal_test.go b/server/internal/httpapi/projects_internal_test.go new file mode 100644 index 0000000..fd6b34e --- /dev/null +++ b/server/internal/httpapi/projects_internal_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/projects_test.go b/server/internal/httpapi/projects_test.go new file mode 100644 index 0000000..0e4b864 --- /dev/null +++ b/server/internal/httpapi/projects_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 9b1133e..eab9860 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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 { diff --git a/server/main.go b/server/main.go index 8aa7cd8..bd74596 100644 --- a/server/main.go +++ b/server/main.go @@ -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 { diff --git a/server/main_test.go b/server/main_test.go index 2f32b29..968daf4 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) // CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 -* 清理不到, @@ -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() diff --git a/server/passwd.go b/server/passwd.go index 0e87325..d8ace3d 100644 --- a/server/passwd.go +++ b/server/passwd.go @@ -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 { diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts index a11aacc..4dae899 100644 --- a/web/src/admin/components/system/systemApi.ts +++ b/web/src/admin/components/system/systemApi.ts @@ -641,6 +641,54 @@ export const projectApi = { }, } +// --------------------------------------------------------------------------- +// 切换赛事后的重启等待 +// --------------------------------------------------------------------------- + +export interface ServerRestartWaitOptions { + /** 健康检查;不抛错即视为服务端已回来。 */ + probe: () => Promise + /** 两次探测的间隔,默认 1.5 秒。 */ + intervalMs?: number + /** 总等待上限,默认 60 秒。 */ + timeoutMs?: number + /** + * true 表示这次切换会让服务端重启。服务端先优雅退出、再由 Console 拉起, + * 旧进程在退出前仍能满足健康检查,所以必须等到探测到一次失败才认为重启真的开始, + * 否则会在旧进程上误判「已经回来了」。 + */ + waitForDown?: boolean + /** 便于测试注入;默认用 window.setTimeout。 */ + sleep?: (ms: number) => Promise + now?: () => number +} + +/** 轮询健康检查等服务端回来;返回 false 表示超时,调用方需提示人工检查服务状态。 */ +export async function waitForServerOnline(options: ServerRestartWaitOptions): Promise { + const intervalMs = options.intervalMs ?? 1500 + const timeoutMs = options.timeoutMs ?? 60_000 + const now = options.now ?? ((): number => Date.now()) + const sleep = + options.sleep ?? + ((ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + })) + const deadline = now() + timeoutMs + let downSeen = false + for (;;) { + try { + await options.probe() + if (options.waitForDown !== true || downSeen) return true + } catch { + // 重启窗口内连不上是预期结果,继续等。 + downSeen = true + } + if (now() >= deadline) return false + await sleep(intervalMs) + } +} + // --------------------------------------------------------------------------- // 规则包(YAML) // --------------------------------------------------------------------------- diff --git a/web/src/admin/pages/ExportPage.vue b/web/src/admin/pages/ExportPage.vue index e0d3e00..b2e0e7a 100644 --- a/web/src/admin/pages/ExportPage.vue +++ b/web/src/admin/pages/ExportPage.vue @@ -1,6 +1,6 @@