人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
70 lines
2.9 KiB
Go
70 lines
2.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
|
|
)
|
|
|
|
// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。
|
|
const shutdownTokenHeader = "X-Shutdown-Token"
|
|
|
|
// systemRoutes 挂载系统级只读信息与优雅关闭握手。
|
|
func (s *server) systemRoutes(mux *http.ServeMux) {
|
|
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
|
|
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
|
|
// 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。
|
|
mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler))
|
|
}
|
|
|
|
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
|
|
return map[string]any{
|
|
"internalSignature": version.InternalSignature,
|
|
// 构建时间同样是编译期注入的,未注入时为空串,由前端显示「未注入」。
|
|
"buildTime": version.BuildTime,
|
|
}, nil
|
|
}
|
|
|
|
// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。
|
|
func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
|
method, ok := s.shutdownAuth(r)
|
|
if !ok {
|
|
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
|
|
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
|
|
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
|
|
}
|
|
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
|
|
}
|
|
if s.shutdown == nil {
|
|
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
|
|
}
|
|
|
|
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
|
// 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。
|
|
go s.shutdown()
|
|
return nil, errHandled
|
|
}
|
|
|
|
// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。
|
|
// 环境变量为空时只认 admin 会话。
|
|
func (s *server) shutdownAuth(r *http.Request) (string, bool) {
|
|
if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
|
|
return "session", true
|
|
}
|
|
expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN"))
|
|
if expected == "" {
|
|
return "", false
|
|
}
|
|
got := r.Header.Get(shutdownTokenHeader)
|
|
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
|
|
return "", false
|
|
}
|
|
return "token", true
|
|
}
|