feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由 - store: system.db + event.db 两套迁移,业务表 + 种子数据 - domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试 - web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端) - console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
1 parent
a61d793d79
commit
7d5d94f815
76 files changed
+10131
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
|
||||
)
|
||||
|
||||
const createMigrationsTable = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
|
||||
// Migrate 按文件名顺序迁移系统库(data/system.db)。
|
||||
func (s *Store) Migrate() error {
|
||||
fsys, err := fs.Sub(migrations.SystemFS, "system")
|
||||
if err != nil {
|
||||
return fmt.Errorf("定位系统库迁移目录: %w", err)
|
||||
}
|
||||
return migrate(s.db, fsys, "system")
|
||||
}
|
||||
|
||||
// MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开,
|
||||
// 本函数只负责执行 event/ 下的迁移,不持有连接。
|
||||
func MigrateEvent(db *sql.DB) error {
|
||||
fsys, err := fs.Sub(migrations.EventFS, "event")
|
||||
if err != nil {
|
||||
return fmt.Errorf("定位赛事库迁移目录: %w", err)
|
||||
}
|
||||
return migrate(db, fsys, "event")
|
||||
}
|
||||
|
||||
// migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。
|
||||
func migrate(db *sql.DB, fsys fs.FS, label string) error {
|
||||
if _, err := db.Exec(createMigrationsTable); err != nil {
|
||||
return fmt.Errorf("创建 schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := appliedMigrations(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names, err := fs.Glob(fsys, "*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取迁移 %s: %w", name, err)
|
||||
}
|
||||
if err := applyMigration(db, name, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("已应用迁移", "db", label, "name", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedMigrations(db *sql.DB) (map[string]bool, error) {
|
||||
rows, err := db.Query(`SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询迁移记录: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applied[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func applyMigration(db *sql.DB, name, body string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(body); err != nil {
|
||||
return fmt.Errorf("执行迁移 %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil {
|
||||
return fmt.Errorf("记录迁移 %s: %w", name, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package store 负责数据库连接、迁移与仓储。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store 持有系统库(data/system.db)的连接。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open 打开(必要时创建)数据目录下的 system.db。
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据目录 %s: %w", dataDir, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, "system.db")
|
||||
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)
|
||||
}
|
||||
|
||||
// SQLite 只允许单个写者,连接数收紧到 1 最省心。
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("连接数据库 %s: %w", path, err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接。
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// DB 暴露底层连接,供仓储层使用。
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
@@ -0,0 +1,232 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openEventDB 打开一个临时赛事库;DSN 与 store.Open 保持一致,便于验证外键约束。
|
||||
func openEventDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "event.db")
|
||||
dsn := "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=foreign_keys(1)" +
|
||||
"&_pragma=busy_timeout(5000)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("打开赛事库: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateSystem(t *testing.T) {
|
||||
st, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Fatalf("首次迁移: %v", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"schema_migrations", "settings", "users", "sessions", "recent_projects"} {
|
||||
assertTable(t, st.DB(), table)
|
||||
}
|
||||
|
||||
if got := queryString(t, st.DB(), `SELECT value FROM settings WHERE key = 'app.name'`); got == "" {
|
||||
t.Error("默认系统设置 app.name 缺失")
|
||||
}
|
||||
|
||||
before := countRows(t, st.DB(), "schema_migrations")
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Fatalf("重复迁移: %v", err)
|
||||
}
|
||||
if after := countRows(t, st.DB(), "schema_migrations"); after != before {
|
||||
t.Errorf("重复迁移应幂等,迁移记录 %d → %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateEvent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
|
||||
if err := MigrateEvent(db); err != nil {
|
||||
t.Fatalf("首次迁移: %v", err)
|
||||
}
|
||||
|
||||
tables := []string{
|
||||
"schema_migrations", "settings",
|
||||
"teams", "athletes", "sports", "events", "event_categories", "event_score_channels",
|
||||
"scoring_profiles", "scoring_profile_rules",
|
||||
"entries", "results", "records", "record_breaks",
|
||||
"score_ledger", "point_adjustments", "result_revisions", "change_log", "audit_logs",
|
||||
}
|
||||
for _, table := range tables {
|
||||
assertTable(t, db, table)
|
||||
}
|
||||
|
||||
checkProfile(t, db, "individual_top6", "个人项目前六名", []float64{10, 8, 6, 3, 2, 1})
|
||||
checkProfile(t, db, "team_top6", "团体项目前六名", []float64{15, 10, 8, 4, 2, 1})
|
||||
|
||||
if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'event.name'`); got != "未命名赛事" {
|
||||
t.Errorf("默认赛事设置 event.name = %q,期望 %q", got, "未命名赛事")
|
||||
}
|
||||
|
||||
before := []int{
|
||||
countRows(t, db, "schema_migrations"),
|
||||
countRows(t, db, "scoring_profiles"),
|
||||
countRows(t, db, "scoring_profile_rules"),
|
||||
countRows(t, db, "settings"),
|
||||
}
|
||||
if err := MigrateEvent(db); err != nil {
|
||||
t.Fatalf("重复迁移: %v", err)
|
||||
}
|
||||
after := []int{
|
||||
countRows(t, db, "schema_migrations"),
|
||||
countRows(t, db, "scoring_profiles"),
|
||||
countRows(t, db, "scoring_profile_rules"),
|
||||
countRows(t, db, "settings"),
|
||||
}
|
||||
for i, name := range []string{"schema_migrations", "scoring_profiles", "scoring_profile_rules", "settings"} {
|
||||
if before[i] != after[i] {
|
||||
t.Errorf("重复迁移应幂等,%s 行数 %d → %d", name, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSchemaAcceptsCoreFlow(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
if err := MigrateEvent(db); err != nil {
|
||||
t.Fatalf("迁移: %v", err)
|
||||
}
|
||||
|
||||
mustExec(t, db, `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`)
|
||||
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`)
|
||||
mustExec(t, db, `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`)
|
||||
mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`)
|
||||
mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`)
|
||||
mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`)
|
||||
mustExec(t, db, `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 'result', 1, 10)`)
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO results(event_id, entry_id) VALUES (1, 1)`); err == nil {
|
||||
t.Error("results.entry_id 应 UNIQUE")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name) VALUES (999, 'E9', '幽灵项目')`); err == nil {
|
||||
t.Error("外键未生效:不存在的 sport_id 应被拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO athletes(name, gender) VALUES ('张三', '未知')`); err == nil {
|
||||
t.Error("CHECK 未生效:非法性别应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func checkProfile(t *testing.T, db *sql.DB, code, name string, want []float64) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
gotName, tieMode, bonusMode string
|
||||
awardRanks, lastPlaceZero int
|
||||
)
|
||||
err := db.QueryRow(
|
||||
`SELECT name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
|
||||
FROM scoring_profiles WHERE code = ?`, code,
|
||||
).Scan(&gotName, &tieMode, &bonusMode, &awardRanks, &lastPlaceZero)
|
||||
if err != nil {
|
||||
t.Fatalf("查询模板 %s: %v", code, err)
|
||||
}
|
||||
|
||||
if gotName != name {
|
||||
t.Errorf("模板 %s 名称 = %q,期望 %q", code, gotName, name)
|
||||
}
|
||||
if tieMode != "skip" {
|
||||
t.Errorf("模板 %s tie_mode = %q,期望 skip", code, tieMode)
|
||||
}
|
||||
if bonusMode != "max" {
|
||||
t.Errorf("模板 %s record_bonus_mode = %q,期望 max", code, bonusMode)
|
||||
}
|
||||
if awardRanks != 6 {
|
||||
t.Errorf("模板 %s award_ranks = %d,期望 6", code, awardRanks)
|
||||
}
|
||||
if lastPlaceZero != 0 {
|
||||
t.Errorf("模板 %s last_place_zero = %d,期望 0", code, lastPlaceZero)
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT points FROM scoring_profile_rules
|
||||
WHERE profile_id = (SELECT id FROM scoring_profiles WHERE code = ?)
|
||||
ORDER BY rank`, code,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("查询模板 %s 明细: %v", code, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var got []float64
|
||||
for rows.Next() {
|
||||
var points float64
|
||||
if err := rows.Scan(&points); err != nil {
|
||||
t.Fatalf("扫描模板 %s 明细: %v", code, err)
|
||||
}
|
||||
got = append(got, points)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历模板 %s 明细: %v", code, err)
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("模板 %s 名次条数 = %d,期望 %d(%v)", code, len(got), len(want), want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("模板 %s 第 %d 名积分 = %v,期望 %v", code, i+1, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTable(t *testing.T, db *sql.DB, table string) {
|
||||
t.Helper()
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow(
|
||||
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("查询表 %s: %v", table, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("表 %s 不存在", table)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, query string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
t.Fatalf("执行 %q: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||||
t.Helper()
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func queryString(t *testing.T, db *sql.DB, query string, args ...any) string {
|
||||
t.Helper()
|
||||
|
||||
var v string
|
||||
if err := db.QueryRow(query, args...).Scan(&v); err != nil {
|
||||
t.Fatalf("查询 %q: %v", query, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in new issue
Block a user