根因:服务端启动只认 -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 文件通过。
216 lines
8.0 KiB
Go
216 lines
8.0 KiB
Go
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
|
||
}
|