Files
Scoreboard/server/internal/httpapi/projects.go
T
AskaEth 2d207fd654 feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)
根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。

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

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
2026-09-10 09:47:06 +08:00

216 lines
8.0 KiB
Go
Raw Blame History

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