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 }