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:
1 parent
005b51b2ec
commit
2d207fd654
14 files changed
+1363
-24
No files matched your search
+22
-3
@@ -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 节。
|
||||
|
||||
+16
-9
@@ -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`,相对服务端工作目录解析。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -641,6 +641,54 @@ export const projectApi = {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 切换赛事后的重启等待
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServerRestartWaitOptions {
|
||||
/** 健康检查;不抛错即视为服务端已回来。 */
|
||||
probe: () => Promise<unknown>
|
||||
/** 两次探测的间隔,默认 1.5 秒。 */
|
||||
intervalMs?: number
|
||||
/** 总等待上限,默认 60 秒。 */
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* true 表示这次切换会让服务端重启。服务端先优雅退出、再由 Console 拉起,
|
||||
* 旧进程在退出前仍能满足健康检查,所以必须等到探测到一次失败才认为重启真的开始,
|
||||
* 否则会在旧进程上误判「已经回来了」。
|
||||
*/
|
||||
waitForDown?: boolean
|
||||
/** 便于测试注入;默认用 window.setTimeout。 */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/** 轮询健康检查等服务端回来;返回 false 表示超时,调用方需提示人工检查服务状态。 */
|
||||
export async function waitForServerOnline(options: ServerRestartWaitOptions): Promise<boolean> {
|
||||
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<void> =>
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { formatDateTime, type ProjectInfo } from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
projectApi,
|
||||
resultsExportApi,
|
||||
rulesApi,
|
||||
waitForServerOnline,
|
||||
type ProjectImportResult,
|
||||
type ResultExportKind,
|
||||
type RuleApplyResult,
|
||||
@@ -26,6 +27,9 @@ import { useToastStore } from '../stores/toast'
|
||||
*
|
||||
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
|
||||
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
|
||||
*
|
||||
* 赛事包导入只是把目录解到数据目录里,服务端只打开「当前赛事」那一项,
|
||||
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
|
||||
*/
|
||||
|
||||
type TabKey = 'results' | 'project' | 'rules'
|
||||
@@ -86,6 +90,16 @@ const confirmProjectImport = ref(false)
|
||||
const projectResult = ref<ProjectImportResult | null>(null)
|
||||
const projectError = ref<string | null>(null)
|
||||
|
||||
// 服务端一次只打开一个赛事项目(其余目录只是躺在数据目录里的数据),
|
||||
// 导入不会改变「当前赛事」,所以这里列出全部项目供切换。
|
||||
const projects = ref<ProjectInfo[]>([])
|
||||
const projectsLoading = ref(false)
|
||||
const projectsError = ref<string | null>(null)
|
||||
const switchTarget = ref<string | null>(null)
|
||||
const switchError = ref<string | null>(null)
|
||||
/** 已触发重启、正在等服务端回来;期间所有操作都要挡住。 */
|
||||
const restarting = ref(false)
|
||||
|
||||
// ── 规则包 ──────────────────────────────────────────────────────────────────
|
||||
const rulesFileInput = ref<HTMLInputElement | null>(null)
|
||||
const rulesFileName = ref('')
|
||||
@@ -101,6 +115,18 @@ const confirmRulesApply = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
|
||||
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
|
||||
|
||||
/**
|
||||
* 刚导入的赛事还不是当前赛事时,页面上的数据仍旧来自原来的当前赛事,需要就地给出切换入口。
|
||||
* 列表读不到时也按「还不是当前」处理:宁可多提示一次,也不要谎报已生效。
|
||||
*/
|
||||
const importedNotActive = computed(() => {
|
||||
const imported = projectResult.value
|
||||
if (imported === null) return null
|
||||
return activeProjectName.value === imported.name ? null : imported
|
||||
})
|
||||
|
||||
const CHANGE_LABEL: Record<string, string> = {
|
||||
added: '新增',
|
||||
removed: '删除',
|
||||
@@ -206,6 +232,8 @@ async function runProjectImport(): Promise<void> {
|
||||
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
|
||||
projectFile.value = null
|
||||
if (projectFileInput.value !== null) projectFileInput.value.value = ''
|
||||
// 新导入的项目要出现在列表里,用户才能就地切成当前赛事。
|
||||
void loadProjects()
|
||||
} catch (caught) {
|
||||
projectError.value = describeError(caught)
|
||||
} finally {
|
||||
@@ -213,6 +241,81 @@ async function runProjectImport(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
if (!isAdmin.value) return
|
||||
projectsLoading.value = true
|
||||
projectsError.value = null
|
||||
try {
|
||||
projects.value = await adminApi.client.projects.list()
|
||||
} catch (caught) {
|
||||
projects.value = []
|
||||
projectsError.value = describeError(caught)
|
||||
} finally {
|
||||
projectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面挂载时 AdminLayout 还在探测会话,isAdmin 之后才变 true,所以要跟着它再拉一次列表。
|
||||
watch(
|
||||
isAdmin,
|
||||
(value) => {
|
||||
if (value) void loadProjects()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 切换当前赛事。
|
||||
*
|
||||
* 服务端会把项目名写进记忆文件后优雅退出,由 Console 自动重启服务端并打开新赛事;
|
||||
* 这段时间页面必须轮询健康检查等它回来,不能像普通写操作那样立刻认为已经生效。
|
||||
*/
|
||||
async function activateProject(name: string): Promise<void> {
|
||||
if (!isAdmin.value) {
|
||||
toast.warning('只有管理员可以切换当前赛事')
|
||||
return
|
||||
}
|
||||
if (restarting.value || switchTarget.value !== null) return
|
||||
switchTarget.value = name
|
||||
switchError.value = null
|
||||
try {
|
||||
const result = await adminApi.client.projects.activate(name)
|
||||
if (!result.restarting) {
|
||||
// 本来就是当前赛事,服务端不会退出,直接刷新拿最新数据。
|
||||
toast.success(`「${result.name}」已是当前赛事`)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
restarting.value = true
|
||||
toast.info('正在重启服务端…', `正在切换到「${result.name}」,请勿关闭服务管理窗口`)
|
||||
const online = await waitForServerOnline({
|
||||
probe: () => adminApi.client.health(),
|
||||
waitForDown: true,
|
||||
})
|
||||
if (online) {
|
||||
toast.success('服务端已恢复,正在刷新页面')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
restarting.value = false
|
||||
switchError.value =
|
||||
'等待服务端重启超时(60 秒)。请打开服务管理窗口确认服务端状态,待其「运行中」后手动刷新本页。'
|
||||
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
|
||||
void loadProjects()
|
||||
} catch (caught) {
|
||||
switchError.value = describeError(caught)
|
||||
} finally {
|
||||
switchTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRules(): Promise<void> {
|
||||
rulesExporting.value = true
|
||||
rulesError.value = null
|
||||
@@ -377,6 +480,84 @@ async function applyRules(): Promise<void> {
|
||||
|
||||
<!-- 赛事项目包 -->
|
||||
<div v-else-if="activeTab === 'project'" class="project-grid">
|
||||
<section class="mx-card wide">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">赛事项目列表</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || projectsLoading || restarting"
|
||||
@click="loadProjects"
|
||||
>
|
||||
<MxIcon name="refresh" :size="14" /> {{ projectsLoading ? '读取中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="hint">
|
||||
服务端一次只打开一个赛事项目,页面上看到的数据全部来自标记为「当前」的那一项。
|
||||
导入项目包只是把数据解到数据目录,必须切换为当前赛事才会生效。
|
||||
</p>
|
||||
<p v-if="!isAdmin" class="notice">切换当前赛事会让服务端重启,只有管理员可操作。</p>
|
||||
|
||||
<p v-if="restarting" class="notice warn">
|
||||
正在重启服务端…切换赛事需要服务端退出后由服务管理窗口自动拉起,请勿关闭该窗口(最多等待 60 秒)。
|
||||
</p>
|
||||
<p v-if="switchError !== null" class="notice error">{{ switchError }}</p>
|
||||
<p v-if="projectsError !== null" class="notice error">读取赛事项目列表失败:{{ projectsError }}</p>
|
||||
|
||||
<div v-if="projects.length === 0 && projectsError === null" class="mx-empty">
|
||||
<div class="mx-empty-title">
|
||||
{{
|
||||
!isAdmin
|
||||
? '赛事项目列表仅管理员可见'
|
||||
: projectsLoading
|
||||
? '正在读取赛事项目…'
|
||||
: '没有可切换的赛事项目'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="isAdmin && !projectsLoading" class="mx-empty-desc">
|
||||
导入一个赛事项目包后,它会出现在这个列表里
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length > 0" class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
<th>大小</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in projects" :key="item.name">
|
||||
<td class="mono">{{ item.name }}</td>
|
||||
<td>
|
||||
<span v-if="item.active" class="mx-badge mx-badge-success">当前</span>
|
||||
<span v-else-if="!item.hasEvent" class="mx-badge mx-badge-error">缺 event.db</span>
|
||||
<span v-else class="mx-badge mx-badge-neutral">未打开</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(item.updatedAt) }}</td>
|
||||
<td>{{ formatBytes(item.sizeBytes) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || item.active || !item.hasEvent || restarting || switchTarget !== null"
|
||||
@click="activateProject(item.name)"
|
||||
>
|
||||
{{ switchTarget === item.name ? '切换中…' : '切换为当前赛事' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">导出项目包</span>
|
||||
@@ -388,7 +569,12 @@ async function applyRules(): Promise<void> {
|
||||
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。
|
||||
整包拷走即可在另一台机器复现整场赛事。
|
||||
</p>
|
||||
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @click="exportProject">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="projectExporting || restarting"
|
||||
@click="exportProject"
|
||||
>
|
||||
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -442,7 +628,7 @@ async function applyRules(): Promise<void> {
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || projectImporting || projectFile === null"
|
||||
:disabled="!isAdmin || projectImporting || projectFile === null || restarting"
|
||||
@click="requestProjectImport"
|
||||
>
|
||||
{{ projectImporting ? '导入中…' : '导入' }}
|
||||
@@ -455,7 +641,28 @@ async function applyRules(): Promise<void> {
|
||||
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
|
||||
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
|
||||
</dl>
|
||||
<p class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
|
||||
|
||||
<p v-if="importedNotActive === null" class="hint">
|
||||
「{{ projectResult.name }}」已经是当前赛事,直接刷新即可看到数据。
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="notice warn">
|
||||
已导入为「{{ projectResult.name }}」,但它还不是当前赛事:服务端当前打开的是「{{
|
||||
activeProjectName ?? 'default'
|
||||
}}」,页面上看到的是那一项的数据。
|
||||
</p>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || restarting || switchTarget !== null"
|
||||
@click="activateProject(projectResult.name)"
|
||||
>
|
||||
<MxIcon name="refresh" :size="15" />
|
||||
{{ switchTarget === projectResult.name ? '切换中…' : '立即切换并重启服务端' }}
|
||||
</button>
|
||||
<p class="hint">切换会让服务端退出并由服务管理窗口自动拉起,期间页面会短暂连不上。</p>
|
||||
</template>
|
||||
<p v-if="!isAdmin" class="notice">只有管理员可以切换当前赛事。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -665,6 +872,10 @@ async function applyRules(): Promise<void> {
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
|
||||
.project-grid > .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ApiError, createApiClient } from '@/shared/api'
|
||||
import { waitForServerOnline } from '../components/system/systemApi'
|
||||
|
||||
/**
|
||||
* 赛事项目切换是本路新增能力:导入赛事包后不切换,服务端就一直看着原来那个项目,
|
||||
* 页面上永远是空数据。这里钉住前端这一侧的契约(路径 / 字段名 / 降级口径)。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function captureClient(reply: (captured: Captured) => Response): {
|
||||
client: ReturnType<typeof createApiClient>
|
||||
captured: Captured
|
||||
} {
|
||||
const captured: Captured = {}
|
||||
const client = createApiClient({
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body
|
||||
return reply(captured)
|
||||
},
|
||||
})
|
||||
return { client, captured }
|
||||
}
|
||||
|
||||
test('projects.list 读 GET /api/projects 并归一化每条项目', async () => {
|
||||
const { client, captured } = captureClient(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
projects: [
|
||||
{
|
||||
name: 'default',
|
||||
active: true,
|
||||
hasEvent: true,
|
||||
sizeBytes: 2048,
|
||||
updatedAt: '2026-09-10 08:00:00',
|
||||
},
|
||||
{ name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = await client.projects.list()
|
||||
|
||||
assert.equal(captured.url, '/api/projects')
|
||||
assert.equal(captured.method, 'GET')
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
name: 'default',
|
||||
active: true,
|
||||
hasEvent: true,
|
||||
sizeBytes: 2048,
|
||||
updatedAt: '2026-09-10 08:00:00',
|
||||
},
|
||||
{ name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null },
|
||||
])
|
||||
})
|
||||
|
||||
// 缺字段时按「有库、未激活」呈现:真正缺库激活时服务端会给出中文 400,
|
||||
// 不该在列表里被静默禁掉,那样用户只会看到一排点不动的按钮。
|
||||
test('projects.list 补全缺失字段,并跳过没有名字的条目', async () => {
|
||||
const { client } = captureClient(() =>
|
||||
jsonResponse({ ok: true, data: [{ name: '甲' }, { active: true }, 'x'] }),
|
||||
)
|
||||
|
||||
const rows = await client.projects.list()
|
||||
|
||||
assert.deepEqual(rows, [{ name: '甲', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null }])
|
||||
})
|
||||
|
||||
test('projects.list 响应格式不符时报 PARSE_FAILED,不伪装成空列表', async () => {
|
||||
const { client } = captureClient(() => jsonResponse({ ok: true, data: { nope: 1 } }))
|
||||
|
||||
await assert.rejects(
|
||||
() => client.projects.list(),
|
||||
(error: unknown) => error instanceof ApiError && error.code === 'PARSE_FAILED',
|
||||
)
|
||||
})
|
||||
|
||||
test('projects.activate 以 POST /api/projects/activate 提交项目名', async () => {
|
||||
const { client, captured } = captureClient(() =>
|
||||
jsonResponse({ ok: true, data: { ok: true, name: '市运会2026', restarting: true } }),
|
||||
)
|
||||
|
||||
const result = await client.projects.activate('市运会2026')
|
||||
|
||||
assert.equal(captured.url, '/api/projects/activate')
|
||||
assert.equal(captured.method, 'POST')
|
||||
const body = JSON.parse(String(captured.body)) as Record<string, unknown>
|
||||
assert.equal(body.name, '市运会2026')
|
||||
assert.equal(result.restarting, true)
|
||||
assert.equal(result.name, '市运会2026')
|
||||
})
|
||||
|
||||
// restarting 缺失按 false 处理:这是「本来就是当前赛事」的回答,
|
||||
// 误判成 true 会让页面干等一个永远不会发生的重启,60 秒后误报失败。
|
||||
test('projects.activate 缺 restarting 时按「不重启」处理,失败文案原样抛出', async () => {
|
||||
const ok = captureClient(() => jsonResponse({ ok: true, data: { name: 'default' } }))
|
||||
assert.deepEqual(await ok.client.projects.activate('default'), { name: 'default', restarting: false })
|
||||
|
||||
const failed = captureClient(() =>
|
||||
jsonResponse(
|
||||
{ ok: false, error: { code: 'VALIDATION_FAILED', message: '赛事项目不存在:丙', detail: '丙' } },
|
||||
400,
|
||||
),
|
||||
)
|
||||
await assert.rejects(
|
||||
() => failed.client.projects.activate('丙'),
|
||||
(error: unknown) =>
|
||||
error instanceof ApiError && error.status === 400 && error.message === '赛事项目不存在:丙',
|
||||
)
|
||||
})
|
||||
|
||||
// 可控时钟:sleep 只推进 now,不真的等待,超时分支才能瞬间跑完。
|
||||
function fakeClock(): ServerRestartClock {
|
||||
let time = 0
|
||||
return {
|
||||
now: () => time,
|
||||
sleep: async (ms: number) => {
|
||||
time += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerRestartClock {
|
||||
now: () => number
|
||||
sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
test('waitForServerOnline 在服务端重新可达时返回 true', async () => {
|
||||
const clock = fakeClock()
|
||||
const replies = [false, false, true]
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 60_000,
|
||||
probe: async () => {
|
||||
const up = replies[probes] ?? true
|
||||
probes += 1
|
||||
if (!up) throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, true)
|
||||
assert.equal(probes, 3)
|
||||
})
|
||||
|
||||
// 优雅退出需要时间:旧进程在退出前仍能满足健康检查,
|
||||
// 不等首次失败就下结论会在旧进程上误判「已经回来了」。
|
||||
test('waitForServerOnline 要求先探到一次失败,再等恢复', async () => {
|
||||
const clock = fakeClock()
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 60_000,
|
||||
waitForDown: true,
|
||||
probe: async () => {
|
||||
probes += 1
|
||||
// 前两次仍是旧进程(健康检查照常通过),第三、四次进入重启窗口,第五次新进程已起来。
|
||||
if (probes === 3 || probes === 4) throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, true)
|
||||
assert.equal(probes, 5)
|
||||
})
|
||||
|
||||
test('waitForServerOnline 一直连不上时超时返回 false', async () => {
|
||||
const clock = fakeClock()
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 6000,
|
||||
probe: async () => {
|
||||
probes += 1
|
||||
throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, false)
|
||||
assert.equal(probes, 5)
|
||||
})
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
ProjectActivateResult,
|
||||
ProjectInfo,
|
||||
PublishPayload,
|
||||
RecordBreak,
|
||||
RecordBreakInput,
|
||||
@@ -212,6 +214,19 @@ export interface SystemApi {
|
||||
backup(): Promise<BackupResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事项目切换。
|
||||
*
|
||||
* 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default),
|
||||
* 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。
|
||||
*/
|
||||
export interface ProjectsApi {
|
||||
/** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */
|
||||
list(): Promise<ProjectInfo[]>
|
||||
/** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */
|
||||
activate(name: string): Promise<ProjectActivateResult>
|
||||
}
|
||||
|
||||
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
|
||||
export interface SystemAbout {
|
||||
internalSignature?: string
|
||||
@@ -308,6 +323,48 @@ function createRulesApi(http: Http): RulesApi {
|
||||
}
|
||||
}
|
||||
|
||||
function readProjectInfo(value: unknown): ProjectInfo | null {
|
||||
if (!isRecord(value)) return null
|
||||
const name = readString(value, 'name')
|
||||
if (name === null) return null
|
||||
return {
|
||||
name,
|
||||
active: value.active === true,
|
||||
// 缺字段时按「有库」处理:真正缺库时激活接口会给出中文报错,不该在列表里被静默禁掉。
|
||||
hasEvent: value.hasEvent !== false,
|
||||
sizeBytes:
|
||||
typeof value.sizeBytes === 'number' && Number.isFinite(value.sizeBytes) ? value.sizeBytes : 0,
|
||||
updatedAt: readString(value, 'updatedAt'),
|
||||
}
|
||||
}
|
||||
|
||||
function createProjectsApi(http: Http): ProjectsApi {
|
||||
return {
|
||||
list: async () => {
|
||||
const body = await http.request<unknown>('/projects')
|
||||
// 服务端回 { projects: [...] };裸数组也接受,省得为了外层字段名再改一次前端。
|
||||
const raw = Array.isArray(body)
|
||||
? body
|
||||
: isRecord(body) && Array.isArray(body.projects)
|
||||
? body.projects
|
||||
: null
|
||||
if (raw === null) throw new ApiError('PARSE_FAILED', '赛事项目列表响应格式不符', 0, body)
|
||||
return raw.map(readProjectInfo).filter((row): row is ProjectInfo => row !== null)
|
||||
},
|
||||
activate: async (name) => {
|
||||
const body = await http.request<unknown>('/projects/activate', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
// 字段缺失时按「不需要重启」处理:宁可让页面直接刷新,也不要卡在一个不会来的重启上。
|
||||
return {
|
||||
name: isRecord(body) ? (readString(body, 'name') ?? name) : name,
|
||||
restarting: isRecord(body) && body.restarting === true,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function readCsrfCookie(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||
@@ -341,6 +398,7 @@ export class ApiClient {
|
||||
readonly auditLogs: AuditApi
|
||||
readonly rules: RulesApi
|
||||
readonly system: SystemApi
|
||||
readonly projects: ProjectsApi
|
||||
readonly uploads: UploadsApi
|
||||
|
||||
private readonly baseUrl: string
|
||||
@@ -447,6 +505,7 @@ export class ApiClient {
|
||||
this.system = {
|
||||
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
|
||||
}
|
||||
this.projects = createProjectsApi(http)
|
||||
|
||||
this.uploads = {
|
||||
upload: (file) => {
|
||||
|
||||
@@ -458,6 +458,27 @@ export interface HealthInfo {
|
||||
version: string
|
||||
}
|
||||
|
||||
/** 数据目录 `event_projs/` 下的一个赛事项目(GET /api/projects,admin)。 */
|
||||
export interface ProjectInfo {
|
||||
name: string
|
||||
/** 是否为服务端当前打开的赛事(启动参数 -project,或记忆文件里记下的名字)。 */
|
||||
active: boolean
|
||||
/** 目录里是否存在 event.db;缺库的项目不可切换。 */
|
||||
hasEvent: boolean
|
||||
sizeBytes: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** POST /api/projects/activate 的结果。 */
|
||||
export interface ProjectActivateResult {
|
||||
name: string
|
||||
/**
|
||||
* true:服务端已把项目名写入记忆文件并优雅退出,等待 Console 自动拉起;
|
||||
* false:本来就是当前赛事,服务端不会退出,直接刷新页面即可。
|
||||
*/
|
||||
restarting: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 规则包(见 02-积分与排名规则 第 7 节)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user