feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
+147
-7
@@ -3,6 +3,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -10,11 +11,17 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -22,7 +29,16 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "backup" {
|
||||
if err := runBackup(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "备份失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configPath := flag.String("config", "config.yaml", "配置文件路径")
|
||||
project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
@@ -36,20 +52,58 @@ func main() {
|
||||
|
||||
st, err := store.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
logger.Error("打开数据库失败", "err", err)
|
||||
logger.Error("打开系统库失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
if err := st.Migrate(); err != nil {
|
||||
logger.Error("数据库迁移失败", "err", err)
|
||||
logger.Error("系统库迁移失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("数据库就绪", "dataDir", cfg.DataDir)
|
||||
|
||||
eventDB, err := openEventDB(cfg.DataDir, *project)
|
||||
if err != nil {
|
||||
logger.Error("打开赛事库失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = eventDB.Close() }()
|
||||
if err := store.MigrateEvent(eventDB); err != nil {
|
||||
logger.Error("赛事库迁移失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", *project)
|
||||
|
||||
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
|
||||
hub := realtime.NewHub(eventDB, logger)
|
||||
defer func() { _ = hub.Close() }()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if username, password, created, err := authSvc.EnsureDefaultAdmin(ctx); err != nil {
|
||||
logger.Error("初始化默认管理员失败", "err", err)
|
||||
os.Exit(1)
|
||||
} else if created {
|
||||
// 初始密码只打印这一次;数据库里只有 bcrypt 哈希。
|
||||
fmt.Printf("\n========================================\n"+
|
||||
" 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+
|
||||
"========================================\n\n", username, password)
|
||||
logger.Warn("已创建默认管理员", "username", username, "password", password)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Addr(),
|
||||
Handler: httpapi.New(httpapi.Options{Logger: logger, Version: version}),
|
||||
Addr: cfg.Server.Addr(),
|
||||
Handler: httpapi.New(httpapi.Options{
|
||||
Logger: logger,
|
||||
Version: version,
|
||||
System: st.DB(),
|
||||
Event: eventDB,
|
||||
Auth: authSvc,
|
||||
DataDir: cfg.DataDir,
|
||||
Display: displaySender{hub: hub},
|
||||
WS: hub.Handler(),
|
||||
}),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
@@ -61,8 +115,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
|
||||
logger.Info("正在关闭…")
|
||||
@@ -72,3 +124,91 @@ func main() {
|
||||
logger.Error("关闭超时", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// displaySender 把 httpapi 的大屏命令接口适配到实时层。
|
||||
type displaySender struct{ hub *realtime.Hub }
|
||||
|
||||
func (d displaySender) SendDisplayCommand(action string, payload any) error {
|
||||
cmd, err := realtime.NewDisplayCommand(action, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.hub.SendCommand(cmd)
|
||||
return 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", "赛事项目名")
|
||||
reason := fs.String("reason", "manual", "备份原因:manual / publish / auto")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(cfg.DataDir, "backups")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sysDB, err := openSQLite(filepath.Join(cfg.DataDir, "system.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sysDB.Close() }()
|
||||
if _, err := export.Backup(sysDB, export.BackupOptions{Dir: dir, Prefix: "system", Reason: *reason}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evPath := filepath.Join(cfg.DataDir, "event_projs", *project, "event.db")
|
||||
if _, err := os.Stat(evPath); err == nil {
|
||||
evDB, err := openSQLite(evPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = evDB.Close() }()
|
||||
if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: *project, Reason: *reason}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
items, err := export.ListBackups(dir, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("备份完成:%s(现有 %d 份)\n", dir, len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// openEventDB 打开(必要时创建)赛事库;store 包不持有赛事库连接,
|
||||
// 由 main 负责生命周期,再交给 httpapi 与实时层使用。
|
||||
func openEventDB(dataDir, project string) (*sql.DB, error) {
|
||||
dir := filepath.Join(dataDir, "event_projs", project)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建赛事目录 %s: %w", dir, err)
|
||||
}
|
||||
return openSQLite(filepath.Join(dir, "event.db"))
|
||||
}
|
||||
|
||||
func openSQLite(path string) (*sql.DB, error) {
|
||||
dsn := "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=foreign_keys(1)" +
|
||||
"&_pragma=busy_timeout(5000)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库 %s: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("连接数据库 %s: %w", path, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
Reference in new issue
Block a user