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:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
@echo off
REM ============================================================================
REM Scoreboard 一键备份(Windows)
REM
REM 用 sqlite3.exe 的 VACUUM INTO 生成一致性快照,服务运行中也能安全执行,
REM 不需要停服务、不需要重启。产物落在 data\backups\,滚动保留最近 20 份。
REM
REM 依赖:sqlite3.exe(放到本脚本同目录、项目根目录,或加入 PATH 均可)。
REM 下载地址 https://www.sqlite.org/download.html 的 sqlite-tools-win-x64。
REM
REM 用法:deploy\backup.bat 手动备份(原因记 manual)
REM deploy\backup.bat publish 发布前备份(原因记 publish)
REM ============================================================================
setlocal enabledelayedexpansion
chcp 65001 >nul 2>&1
cd /d "%~dp0.."
set "ROOT=%CD%"
set "DATA=%ROOT%\data"
set "OUT=%DATA%\backups"
set "KEEP=20"
set "REASON=manual"
if not "%~1"=="" set "REASON=%~1"
set "REASON=%REASON: =_%"
set "REASON=%REASON:/=-%"
set "REASON=%REASON:\=-%"
set "REASON=%REASON:"=%"
if not exist "%DATA%" (
echo [错误] 找不到数据目录:%DATA%
echo 请在项目根目录执行本脚本。
exit /b 1
)
REM ---- 定位 sqlite3.exe -------------------------------------------------------
set "SQLITE="
if exist "%~dp0sqlite3.exe" set "SQLITE=%~dp0sqlite3.exe"
if not defined SQLITE if exist "%ROOT%\sqlite3.exe" set "SQLITE=%ROOT%\sqlite3.exe"
if not defined SQLITE for %%I in (sqlite3.exe) do if exist "%%~$PATH:I" set "SQLITE=%%~$PATH:I"
if not defined SQLITE (
echo [错误] 找不到 sqlite3.exe。
echo 请把它放到 deploy\ 目录或加入 PATH 后重试。
exit /b 1
)
REM ---- 时间戳:优先 PowerShell,避免受区域设置影响 -----------------------------
set "TS="
for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss" 2^>nul`) do set "TS=%%I"
if not defined TS call :fallback_ts
if not defined TS (
echo [错误] 无法生成时间戳。
exit /b 1
)
if not exist "%OUT%" mkdir "%OUT%"
echo === Scoreboard 备份 ===
echo 数据目录:%DATA%
echo 备份目录:%OUT%
echo 时间戳 :%TS%
echo.
set /a OK=0
set /a FAIL=0
if exist "%DATA%\system.db" call :backup "system" "%DATA%\system.db"
for /d %%D in ("%DATA%\event_projs\*") do (
if exist "%%~fD\event.db" call :backup "%%~nxD" "%%~fD\event.db"
)
echo.
echo === 滚动清理(保留最近 %KEEP% 份)===
set /a N=0
for /f "delims=" %%F in ('dir /b /o-d /a-d "%OUT%\*.db" 2^>nul') do (
set /a N+=1
if !N! GTR %KEEP% (
echo 删除 %%F
del /f /q "%OUT%\%%F" >nul 2>&1
)
)
echo.
echo === 备份完成:成功 !OK! 个,失败 !FAIL! 个 ===
dir /b "%OUT%\*.db" 2>nul
if !FAIL! GTR 0 (
endlocal
exit /b 1
)
endlocal
exit /b 0
REM ---- 备份单个库:%1 文件名前缀,%2 数据库路径 -------------------------------
:backup
set "PREFIX=%~1"
set "DBFILE=%~2"
set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%.db"
if exist "%TARGET%" set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%-%RANDOM%.db"
echo 备份 %DBFILE%
echo -^> %TARGET%
"%SQLITE%" "%DBFILE%" "VACUUM INTO '%TARGET%'"
if errorlevel 1 (
echo [失败] sqlite3 返回错误
set /a FAIL+=1
) else (
set /a OK+=1
)
exit /b 0
REM ---- %DATE%/%TIME% 兜底:只保证名字不重复 -----------------------------------
:fallback_ts
set "D=%DATE%"
set "T=%TIME%"
set "D=%D:/=%"
set "D=%D:-=%"
set "D=%D:.=%"
set "T=%T::=%"
set "T=%T:.=%"
set "T=%T: =0%"
set "TS=%D%-%T%"
exit /b 0
+401
View File
@@ -0,0 +1,401 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Cookie 名称与会话默认值(见 devdocs/08-安全与可靠性 第 1 节)。
const (
CookieSession = "scoreboard_session"
CookieCSRF = "csrf_token"
// DefaultSessionTTL 是会话有效期:12h。
DefaultSessionTTL = 12 * time.Hour
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
DefaultBcryptCost = 12
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。
DefaultLoginPerMinute = 5
// DefaultWritePerMinute 写操作限流:30 次/分钟/用户。
DefaultWritePerMinute = 30
)
// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较,
// 避免用响应时间探测用户名是否存在。
const dummyBcryptHash = "$2a$12$m3MLFvKxRAgD/5cksQn9wuFpm/NmV0nyAWAJkZMCyT7s7oAoHWX8q"
// 认证相关哨兵错误,调用方据此映射 REST 错误码。
var (
ErrInvalidCredentials = errors.New("auth: 用户名或密码错误")
ErrUserInactive = errors.New("auth: 用户已停用")
ErrSessionNotFound = errors.New("auth: 会话不存在或已过期")
ErrRateLimited = errors.New("auth: 触发限流")
)
// User 是登录用户;guest 不落库,用 RoleGuest 表示。
type User struct {
ID int64
Username string
DisplayName string
Role Role
Active bool
MustChangePassword bool
LastLoginAt string
}
// Session 是一次登录产生的服务端会话。
type Session struct {
ID string
UserID int64
ExpiresAt time.Time
IP string
UserAgent string
}
// Options 是认证服务的可调参数;零值即文档默认。
type Options struct {
Logger *slog.Logger
SessionTTL time.Duration
BcryptCost int
LoginPerMinute int
WritePerMinute int
// Secure 为 true 时 Cookie 带 Secure 标记(HTTPS 部署)。
Secure bool
// Now 便于测试注入时间;默认 time.Now。
Now func() time.Time
}
// Service 封装用户与会话的读写、密码校验与限流。
type Service struct {
db *sql.DB
log *slog.Logger
sessionTTL time.Duration
bcryptCost int
secureCookies bool
now func() time.Time
loginLimiter *Limiter
writeLimiter *Limiter
}
// New 构建认证服务;db 为系统库(data/system.db)。
func New(db *sql.DB, opts Options) *Service {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
ttl := opts.SessionTTL
if ttl <= 0 {
ttl = DefaultSessionTTL
}
cost := opts.BcryptCost
if cost <= 0 {
cost = DefaultBcryptCost
}
now := opts.Now
if now == nil {
now = time.Now
}
loginLimit := opts.LoginPerMinute
if loginLimit == 0 {
loginLimit = DefaultLoginPerMinute
}
writeLimit := opts.WritePerMinute
if writeLimit == 0 {
writeLimit = DefaultWritePerMinute
}
return &Service{
db: db,
log: logger,
sessionTTL: ttl,
bcryptCost: cost,
secureCookies: opts.Secure,
now: now,
loginLimiter: NewLimiter(loginLimit, time.Minute, now),
writeLimiter: NewLimiter(writeLimit, time.Minute, now),
}
}
// SessionTTL 返回会话有效期。
func (s *Service) SessionTTL() time.Duration { return s.sessionTTL }
// AllowLogin 按 IP 限流登录。
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
// AllowWrite 按用户限流写操作。
func (s *Service) AllowWrite(userID int64) bool {
return s.writeLimiter.Allow(fmt.Sprintf("u:%d", userID))
}
// EnsureDefaultAdmin 在 users 表为空时创建默认管理员,返回随机初始密码。
// 密码只在此处打印一次,数据库里只存 bcrypt 哈希。
func (s *Service) EnsureDefaultAdmin(ctx context.Context) (username, password string, created bool, err error) {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return "", "", false, fmt.Errorf("统计用户: %w", err)
}
if count > 0 {
return "", "", false, nil
}
password, err = randomPassword(16)
if err != nil {
return "", "", false, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return "", "", false, fmt.Errorf("生成密码哈希: %w", err)
}
username = "admin"
_, err = s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, 'admin', 1, 1)`,
username, string(hash), "系统管理员")
if err != nil {
return "", "", false, fmt.Errorf("创建默认管理员: %w", err)
}
return username, password, true, nil
}
// Authenticate 校验用户名密码并创建会话,返回用户与不落库的明文 token。
func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) {
if !s.AllowLogin(ip) {
return nil, "", ErrRateLimited
}
var (
u User
hash string
role string
)
err := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, display_name, role, active, must_change_password,
COALESCE(last_login_at, '')
FROM users WHERE username = ?`, strings.TrimSpace(username)).
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
switch {
case errors.Is(err, sql.ErrNoRows):
// 用户名不存在也走一次 bcrypt,避免用响应时间探测用户是否存在。
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
return nil, "", ErrInvalidCredentials
case err != nil:
return nil, "", fmt.Errorf("查询用户: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, "", ErrInvalidCredentials
}
if !u.Active {
return nil, "", ErrUserInactive
}
u.Role = ParseRole(role)
token, _, err := s.createSession(ctx, u.ID, ip, userAgent)
if err != nil {
return nil, "", err
}
if _, err := s.db.ExecContext(ctx,
`UPDATE users SET last_login_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, u.ID); err != nil {
return nil, "", fmt.Errorf("更新登录时间: %w", err)
}
u.LastLoginAt = s.now().UTC().Format(time.DateTime)
s.loginLimiter.Reset(ip)
return &u, token, nil
}
// Lookup 按明文 token 查会话与用户;过期或停用都返回 ErrSessionNotFound。
func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, error) {
if strings.TrimSpace(token) == "" {
return nil, nil, ErrSessionNotFound
}
hash := hashToken(token)
var (
u User
sess Session
expiresAt string
role string
)
err := s.db.QueryRowContext(ctx,
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
u.id, u.username, u.display_name, u.role, u.active, u.must_change_password,
COALESCE(u.last_login_at, '')
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`,
hash, s.now().UTC().Format(time.DateTime)).
Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent,
&u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil, ErrSessionNotFound
case err != nil:
return nil, nil, fmt.Errorf("查询会话: %w", err)
}
exp, err := parseDBTime(expiresAt)
if err != nil {
return nil, nil, err
}
sess.ExpiresAt = exp
u.Role = ParseRole(role)
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
s.now().UTC().Format(time.DateTime), sess.ID)
return &u, &sess, nil
}
// Logout 删除会话(服务端强制下线)。
func (s *Service) Logout(ctx context.Context, token string) error {
if strings.TrimSpace(token) == "" {
return nil
}
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
if err != nil {
return fmt.Errorf("删除会话: %w", err)
}
return nil
}
// RevokeUserSessions 强制某用户全部下线。
func (s *Service) RevokeUserSessions(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent string) (string, *Session, error) {
token, err := randomToken(32)
if err != nil {
return "", nil, err
}
id, err := randomToken(16)
if err != nil {
return "", nil, err
}
expiresAt := s.now().UTC().Add(s.sessionTTL)
_, err = s.db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, userID, hashToken(token), expiresAt.Format(time.DateTime), ip, userAgent, s.now().UTC().Format(time.DateTime))
if err != nil {
return "", nil, fmt.Errorf("创建会话: %w", err)
}
return token, &Session{ID: id, UserID: userID, ExpiresAt: expiresAt, IP: ip, UserAgent: userAgent}, nil
}
// ---------- Cookie 与 CSRF ----------
// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: CookieSession,
Value: token,
Path: "/",
MaxAge: int(s.sessionTTL.Seconds()),
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie 删除会话 Cookie。
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieSession,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
}
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: CookieCSRF,
Value: token,
Path: "/",
MaxAge: int(s.sessionTTL.Seconds()),
HttpOnly: false,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
}
// ClearCSRFCookie 删除 CSRF Cookie。
func (s *Service) ClearCSRFCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieCSRF,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
}
// NewCSRFToken 生成新的 CSRF Token。
func NewCSRFToken() (string, error) { return randomToken(32) }
// ValidCSRF 比较请求头与 Cookie 里的 CSRF Token(常数时间比较)。
func ValidCSRF(header, cookie string) bool {
if header == "" || cookie == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(header), []byte(cookie)) == 1
}
// ---------- 辅助 ----------
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机数: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// randomPassword 生成去掉易混淆字符的随机密码。
func randomPassword(n int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机密码: %w", err)
}
out := make([]byte, n)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
}
return string(out), nil
}
// parseDBTime 解析迁移里 datetime('now') 写入的 UTC 时间。
func parseDBTime(v string) (time.Time, error) {
for _, layout := range []string{time.DateTime, "2006-01-02T15:04:05Z07:00", time.RFC3339} {
if t, err := time.Parse(layout, v); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("auth: 无法解析时间 %q", v)
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"sync"
"time"
)
// Limiter 是按键(IP / 用户)计数的滑动窗口限流器。
// 进程内内存实现:现场单实例部署足够,不引入额外存储。
type Limiter struct {
mu sync.Mutex
hits map[string][]time.Time
limit int
window time.Duration
now func() time.Time
}
// NewLimiter 创建限流器;limit <= 0 表示不限流。
func NewLimiter(limit int, window time.Duration, now func() time.Time) *Limiter {
if now == nil {
now = time.Now
}
return &Limiter{hits: make(map[string][]time.Time), limit: limit, window: window, now: now}
}
// Allow 记录一次访问并报告是否放行。
func (l *Limiter) Allow(key string) bool {
if l == nil || l.limit <= 0 {
return true
}
now := l.now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
kept := l.hits[key][:0]
for _, t := range l.hits[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.limit {
l.hits[key] = kept
return false
}
l.hits[key] = append(kept, now)
return true
}
// Reset 清空某个键的计数,例如登录成功后不再累计失败次数。
func (l *Limiter) Reset(key string) {
if l == nil {
return
}
l.mu.Lock()
delete(l.hits, key)
l.mu.Unlock()
}
+49
View File
@@ -0,0 +1,49 @@
// Package auth 提供用户、会话与权限模型。
//
// 角色是包含关系:admin ⊇ recorder ⊇ guest;guest 是「未登录」的默认身份,
// 不落库(见 devdocs/08-安全与可靠性 第 2 节)。
package auth
import "strings"
// Role 是权限角色。
type Role string
const (
// RoleGuest 未登录访客:只读赛事数据,禁读系统配置。
RoleGuest Role = "guest"
// RoleRecorder 记录员:guest 全部 + 录入、发布、撤销、大屏控制。
RoleRecorder Role = "recorder"
// RoleAdmin 管理员:recorder 全部 + 系统配置、审计、备份。
RoleAdmin Role = "admin"
)
func roleRank(r Role) int {
switch r {
case RoleAdmin:
return 2
case RoleRecorder:
return 1
default:
return 0
}
}
// Allows 报告 r 是否覆盖 need,即 admin ⊇ recorder ⊇ guest。
func (r Role) Allows(need Role) bool {
return roleRank(r) >= roleRank(need)
}
// ParseRole 把数据库里的角色字符串收敛成已知角色;未知值按最低权限处理。
func ParseRole(s string) Role {
switch Role(strings.ToLower(strings.TrimSpace(s))) {
case RoleAdmin:
return RoleAdmin
case RoleRecorder:
return RoleRecorder
default:
return RoleGuest
}
}
func (r Role) String() string { return string(r) }
+198
View File
@@ -0,0 +1,198 @@
package export
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// DefaultBackupKeep 是滚动保留的备份份数(见 08-安全与可靠性 第 6 节)。
const DefaultBackupKeep = 20
// BackupOptions 描述一次备份。
type BackupOptions struct {
// Dir 是备份目录,通常为 <dataDir>/backups。
Dir string
// Prefix 是文件名前缀,用于区分不同库(如 event / system)。
Prefix string
// Reason 记录触发原因:manual / publish / auto。
Reason string
// Keep 是滚动保留份数,<=0 时用 DefaultBackupKeep。
Keep int
// SkipPrune 为 true 时只备份不清理,供「手动留档」场景使用。
SkipPrune bool
// Now 便于测试注入固定时间;零值取当前时间。
Now time.Time
}
// BackupInfo 是一份备份的描述。
type BackupInfo struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"`
}
// Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。
// VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。
func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
if db == nil {
return BackupInfo{}, ErrNoDatabase
}
dir := strings.TrimSpace(opts.Dir)
if dir == "" {
return BackupInfo{}, errors.New("export: 备份目录为空")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
}
prefix := strings.TrimSpace(opts.Prefix)
if prefix == "" {
prefix = "db"
}
reason := strings.TrimSpace(opts.Reason)
if reason == "" {
reason = "manual"
}
now := nowOr(opts.Now)
path := uniqueBackupPath(dir, prefix, reason, now)
if err := vacuumInto(db, path); err != nil {
return BackupInfo{}, err
}
sum, size, err := hashFile(path)
if err != nil {
return BackupInfo{}, err
}
// 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。
_ = os.Chtimes(path, now, now)
info := BackupInfo{
Path: path,
Name: filepath.Base(path),
Size: size,
SHA256: sum,
Reason: reason,
CreatedAt: now,
}
if !opts.SkipPrune {
if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil {
return info, err
}
}
return info, nil
}
// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish,
// 便于在备份列表里一眼看出哪些是发布节点留的。
func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
if strings.TrimSpace(opts.Reason) == "" {
opts.Reason = "publish"
}
return Backup(db, opts)
}
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err)
}
prefix = strings.TrimSpace(prefix)
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
continue
}
if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") {
continue
}
info, err := e.Info()
if err != nil {
return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err)
}
out = append(out, BackupInfo{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
Size: info.Size(),
Reason: reasonFromName(e.Name(), prefix),
CreatedAt: info.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.After(out[j].CreatedAt)
}
return out[i].Name > out[j].Name
})
return out, nil
}
// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。
func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
list, err := ListBackups(dir, prefix)
if err != nil || len(list) == 0 {
return BackupInfo{}, false, err
}
return list[0], true, nil
}
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
if keep <= 0 {
keep = DefaultBackupKeep
}
list, err := ListBackups(dir, prefix)
if err != nil {
return nil, err
}
if len(list) <= keep {
return nil, nil
}
removed := make([]string, 0, len(list)-keep)
for _, b := range list[keep:] {
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err)
}
removed = append(removed, b.Path)
}
return removed, nil
}
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405"))
p := filepath.Join(dir, base+".db")
for i := 2; ; i++ {
if _, err := os.Stat(p); os.IsNotExist(err) {
return p
}
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
}
}
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>.db 里取回原因。
func reasonFromName(name, prefix string) string {
base := strings.TrimSuffix(name, filepath.Ext(name))
if prefix != "" {
base = strings.TrimPrefix(base, prefix+"-")
}
parts := strings.Split(base, "-")
if len(parts) >= 3 {
return strings.Join(parts[:len(parts)-2], "-")
}
return ""
}
+123
View File
@@ -0,0 +1,123 @@
package export
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestBackupAndRetention(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 9, 9, 10, 0, 0, 0, time.Local)
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "publish", Keep: 3, Now: base})
if err != nil {
t.Fatalf("备份: %v", err)
}
if info.Size == 0 || info.SHA256 == "" {
t.Errorf("备份信息不完整: %+v", info)
}
if info.Reason != "publish" {
t.Errorf("备份原因 = %q,期望 publish", info.Reason)
}
if _, err := os.Stat(info.Path); err != nil {
t.Fatalf("备份文件不存在: %v", err)
}
// 快照必须可打开,且数据与源库一致。
bdb := openSQLite(t, info.Path)
if got := countRows(t, bdb, "teams"); got != 2 {
t.Errorf("备份内 teams = %d,期望 2", got)
}
if got := countRows(t, bdb, "score_ledger"); got != 2 {
t.Errorf("备份内 score_ledger = %d,期望 2", got)
}
_ = bdb.Close()
// 再生成 5 份,Keep=3 应只留最近 3 份。
for i := 1; i <= 5; i++ {
if _, err := Backup(db, BackupOptions{
Dir: dir, Prefix: "event", Reason: "auto", Keep: 3,
Now: base.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("第 %d 次备份: %v", i, err)
}
}
list, err := ListBackups(dir, "event")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
if len(list) != 3 {
t.Fatalf("保留份数 = %d,期望 3", len(list))
}
if list[0].Reason != "auto" {
t.Errorf("最新备份原因 = %q,期望 auto", list[0].Reason)
}
latest, ok, err := LatestBackup(dir, "event")
if err != nil || !ok {
t.Fatalf("最近备份: ok=%v err=%v", ok, err)
}
if !latest.CreatedAt.Equal(base.Add(5 * time.Minute)) {
t.Errorf("最近备份时间 = %s,期望 %s", latest.CreatedAt, base.Add(5*time.Minute))
}
if _, err := PruneBackups(dir, "event", 1); err != nil {
t.Fatalf("手动清理: %v", err)
}
list, err = ListBackups(dir, "event")
if err != nil {
t.Fatalf("清理后列出备份: %v", err)
}
if len(list) != 1 {
t.Errorf("清理后份数 = %d,期望 1", len(list))
}
}
func TestBackupBeforePublishAndUniqueNames(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 9, 9, 11, 0, 0, 0, time.Local)
a, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true})
if err != nil {
t.Fatalf("备份 A: %v", err)
}
b, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true})
if err != nil {
t.Fatalf("备份 B: %v", err)
}
if a.Path == b.Path {
t.Errorf("同一秒的两份备份重名: %s", a.Path)
}
info, err := BackupBeforePublish(db, BackupOptions{Dir: dir, Prefix: "event", Keep: 20, Now: base.Add(time.Hour)})
if err != nil {
t.Fatalf("发布前备份: %v", err)
}
if info.Reason != "publish" {
t.Errorf("发布前备份原因 = %q,期望 publish", info.Reason)
}
if filepath.Dir(info.Path) != dir {
t.Errorf("备份目录 = %q,期望 %q", filepath.Dir(info.Path), dir)
}
}
func TestListBackupsMissingDir(t *testing.T) {
list, err := ListBackups(filepath.Join(t.TempDir(), "不存在"), "event")
if err != nil {
t.Fatalf("目录不存在不应报错: %v", err)
}
if len(list) != 0 {
t.Errorf("备份数 = %d,期望 0", len(list))
}
if _, ok, err := LatestBackup(filepath.Join(t.TempDir(), "不存在"), "event"); err != nil || ok {
t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err)
}
}
+29
View File
@@ -0,0 +1,29 @@
// Package export 负责成绩导出(xlsx)、赛事项目包(zip)与数据库备份。
//
// 本包只做三件事:把数据整理成可直接打印的表格、把整场赛事打包/解包、把库
// 备份成一致性快照。所有业务数值(积分、名次、奖牌)都来自调用方传入的
// domain 结构体或赛事库本身,这里不重新实现任何计分规则——规则只有一份,
// 在 internal/domain。
//
// 依赖方向:export → domain(只读类型)与 database/sql;domain 不认识 export。
package export
import "errors"
// 导出的哨兵错误,调用方据此把失败原因映射成 HTTP 状态码或界面提示。
var (
// ErrNoDatabase 表示调用方传入了空的数据库连接。
ErrNoDatabase = errors.New("export: 数据库连接为空")
// ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。
ErrInvalidPackage = errors.New("export: 赛事项目包非法")
// ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。
ErrUnsupportedVersion = errors.New("export: 赛事项目包格式版本不受支持")
// ErrChecksumMismatch 表示包内文件与 meta.yaml 记录的校验和不一致。
ErrChecksumMismatch = errors.New("export: 赛事项目包校验和不匹配")
// ErrProjectExists 表示目标目录已有同名赛事项目,且调用方未选择覆盖。
ErrProjectExists = errors.New("export: 目标赛事项目已存在")
// ErrUnsafeName 表示赛事项目名含有路径分隔符、保留名等不安全的字符。
ErrUnsafeName = errors.New("export: 赛事项目名非法")
// ErrTeamNotFound 表示导出单位积分明细时找不到该单位。
ErrTeamNotFound = errors.New("export: 单位不存在")
)
+152
View File
@@ -0,0 +1,152 @@
package export
import (
"database/sql"
"io"
"strings"
"github.com/xuri/excelize/v2"
)
// EventResultRow 是分项成绩表的一行。
// Rank 用字符串是为了承载「1(并列)」这种展示值。
type EventResultRow struct {
Rank string
Bib string
TeamName string
Participant string
ResultText string
Unit string
Points float64
Status string
Remark string
}
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
type EventSheet struct {
SportName string
EventName string
Category string
Rows []EventResultRow
}
// EventsWorkbook 是「分项成绩表」的内容:一个小项可含多个组别 sheet,
// 也可承载全部小项(ExportAllXLSX 用)。
type EventsWorkbook struct {
Meta Meta
Sheets []EventSheet
}
var eventColumns = []column{
{"名次", 8, kindCenter},
{"号码", 9, kindCenter},
{"单位", 22, kindText},
{"运动员/队", 18, kindText},
{"成绩", 12, kindCenter},
{"积分", 8, kindNumber},
{"状态", 8, kindCenter},
{"备注", 20, kindText},
}
// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。
func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error {
wb, err := LoadEventWorkbook(db, eventID, opts)
if err != nil {
return err
}
f, err := eventFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteEventXLSX 把分项成绩表写成 xlsx 流。
func WriteEventXLSX(w io.Writer, wb EventsWorkbook) error {
f, err := eventFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
func eventFile(wb EventsWorkbook) (*excelize.File, error) {
return buildWorkbook("成绩表", func(f *excelize.File, st *styleSet) error {
return writeEventSheets(f, st, wb.Meta, wb.Sheets, &sheetNamer{})
})
}
func writeEventSheets(f *excelize.File, st *styleSet, meta Meta, sheets []EventSheet, namer *sheetNamer) error {
meta = meta.normalized()
for _, sh := range sheets {
name := namer.add(eventSheetBase(sh))
if err := addSheet(f, name); err != nil {
return err
}
if err := writeSheet(f, st, name, meta, eventSheetTitle(meta, sh), eventColumns, eventRows(sh.Rows)); err != nil {
return err
}
}
return nil
}
// eventSheetBase 是工作表名:大项·小项·组别,够短且能区分。
func eventSheetBase(sh EventSheet) string {
parts := make([]string, 0, 3)
if sh.SportName != "" {
parts = append(parts, sh.SportName)
}
parts = append(parts, sh.EventName)
if sh.Category != "" {
parts = append(parts, sh.Category)
}
return strings.Join(parts, "·")
}
// eventSheetTitle 是表内大标题,带赛事名。
func eventSheetTitle(meta Meta, sh EventSheet) string {
base := eventSheetBase(sh)
if base == "" {
base = "成绩表"
}
return sheetTitle(meta, base)
}
func eventRows(rows []EventResultRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{
r.Rank, r.Bib, r.TeamName, r.Participant,
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
})
}
return out
}
// displayResult 把成绩与单位拼成「11.58 秒」;单位已含在文本里时不重复拼接。
func displayResult(text, unit string) string {
text = strings.TrimSpace(text)
unit = strings.TrimSpace(unit)
if text == "" {
return ""
}
if unit == "" || strings.HasSuffix(text, unit) {
return text
}
return text + " " + unitLabel(unit)
}
func unitLabel(unit string) string {
switch unit {
case "s":
return "秒"
case "m":
return "米"
case "pts":
return "分"
case "次":
return "次"
default:
return unit
}
}
+87
View File
@@ -0,0 +1,87 @@
package export
import (
"database/sql"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 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() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return db
}
// seedEvent 造一场最小可用赛事:2 个单位、2 名运动员、1 个小项 1 个组别、
// 2 条已发布成绩与对应积分流水。
func seedEvent(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 2)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 2, '102')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 2, 2, '11.90', 11.90, 's', 'published')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (2, 2, 1, 2, 2, 'result', 2, 8)`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
`UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, 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
}
// openSQLite 以只读方式打开一个已存在的库,用于校验备份 / 导入产物可读。
func openSQLite(t *testing.T, path string) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
t.Fatalf("打开 %s: %v", path, err)
}
return db
}
+641
View File
@@ -0,0 +1,641 @@
package export
import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。
type Meta struct {
EventName string
Subtitle string
Organizer string
GeneratedAt time.Time
GeneratedBy string
}
func (m Meta) normalized() Meta {
if m.GeneratedAt.IsZero() {
m.GeneratedAt = time.Now()
}
return m
}
// Options 是导出选项;零值即「正式成绩」口径。
type Options struct {
Meta Meta
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
IncludeDrafts bool
}
// resolveMeta 读一次 settings,把留空的抬头字段补齐;顺带把设置表返回给调用方复用。
func resolveMeta(db *sql.DB, meta Meta) (Meta, map[string]string, error) {
settings, err := loadSettings(db)
if err != nil {
return Meta{}, nil, err
}
if meta.EventName == "" {
meta.EventName = settings["event.name"]
}
if meta.Subtitle == "" {
meta.Subtitle = settings["event.subtitle"]
}
if meta.Organizer == "" {
meta.Organizer = settings["event.organizer"]
}
return meta.normalized(), settings, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("export: 读取赛事设置: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("export: 扫描赛事设置: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历赛事设置: %w", err)
}
return out, nil
}
// LoadStandingsWorkbook 读取单位、运动员、积分流水与名次,交给 domain 算出三个榜。
// 排名规则只在 domain 里有一份,这里不做任何二次计算。
func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error) {
if db == nil {
return StandingsWorkbook{}, ErrNoDatabase
}
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return StandingsWorkbook{}, err
}
teams, err := loadTeams(db)
if err != nil {
return StandingsWorkbook{}, err
}
athletes, err := loadAthletes(db)
if err != nil {
return StandingsWorkbook{}, err
}
ledger, err := loadLedger(db)
if err != nil {
return StandingsWorkbook{}, err
}
ranked, err := loadRanked(db)
if err != nil {
return StandingsWorkbook{}, err
}
in := domain.StandingsInput{
Teams: teams,
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
}
return StandingsWorkbook{
Meta: meta,
Team: domain.BuildTeamStandings(in),
Medal: domain.BuildMedalStandings(in),
Athlete: domain.BuildAthleteStandings(in),
}, nil
}
func loadTeams(db *sql.DB) ([]domain.Team, error) {
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
rows, err := db.Query(`
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE active = 1
ORDER BY sort_order, code`)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Team
for rows.Next() {
var t domain.Team
var active int
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
return nil, fmt.Errorf("export: 扫描单位: %w", err)
}
t.Active = active != 0
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历单位: %w", err)
}
return out, nil
}
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
rows, err := db.Query(`
SELECT id, name, COALESCE(gender, ''), COALESCE(team_id, 0), COALESCE(employee_no, ''), active
FROM athletes ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取运动员: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Athlete
for rows.Next() {
var a domain.Athlete
var active int
if err := rows.Scan(&a.ID, &a.Name, &a.Gender, &a.TeamID, &a.EmployeeNo, &active); err != nil {
return nil, fmt.Errorf("export: 扫描运动员: %w", err)
}
a.Active = active != 0
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历运动员: %w", err)
}
return out, nil
}
func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
rows, err := db.Query(`
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0),
source_type, source_id, points, voided, COALESCE(void_reason, '')
FROM score_ledger ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取积分流水: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.LedgerEntry
for rows.Next() {
var e domain.LedgerEntry
var voided int
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID,
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil {
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
}
e.Voided = voided != 0
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分流水: %w", err)
}
return out, nil
}
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
// 重算分值,也就能保证与正式计分结果完全一致。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
scopes, err := loadChannelScopes(db)
if err != nil {
return nil, err
}
rows, err := db.Query(`
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN events e ON e.id = r.event_id
LEFT JOIN athletes a ON a.id = en.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
ORDER BY r.event_id, r.rank, r.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取名次: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.RankedResult
for rows.Next() {
var (
rr domain.RankedResult
resultValue sql.NullFloat64
)
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
return nil, fmt.Errorf("export: 扫描名次: %w", err)
}
if resultValue.Valid {
v := resultValue.Float64
rr.ResultValue = &v
}
if rr.TeamID == 0 {
continue
}
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
// 因此按 scope 展开成多条明细。
for _, sc := range scopes[rr.EventID] {
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
continue
}
item := rr
item.Scope = sc
item.Basis = domain.BasisResult
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, item)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
}
return out, nil
}
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
if err != nil {
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.Scope)
for rows.Next() {
var eventID int64
var scope domain.Scope
if err := rows.Scan(&eventID, &scope); err != nil {
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
}
if !containsScope(out[eventID], scope) {
out[eventID] = append(out[eventID], scope)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
}
return out, nil
}
func containsScope(list []domain.Scope, scope domain.Scope) bool {
for _, s := range list {
if s == scope {
return true
}
}
return false
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
sportName string
name string
}
// LoadEventWorkbook 读取一个小项(eventID=0 表示全部)的成绩,按组别拆成多个 sheet。
func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, error) {
if db == nil {
return EventsWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return EventsWorkbook{}, err
}
events, err := loadEvents(db, eventID)
if err != nil {
return EventsWorkbook{}, err
}
if eventID != 0 && len(events) == 0 {
return EventsWorkbook{}, fmt.Errorf("export: 小项 %d 不存在", eventID)
}
sheets := make([]EventSheet, 0, len(events))
for _, ev := range events {
cats, err := loadCategories(db, ev.id)
if err != nil {
return EventsWorkbook{}, err
}
if len(cats) == 0 {
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{SportName: ev.sportName, EventName: ev.name, Rows: rows})
continue
}
for _, cat := range cats {
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{
SportName: ev.sportName,
EventName: ev.name,
Category: cat.name,
Rows: rows,
})
}
}
return EventsWorkbook{Meta: meta, Sheets: sheets}, nil
}
func loadEvents(db *sql.DB, eventID int64) ([]eventInfo, error) {
query := `
SELECT e.id, COALESCE(s.name, ''), e.name
FROM events e
LEFT JOIN sports s ON s.id = e.sport_id`
args := []any{}
if eventID != 0 {
query += ` WHERE e.id = ?`
args = append(args, eventID)
}
query += ` ORDER BY s.sort_order, e.sort_order, e.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
var out []eventInfo
for rows.Next() {
var ev eventInfo
if err := rows.Scan(&ev.id, &ev.sportName, &ev.name); err != nil {
return nil, fmt.Errorf("export: 扫描小项: %w", err)
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历小项: %w", err)
}
return out, nil
}
type categoryInfo struct {
id int64
name string
}
func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
rows, err := db.Query(`
SELECT id, name FROM event_categories
WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, fmt.Errorf("export: 读取组别: %w", err)
}
defer func() { _ = rows.Close() }()
var out []categoryInfo
for rows.Next() {
var c categoryInfo
if err := rows.Scan(&c.id, &c.name); err != nil {
return nil, fmt.Errorf("export: 扫描组别: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历组别: %w", err)
}
return out, nil
}
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
query := `
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '')
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
LEFT JOIN results r ON r.entry_id = en.id
WHERE en.event_id = ?`
args := []any{eventID}
if categoryID != 0 {
query += ` AND en.category_id = ?`
args = append(args, categoryID)
}
if !includeDrafts {
query += ` AND (r.state IS NULL OR r.state = 'published')`
}
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取成绩: %w", err)
}
defer func() { _ = rows.Close() }()
var out []EventResultRow
for rows.Next() {
var (
row EventResultRow
rank, isTie int
memberJSON, athleteName string
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历成绩: %w", err)
}
return out, nil
}
// LoadTeamDetailWorkbook 读取某单位未被作废的积分流水,逐条列出得分来源。
func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailWorkbook, error) {
if db == nil {
return TeamDetailWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return TeamDetailWorkbook{}, err
}
team, err := loadTeam(db, teamID)
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID)
if err != nil {
return TeamDetailWorkbook{}, err
}
return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
}
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
var t domain.Team
var active int
err := db.QueryRow(`
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE id = ?`, teamID).
Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active)
if err == sql.ErrNoRows {
return domain.Team{}, fmt.Errorf("%w: %d", ErrTeamNotFound, teamID)
}
if err != nil {
return domain.Team{}, fmt.Errorf("export: 读取单位 %d: %w", teamID, err)
}
t.Active = active != 0
return t, nil
}
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
l.source_type, l.points, COALESCE(l.void_reason, '')
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
LEFT JOIN entries en ON en.id = l.entry_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN results r ON r.id = l.result_id
LEFT JOIN athletes a ON a.id = l.athlete_id
WHERE l.team_id = ? AND l.voided = 0
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID)
if err != nil {
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
}
defer func() { _ = rows.Close() }()
var out []TeamDetailRow
for rows.Next() {
var r TeamDetailRow
var source string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
r.Source = sourceLabel(source)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分明细: %w", err)
}
return out, nil
}
func rankLabel(rank int, isTie bool) string {
if rank <= 0 {
return ""
}
if isTie {
return strconv.Itoa(rank) + "(并列)"
}
return strconv.Itoa(rank)
}
// statusLabel 把成绩状态翻成中文;没有成绩行时退回报名状态。
func statusLabel(resultStatus, resultState sql.NullString, entryStatus string) string {
if !resultStatus.Valid {
return entryStatusLabel(entryStatus)
}
if resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft {
return "草稿"
}
switch domain.ResultStatus(resultStatus.String) {
case domain.ResultStatusOK, "":
return "有效"
case domain.ResultStatusDQ:
return "犯规"
case domain.ResultStatusDNS:
return "弃权"
case domain.ResultStatusDNF:
return "未完成"
case domain.ResultStatusWithdrawn:
return "退赛"
default:
return resultStatus.String
}
}
// joinRemark 合并报名备注与成绩备注,两条都有时用分号隔开。
func joinRemark(entryRemark, resultNote string) string {
entryRemark = strings.TrimSpace(entryRemark)
resultNote = strings.TrimSpace(resultNote)
switch {
case entryRemark == "":
return resultNote
case resultNote == "":
return entryRemark
default:
return entryRemark + ";" + resultNote
}
}
func entryStatusLabel(status string) string {
switch domain.EntryStatus(status) {
case domain.EntryStatusRegistered, "":
return "未录入"
case domain.EntryStatusScratched:
return "弃权"
case domain.EntryStatusDQ:
return "犯规"
case domain.EntryStatusDNS:
return "弃权"
default:
return status
}
}
func sourceLabel(source string) string {
switch domain.SourceType(source) {
case domain.SourceResult:
return "成绩"
case domain.SourceRecordBonus:
return "破纪录加分"
case domain.SourceAdjustment:
return "人工调整"
default:
return source
}
}
func parseFloatDefault(raw string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return def
}
return v
}
// participantName 取个人姓名;团体项目则把成员 JSON 数组拼成顿号列表。
func participantName(athleteName, memberJSON string) string {
if strings.TrimSpace(athleteName) != "" {
return athleteName
}
memberJSON = strings.TrimSpace(memberJSON)
if memberJSON == "" {
return ""
}
var names []string
if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 {
return strings.Join(names, "、")
}
return memberJSON
}
+628
View File
@@ -0,0 +1,628 @@
package export
import (
"archive/zip"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
_ "modernc.org/sqlite"
)
const (
// projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。
projectFormat = "scoreboard-event-project"
// projectFormatVersion 是当前程序能读写的包格式版本。
projectFormatVersion = 1
// eventDBName 是包内赛事库的固定文件名。
eventDBName = "event.db"
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
maxExtractBytes = 4 << 30
)
// ProjectFile 描述包内一个文件的校验信息。
type ProjectFile struct {
Path string `yaml:"path"`
Size int64 `yaml:"size"`
SHA256 string `yaml:"sha256"`
}
// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。
type ProjectMeta struct {
Format string `yaml:"format"`
FormatVersion int `yaml:"formatVersion"`
Name string `yaml:"name"`
EventName string `yaml:"eventName,omitempty"`
Subtitle string `yaml:"subtitle,omitempty"`
Organizer string `yaml:"organizer,omitempty"`
CreatedAt time.Time `yaml:"createdAt"`
CreatedBy string `yaml:"createdBy,omitempty"`
AppVersion string `yaml:"appVersion,omitempty"`
Database ProjectFile `yaml:"database"`
Files []ProjectFile `yaml:"files"`
}
// ProjectExportOptions 描述一次赛事包导出。
type ProjectExportOptions struct {
Name string // 项目名,导入后落成 data/event_projs/<Name>/
EventName string // 留空则读赛事库 settings
Subtitle string
Organizer string
CreatedBy string
AppVersion string
UploadsDir string // 可选:背景图 / 会标目录
FontsDir string // 可选:字体目录
IncludeFonts bool // 是否把字体一并打包
RulesPath string // 可选:规则包 YAML
Now time.Time
}
// ImportOptions 描述一次赛事包导入。
type ImportOptions struct {
// Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。
Overwrite bool
// Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。
Rename string
}
// ImportResult 是导入结果。
type ImportResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
EventDB string `json:"eventDB"`
Meta ProjectMeta `json:"meta"`
}
// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。
// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。
func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) {
if db == nil {
return ProjectMeta{}, ErrNoDatabase
}
name, err := SafeProjectName(opts.Name)
if err != nil {
return ProjectMeta{}, err
}
meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer})
if err != nil {
return ProjectMeta{}, err
}
tmp, err := os.MkdirTemp("", "scoreboard-project-*")
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil {
return ProjectMeta{}, err
}
// uploads 即使为空也要保留目录,导入方才知道该往哪放资源。
uploads := filepath.Join(tmp, "uploads")
if err := os.MkdirAll(uploads, 0o755); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err)
}
if opts.UploadsDir != "" {
if err := copyTree(opts.UploadsDir, uploads); err != nil {
return ProjectMeta{}, err
}
}
if opts.IncludeFonts && opts.FontsDir != "" {
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil {
return ProjectMeta{}, err
}
}
if opts.RulesPath != "" {
if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil {
return ProjectMeta{}, err
}
}
files, err := buildManifest(tmp)
if err != nil {
return ProjectMeta{}, err
}
dbFile, ok := findFile(files, eventDBName)
if !ok {
return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName)
}
pkg := ProjectMeta{
Format: projectFormat,
FormatVersion: projectFormatVersion,
Name: name,
EventName: meta.EventName,
Subtitle: meta.Subtitle,
Organizer: meta.Organizer,
CreatedAt: nowOr(opts.Now),
CreatedBy: opts.CreatedBy,
AppVersion: opts.AppVersion,
Database: dbFile,
Files: files,
}
raw, err := yaml.Marshal(&pkg)
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err)
}
if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err)
}
if err := zipDir(tmp, dst); err != nil {
return ProjectMeta{}, err
}
return pkg, nil
}
// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。
func InspectProjectZip(src string) (ProjectMeta, error) {
r, err := zip.OpenReader(src)
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {
if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" {
continue
}
rc, err := f.Open()
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
defer func() { _ = rc.Close() }()
raw, err := io.ReadAll(io.LimitReader(rc, 1<<20))
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
var meta ProjectMeta
if err := yaml.Unmarshal(raw, &meta); err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err)
}
if err := validateMeta(meta); err != nil {
return ProjectMeta{}, err
}
return meta, nil
}
return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage)
}
func validateMeta(meta ProjectMeta) error {
if meta.Format != projectFormat {
return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format)
}
if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion {
return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion)
}
if _, err := SafeProjectName(meta.Name); err != nil {
return err
}
if path.Clean(meta.Database.Path) != eventDBName {
return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path)
}
if meta.Database.SHA256 == "" {
return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage)
}
return nil
}
// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。
func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) {
meta, err := InspectProjectZip(src)
if err != nil {
return ImportResult{}, err
}
name := meta.Name
if strings.TrimSpace(opts.Rename) != "" {
name = opts.Rename
}
name, err = SafeProjectName(name)
if err != nil {
return ImportResult{}, err
}
if err := os.MkdirAll(projRoot, 0o755); err != nil {
return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err)
}
tmp, err := os.MkdirTemp(projRoot, ".import-*")
if err != nil {
return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := extractZip(src, tmp); err != nil {
return ImportResult{}, err
}
if err := verifyManifest(tmp, meta); err != nil {
return ImportResult{}, err
}
eventDB := filepath.Join(tmp, eventDBName)
if err := verifySQLite(eventDB); err != nil {
return ImportResult{}, err
}
target := filepath.Join(projRoot, name)
if _, err := os.Stat(target); err == nil {
if !opts.Overwrite {
return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale := target + ".replaced-" + time.Now().Format("20060102-150405")
if err := os.Rename(target, stale); err != nil {
return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
defer func() { _ = os.RemoveAll(stale) }()
}
if err := os.Rename(tmp, target); err != nil {
return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
}
return ImportResult{
Name: name,
Dir: target,
EventDB: filepath.Join(target, eventDBName),
Meta: meta,
}, nil
}
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
func SafeProjectName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName)
}
if name == "." || name == ".." {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
if len([]rune(name)) > 64 {
return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName)
}
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
for _, r := range name {
if r < 0x20 {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
}
if filepath.IsAbs(name) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
switch base {
case "CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name)
}
return name, nil
}
func vacuumInto(db *sql.DB, dst string) error {
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
}
return nil
}
func buildManifest(root string) ([]ProjectFile, error) {
var out []ProjectFile
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == "meta.yaml" {
return nil
}
sum, size, err := hashFile(p)
if err != nil {
return err
}
out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum})
return nil
})
if err != nil {
return nil, fmt.Errorf("export: 生成文件清单: %w", err)
}
return out, nil
}
func findFile(files []ProjectFile, name string) (ProjectFile, bool) {
for _, f := range files {
if f.Path == name {
return f, true
}
}
return ProjectFile{}, false
}
func hashFile(p string) (string, int64, error) {
f, err := os.Open(p)
if err != nil {
return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err)
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("export: 读取目录 %s: %w", src, err)
}
if !info.IsDir() {
return fmt.Errorf("export: %s 不是目录", src)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", dst, err)
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", src, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("export: 关闭 %s: %w", dst, err)
}
return nil
}
// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。
func zipDir(root, dst string) error {
if dir := filepath.Dir(dst); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
}
}
tmp := dst + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", tmp, err)
}
zw := zip.NewWriter(out)
if err := writeZipTree(zw, root); err != nil {
_ = zw.Close()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := zw.Close(); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 zip: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 %s: %w", tmp, err)
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 重命名 %s: %w", dst, err)
}
return nil
}
func writeZipTree(zw *zip.Writer, root string) error {
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
name := filepath.ToSlash(rel)
if d.IsDir() {
// 保留空目录(如空的 uploads/),导入方才能得到完整结构。
_, err := zw.Create(name + "/")
return err
}
info, err := d.Info()
if err != nil {
return err
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Name = name
hdr.Method = zip.Deflate
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(p)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", p, err)
}
_, copyErr := io.Copy(w, src)
closeErr := src.Close()
if copyErr != nil {
return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr)
}
return closeErr
})
}
func extractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
var total uint64
for _, f := range r.File {
target, err := secureJoin(dst, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", target, err)
}
continue
}
total += f.UncompressedSize64
if total > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = rc.Close()
return fmt.Errorf("export: 写入 %s: %w", target, err)
}
_, copyErr := io.Copy(out, rc)
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
if closeOutErr != nil {
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
}
if closeInErr != nil {
return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr)
}
}
return nil
}
// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。
func secureJoin(root, name string) (string, error) {
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
if clean == "." {
return root, nil
}
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
target := filepath.Join(root, filepath.FromSlash(clean))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
return target, nil
}
func verifyManifest(root string, meta ProjectMeta) error {
if len(meta.Files) == 0 {
return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage)
}
for _, f := range meta.Files {
target, err := secureJoin(root, f.Path)
if err != nil {
return err
}
sum, size, err := hashFile(target)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPackage, err)
}
if f.Size != 0 && f.Size != size {
return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size)
}
if !strings.EqualFold(sum, f.SHA256) {
return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path)
}
}
dbFile, ok := findFile(meta.Files, eventDBName)
if !ok {
return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName)
}
if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) {
return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch)
}
return nil
}
// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。
func verifySQLite(p string) error {
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
if err != nil {
return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err)
}
defer func() { _ = db.Close() }()
var integrity string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil {
return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err)
}
if !strings.EqualFold(integrity, "ok") {
return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity)
}
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil {
return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err)
}
if n == 0 {
return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName)
}
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package export
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestExportImportProjectZip(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
uploads := t.TempDir()
if err := os.WriteFile(filepath.Join(uploads, "logo.txt"), []byte("logo"), 0o644); err != nil {
t.Fatalf("写 uploads: %v", err)
}
rules := filepath.Join(t.TempDir(), "rules.yaml")
if err := os.WriteFile(rules, []byte("format: scoreboard-rules\nformatVersion: 1\n"), 0o644); err != nil {
t.Fatalf("写 rules.yaml: %v", err)
}
zipPath := filepath.Join(t.TempDir(), "赛事包.zip")
meta, err := ExportProjectZip(db, zipPath, ProjectExportOptions{
Name: "测试赛事",
UploadsDir: uploads,
RulesPath: rules,
CreatedBy: "tester",
Now: time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local),
})
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
if meta.Format != projectFormat || meta.FormatVersion != projectFormatVersion {
t.Fatalf("meta 格式 = %s/%d", meta.Format, meta.FormatVersion)
}
if meta.EventName != "测试运动会" {
t.Errorf("meta.eventName = %q,期望 测试运动会", meta.EventName)
}
for _, want := range []string{"event.db", "uploads/logo.txt", "rules.yaml", "meta.yaml"} {
if !zipHas(zipPath, t, want) {
t.Errorf("包内缺少 %s", want)
}
}
got, err := InspectProjectZip(zipPath)
if err != nil {
t.Fatalf("预览赛事包: %v", err)
}
if got.Name != "测试赛事" || got.Database.Path != "event.db" || got.Database.SHA256 == "" {
t.Fatalf("预览结果异常: %+v", got)
}
root := t.TempDir()
res, err := ImportProjectZip(zipPath, root, ImportOptions{})
if err != nil {
t.Fatalf("导入赛事包: %v", err)
}
if res.Dir != filepath.Join(root, "测试赛事") {
t.Errorf("导入目录 = %q", res.Dir)
}
if _, err := os.Stat(res.EventDB); err != nil {
t.Fatalf("导入后 event.db 不存在: %v", err)
}
if _, err := os.Stat(filepath.Join(res.Dir, "uploads", "logo.txt")); err != nil {
t.Errorf("导入后 uploads 资源缺失: %v", err)
}
assertTeams(t, res.EventDB, 2)
if _, err := ImportProjectZip(zipPath, root, ImportOptions{}); !errors.Is(err, ErrProjectExists) {
t.Fatalf("同名导入应报 ErrProjectExists,实际 %v", err)
}
if _, err := ImportProjectZip(zipPath, root, ImportOptions{Overwrite: true}); err != nil {
t.Fatalf("覆盖导入: %v", err)
}
assertTeams(t, filepath.Join(root, "测试赛事", "event.db"), 2)
renamed, err := ImportProjectZip(zipPath, root, ImportOptions{Rename: "另一场赛事"})
if err != nil {
t.Fatalf("改名导入: %v", err)
}
if renamed.Name != "另一场赛事" {
t.Errorf("改名导入结果 = %q", renamed.Name)
}
}
func TestImportRejectsBadPackages(t *testing.T) {
base := ProjectMeta{
Format: projectFormat,
FormatVersion: projectFormatVersion,
Name: "坏包",
Database: ProjectFile{Path: "event.db", SHA256: strings.Repeat("0", 64)},
Files: []ProjectFile{{Path: "event.db", SHA256: strings.Repeat("0", 64)}},
}
t.Run("校验和不匹配", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.zip")
writeZip(t, p, map[string]string{
"meta.yaml": marshalMeta(t, base),
"event.db": "not a database",
})
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
if !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("期望 ErrChecksumMismatch,实际 %v", err)
}
})
t.Run("版本过新", func(t *testing.T) {
meta := base
meta.FormatVersion = 99
dir := t.TempDir()
p := filepath.Join(dir, "bad.zip")
writeZip(t, p, map[string]string{"meta.yaml": marshalMeta(t, meta), "event.db": "x"})
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
if !errors.Is(err, ErrUnsupportedVersion) {
t.Fatalf("期望 ErrUnsupportedVersion,实际 %v", err)
}
})
t.Run("路径穿越", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.zip")
writeZip(t, p, map[string]string{
"meta.yaml": marshalMeta(t, base),
"../evil.txt": "oops",
"event.db": "x",
})
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
if !errors.Is(err, ErrInvalidPackage) {
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
}
})
t.Run("缺 meta.yaml", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.zip")
writeZip(t, p, map[string]string{"event.db": "x"})
if _, err := InspectProjectZip(p); !errors.Is(err, ErrInvalidPackage) {
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
}
})
}
func TestSafeProjectName(t *testing.T) {
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"}
for _, name := range bad {
if _, err := SafeProjectName(name); err == nil {
t.Errorf("SafeProjectName(%q) 应报错", name)
}
}
for _, name := range []string{"2026 运动会", "test-event_1", "测试赛事"} {
if _, err := SafeProjectName(name); err != nil {
t.Errorf("SafeProjectName(%q) 不应报错: %v", name, err)
}
}
}
func assertTeams(t *testing.T, path string, want int) {
t.Helper()
db := openSQLite(t, path)
defer func() { _ = db.Close() }()
var n int
if err := db.QueryRow(`SELECT count(*) FROM teams`).Scan(&n); err != nil {
t.Fatalf("查询 %s 的 teams: %v", path, err)
}
if n != want {
t.Errorf("%s 的 teams 数 = %d,期望 %d", path, n, want)
}
}
func zipHas(path string, t *testing.T, name string) bool {
t.Helper()
r, err := zip.OpenReader(path)
if err != nil {
t.Fatalf("打开 zip: %v", err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {
if f.Name == name {
return true
}
}
return false
}
func writeZip(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("创建 zip: %v", err)
}
zw := zip.NewWriter(f)
for name, body := range files {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("创建条目 %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("写入条目 %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("关闭 zip: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("关闭文件: %v", err)
}
}
func marshalMeta(t *testing.T, meta ProjectMeta) string {
t.Helper()
raw, err := yaml.Marshal(&meta)
if err != nil {
t.Fatalf("序列化 meta: %v", err)
}
return string(raw)
}
+203
View File
@@ -0,0 +1,203 @@
package export
import (
"database/sql"
"io"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。
// 三个榜由 domain 的排名函数算出,本包只负责排版。
type StandingsWorkbook struct {
Meta Meta
Team []domain.StandingsRow
Medal []domain.MedalRow
Athlete []domain.AthleteStandingsRow
}
var (
teamColumns = []column{
{"名次", 6, kindInt},
{"单位代码", 10, kindCenter},
{"单位名称", 26, kindText},
{"总分", 9, kindNumber},
{"团体项目分", 12, kindNumber},
{"个人项目分", 12, kindNumber},
{"金牌", 7, kindInt},
{"银牌", 7, kindInt},
{"铜牌", 7, kindInt},
{"奖牌合计", 10, kindInt},
}
medalColumns = []column{
{"名次", 6, kindInt},
{"单位代码", 10, kindCenter},
{"单位名称", 26, kindText},
{"金牌", 7, kindInt},
{"银牌", 7, kindInt},
{"铜牌", 7, kindInt},
{"奖牌合计", 10, kindInt},
{"总分", 9, kindNumber},
}
athleteColumns = []column{
{"名次", 6, kindInt},
{"运动员", 16, kindText},
{"单位代码", 10, kindCenter},
{"单位名称", 24, kindText},
{"总分", 9, kindNumber},
{"金牌", 7, kindInt},
{"银牌", 7, kindInt},
{"铜牌", 7, kindInt},
{"奖牌合计", 10, kindInt},
}
)
// ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。
func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadStandingsWorkbook(db, opts)
if err != nil {
return err
}
f, err := standingsFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteStandingsXLSX 把已算好的总表写成 xlsx 流,供 HTTP 直接响应下载。
func WriteStandingsXLSX(w io.Writer, wb StandingsWorkbook) error {
f, err := standingsFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
func standingsFile(wb StandingsWorkbook) (*excelize.File, error) {
return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error {
return writeStandingsSheets(f, st, wb)
})
}
func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook) error {
meta := wb.Meta.normalized()
specs := []struct {
sheet string
title string
cols []column
rows [][]any
}{
{"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)},
{"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)},
{"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)},
}
for _, s := range specs {
if err := addSheet(f, s.sheet); err != nil {
return err
}
if err := writeSheet(f, st, s.sheet, meta, s.title, s.cols, s.rows); err != nil {
return err
}
}
return nil
}
func teamRows(rows []domain.StandingsRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{
r.Rank, r.Code, displayName(r.Name, r.ShortName), r.Points,
r.TeamOnlyPoints, r.IndividualPoints,
r.Gold, r.Silver, r.Bronze, r.Medals,
})
}
return out
}
func medalRows(rows []domain.MedalRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{
r.Rank, r.Code, displayName(r.Name, r.ShortName),
r.Gold, r.Silver, r.Bronze, r.Total, r.Points,
})
}
return out
}
func athleteRows(rows []domain.AthleteStandingsRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{
r.Rank, r.Name, r.TeamCode, displayName(r.TeamName, ""),
r.Points, r.Gold, r.Silver, r.Bronze, r.Medals,
})
}
return out
}
// displayName 优先用全称,其次简称;都为空时留空,不显示 "0" 之类的噪声。
func displayName(name, short string) string {
if name != "" {
return name
}
return short
}
// AllWorkbook 是「全部」导出:总表 + 全部分项成绩。
type AllWorkbook struct {
Standings StandingsWorkbook
Events EventsWorkbook
}
// ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。
func ExportAllXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAllWorkbook(db, opts)
if err != nil {
return err
}
f, err := allFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteAllXLSX 把「全部」写成 xlsx 流。
func WriteAllXLSX(w io.Writer, wb AllWorkbook) error {
f, err := allFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
// LoadAllWorkbook 一次性读取总表与全部分项成绩。
func LoadAllWorkbook(db *sql.DB, opts Options) (AllWorkbook, error) {
standings, err := LoadStandingsWorkbook(db, opts)
if err != nil {
return AllWorkbook{}, err
}
events, err := LoadEventWorkbook(db, 0, opts)
if err != nil {
return AllWorkbook{}, err
}
return AllWorkbook{Standings: standings, Events: events}, nil
}
func allFile(wb AllWorkbook) (*excelize.File, error) {
return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error {
if err := writeStandingsSheets(f, st, wb.Standings); err != nil {
return err
}
namer := &sheetNamer{}
// 三个榜先登记名称,分项表才不会撞名。
for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} {
namer.add(n)
}
return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer)
})
}
+367
View File
@@ -0,0 +1,367 @@
package export
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
)
// 配色跟随管理端 MIUIx 令牌,导出件与界面观感一致;字体用 Windows 自带的微软雅黑,
// 现场机器不需要额外装字体就能正确显示。
const (
colorPrimary = "3482FF"
colorHeaderText = "FFFFFF"
colorBorder = "D0D7E5"
colorBand = "F5F8FF"
colorMuted = "6B7280"
fontFamily = "微软雅黑"
)
// 每张表的固定版式:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起为数据。
const (
rowTitle = 1
rowSubtitle = 2
rowHeader = 4
)
// maxSheetName 是 Excel 对工作表名的硬限制。
const maxSheetName = 31
// cellKind 决定单元格的对齐方式与数字格式。
type cellKind int
const (
kindText cellKind = iota
kindCenter
kindNumber
kindInt
)
// column 是一列的展示定义;宽度按内容预留,导出即可打印,不用再手工调列宽。
type column struct {
title string
width float64
kind cellKind
}
type dataKey struct {
kind cellKind
band bool
}
// styleSet 是一份工作簿内复用的样式集合;excelize 的样式 id 与工作簿绑定,
// 因此每打开一份新工作簿都要重建一次。
type styleSet struct {
title int
subtitle int
header int
data map[dataKey]int
}
func newStyleSet(f *excelize.File) (*styleSet, error) {
border := []excelize.Border{
{Type: "left", Color: colorBorder, Style: 1},
{Type: "right", Color: colorBorder, Style: 1},
{Type: "top", Color: colorBorder, Style: 1},
{Type: "bottom", Color: colorBorder, Style: 1},
}
st := &styleSet{data: make(map[dataKey]int, 8)}
var err error
if st.title, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 16, Family: fontFamily},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}); err != nil {
return nil, fmt.Errorf("export: 创建标题样式: %w", err)
}
if st.subtitle, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 10, Family: fontFamily, Color: colorMuted},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}); err != nil {
return nil, fmt.Errorf("export: 创建副标题样式: %w", err)
}
if st.header, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11, Family: fontFamily, Color: colorHeaderText},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorPrimary}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: border,
}); err != nil {
return nil, fmt.Errorf("export: 创建表头样式: %w", err)
}
for _, kind := range []cellKind{kindText, kindCenter, kindNumber, kindInt} {
for _, band := range []bool{false, true} {
id, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 11, Family: fontFamily},
Alignment: &excelize.Alignment{Horizontal: horizontal(kind), Vertical: "center", WrapText: kind == kindText},
Border: border,
Fill: fill(band),
CustomNumFmt: numFmt(kind),
})
if err != nil {
return nil, fmt.Errorf("export: 创建数据样式: %w", err)
}
st.data[dataKey{kind: kind, band: band}] = id
}
}
return st, nil
}
func horizontal(kind cellKind) string {
if kind == kindText {
return "left"
}
return "center"
}
func fill(band bool) excelize.Fill {
if !band {
return excelize.Fill{}
}
return excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorBand}}
}
// numFmt 让积分最多保留两位小数、不显示多余的 0;整数列固定不带小数。
func numFmt(kind cellKind) *string {
switch kind {
case kindNumber:
return strPtr("0.##")
case kindInt:
return strPtr("0")
default:
return nil
}
}
func (s *styleSet) cell(kind cellKind, band bool) int {
return s.data[dataKey{kind: kind, band: band}]
}
// buildWorkbook 建好工作簿与样式后交给 build 填充。
// build 若不新建任何工作表,就把默认工作表改名为 defaultSheet,避免出现空白的 "Sheet1"。
func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSet) error) (*excelize.File, error) {
f := excelize.NewFile()
st, err := newStyleSet(f)
if err != nil {
return nil, err
}
if err := build(f, st); err != nil {
return nil, err
}
sheets := f.GetSheetList()
if len(sheets) == 1 && sheets[0] == "Sheet1" {
if err := f.SetSheetName("Sheet1", sanitizeSheetName(defaultSheet)); err != nil {
return nil, fmt.Errorf("export: 重命名工作表: %w", err)
}
return f, nil
}
if idx, err := f.GetSheetIndex("Sheet1"); err == nil && idx >= 0 {
_ = f.DeleteSheet("Sheet1")
}
return f, nil
}
func addSheet(f *excelize.File, name string) error {
if _, err := f.NewSheet(name); err != nil {
return fmt.Errorf("export: 新建工作表 %s: %w", name, err)
}
return nil
}
// writeSheet 按统一版式写一张表:合并标题、副标题、带底色的表头、斑马纹数据行、
// 冻结表头,并预设 A4 横向、按宽度缩放、每页重复表头,导出后可直接打印。
func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title string, cols []column, rows [][]any) error {
if len(cols) == 0 {
return fmt.Errorf("export: 工作表 %s 没有列定义", sheet)
}
lastCol := colName(len(cols))
if err := f.SetCellValue(sheet, "A1", title); err != nil {
return err
}
if err := f.MergeCell(sheet, "A1", lastCol+"1"); err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", lastCol+"1", st.title); err != nil {
return err
}
if err := f.SetRowHeight(sheet, rowTitle, 32); err != nil {
return err
}
if sub := metaSubtitle(meta); sub != "" {
if err := f.SetCellValue(sheet, "A2", sub); err != nil {
return err
}
if err := f.MergeCell(sheet, "A2", lastCol+"2"); err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A2", lastCol+"2", st.subtitle); err != nil {
return err
}
}
for i, c := range cols {
name := colName(i + 1)
if err := f.SetCellValue(sheet, name+strconv.Itoa(rowHeader), c.title); err != nil {
return err
}
if err := f.SetColWidth(sheet, name, name, c.width); err != nil {
return err
}
}
if err := f.SetCellStyle(sheet, "A"+strconv.Itoa(rowHeader), lastCol+strconv.Itoa(rowHeader), st.header); err != nil {
return err
}
if err := f.SetRowHeight(sheet, rowHeader, 26); err != nil {
return err
}
for r, row := range rows {
excelRow := rowHeader + 1 + r
for i, c := range cols {
var v any = ""
if i < len(row) && row[i] != nil {
v = row[i]
}
cell := colName(i+1) + strconv.Itoa(excelRow)
if err := f.SetCellValue(sheet, cell, v); err != nil {
return err
}
if err := f.SetCellStyle(sheet, cell, cell, st.cell(c.kind, r%2 == 1)); err != nil {
return err
}
}
}
if err := f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: rowHeader,
TopLeftCell: "A" + strconv.Itoa(rowHeader+1),
ActivePane: "bottomLeft",
}); err != nil {
return err
}
if err := f.SetPageLayout(sheet, &excelize.PageLayoutOptions{
Size: intPtr(9), // 9 = A4
Orientation: strPtr("landscape"),
FitToWidth: intPtr(1),
FitToHeight: intPtr(0),
}); err != nil {
return err
}
// 打印标题与页码是「锦上添花」,失败不影响文件可用性,故忽略错误。
_ = f.SetDefinedName(&excelize.DefinedName{
Name: "_xlnm.Print_Titles",
RefersTo: fmt.Sprintf("'%s'!$1:$%d", sheet, rowHeader),
Scope: sheet,
})
_ = f.SetHeaderFooter(sheet, &excelize.HeaderFooterOptions{
OddHeader: "&C" + title,
OddFooter: "&C第 &P 页 / 共 &N 页",
})
return nil
}
// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。
func saveWorkbook(f *excelize.File, dst string) error {
if strings.TrimSpace(dst) == "" {
return errors.New("export: 导出路径为空")
}
if dir := filepath.Dir(dst); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
}
}
if err := f.SaveAs(dst); err != nil {
return fmt.Errorf("export: 保存 xlsx %s: %w", dst, err)
}
return nil
}
// sheetTitle 把赛事名拼在表名前面,打印出来一眼能看出是哪场赛事。
func sheetTitle(meta Meta, name string) string {
if meta.EventName == "" {
return name
}
return meta.EventName + " · " + name
}
func metaSubtitle(m Meta) string {
parts := make([]string, 0, 4)
if m.Organizer != "" {
parts = append(parts, m.Organizer)
}
if m.Subtitle != "" {
parts = append(parts, m.Subtitle)
}
if !m.GeneratedAt.IsZero() {
parts = append(parts, "导出时间 "+m.GeneratedAt.Format("2006-01-02 15:04"))
}
if m.GeneratedBy != "" {
parts = append(parts, "导出人 "+m.GeneratedBy)
}
return strings.Join(parts, " · ")
}
// sanitizeSheetName 替换 Excel 不允许的字符并截断到 31 字符。
func sanitizeSheetName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "成绩"
}
name = strings.NewReplacer(
"[", "(", "]", ")", ":", ":", "*", "*",
"?", "?", "/", "/", "\\", "\",
).Replace(name)
if rs := []rune(name); len(rs) > maxSheetName {
name = string(rs[:maxSheetName])
}
return name
}
// sheetNamer 保证同一工作簿内的工作表名唯一(Excel 不允许重名)。
type sheetNamer struct {
used map[string]bool
}
func (n *sheetNamer) add(base string) string {
base = sanitizeSheetName(base)
if n.used == nil {
n.used = make(map[string]bool)
}
name := base
for i := 2; n.used[name]; i++ {
suffix := fmt.Sprintf("(%d)", i)
rs := []rune(base)
if keep := maxSheetName - len([]rune(suffix)); len(rs) > keep {
rs = rs[:keep]
}
name = string(rs) + suffix
}
n.used[name] = true
return name
}
func colName(n int) string {
name, _ := excelize.ColumnNumberToName(n)
return name
}
func intPtr(v int) *int { return &v }
func strPtr(v string) *string { return &v }
// nowOr 让测试可以注入固定时间;零值取当前时间。
func nowOr(t time.Time) time.Time {
if t.IsZero() {
return time.Now()
}
return t
}
+98
View File
@@ -0,0 +1,98 @@
package export
import (
"database/sql"
"io"
"strconv"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// TeamDetailRow 是单位积分明细的一行:一分一行的得分来源。
type TeamDetailRow struct {
SportName string
EventName string
Category string
AthleteName string
Rank int
ResultText string
Source string
Points float64
Remark string
}
// TeamDetailWorkbook 是「单位积分明细」的内容。
type TeamDetailWorkbook struct {
Meta Meta
Team domain.Team
Rows []TeamDetailRow
}
var teamDetailColumns = []column{
{"序号", 6, kindInt},
{"大项", 14, kindText},
{"小项", 18, kindText},
{"组别", 14, kindText},
{"运动员", 14, kindText},
{"名次", 7, kindCenter},
{"成绩", 12, kindCenter},
{"来源", 12, kindCenter},
{"积分", 8, kindNumber},
{"备注", 20, kindText},
}
// ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。
func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error {
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
if err != nil {
return err
}
f, err := teamDetailFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteTeamDetailXLSX 把单位积分明细写成 xlsx 流。
func WriteTeamDetailXLSX(w io.Writer, wb TeamDetailWorkbook) error {
f, err := teamDetailFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) {
return buildWorkbook("积分明细", func(f *excelize.File, st *styleSet) error {
meta := wb.Meta.normalized()
name := sanitizeSheetName(displayName(wb.Team.Name, wb.Team.ShortName) + "积分明细")
if err := addSheet(f, name); err != nil {
return err
}
title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细")
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows))
})
}
func teamDetailRows(rows []TeamDetailRow) [][]any {
out := make([][]any, 0, len(rows)+1)
var total float64
for i, r := range rows {
total += r.Points
rank := ""
if r.Rank > 0 {
rank = strconv.Itoa(r.Rank)
}
out = append(out, []any{
i + 1, r.SportName, r.EventName, r.Category, r.AthleteName,
rank, r.ResultText, r.Source, r.Points, r.Remark,
})
}
if len(rows) > 0 {
out = append(out, []any{"合计", "", "", "", "", "", "", "", total, ""})
}
return out
}
+259
View File
@@ -0,0 +1,259 @@
package export
import (
"bytes"
"path/filepath"
"testing"
"github.com/xuri/excelize/v2"
)
func TestExportStandingsXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dst := filepath.Join(t.TempDir(), "总表.xlsx")
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出总表: %v", err)
}
f, err := excelize.OpenFile(dst)
if err != nil {
t.Fatalf("重新打开 xlsx: %v", err)
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
// 版式固定:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起数据;2 个单位 → 6 行。
team, err := f.GetRows("总积分榜")
if err != nil {
t.Fatalf("读取总积分榜: %v", err)
}
if len(team) != 6 {
t.Fatalf("总积分榜行数 = %d,期望 6", len(team))
}
if got := cell(team, 0, 0); got != "测试运动会 · 总积分榜" {
t.Errorf("标题 = %q", got)
}
if got := cell(team, 3, 0); got != "名次" {
t.Errorf("表头首列 = %q,期望 名次", got)
}
if got := cell(team, 4, 1); got != "T1" {
t.Errorf("第一名单位代码 = %q,期望 T1", got)
}
if got := cell(team, 4, 3); got != "10" {
t.Errorf("T1 总分 = %q,期望 10", got)
}
if got := cell(team, 4, 6); got != "1" {
t.Errorf("T1 金牌 = %q,期望 1", got)
}
if got := cell(team, 5, 1); got != "T2" {
t.Errorf("第二名单位代码 = %q,期望 T2", got)
}
medal, err := f.GetRows("团体奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
if len(medal) != 6 {
t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal))
}
if got := cell(medal, 4, 3); got != "1" { // T1 金牌
t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got)
}
athlete, err := f.GetRows("个人积分奖牌榜")
if err != nil {
t.Fatalf("读取个人积分奖牌榜: %v", err)
}
if len(athlete) != 6 {
t.Fatalf("个人积分奖牌榜行数 = %d,期望 6", len(athlete))
}
if got := cell(athlete, 4, 1); got != "张三" {
t.Errorf("个人榜第一行 = %q,期望 张三", got)
}
}
func TestWriteStandingsXLSXStream(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表数据: %v", err)
}
var buf bytes.Buffer
if err := WriteStandingsXLSX(&buf, wb); err != nil {
t.Fatalf("流式导出: %v", err)
}
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("打开流式导出的 xlsx: %v", err)
}
defer func() { _ = f.Close() }()
if got := len(f.GetSheetList()); got != 3 {
t.Errorf("sheet 数 = %d,期望 3", got)
}
}
func TestExportEventXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
f, err := excelize.OpenFile(dst)
if err != nil {
t.Fatalf("重新打开 xlsx: %v", err)
}
defer func() { _ = f.Close() }()
want := []string{"田径·100米跑·男子"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
rows, err := f.GetRows(want[0])
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
if len(rows) != 6 {
t.Fatalf("分项成绩行数 = %d,期望 6", len(rows))
}
if got := cell(rows, 4, 0); got != "1" {
t.Errorf("第一名名次 = %q,期望 1", got)
}
if got := cell(rows, 4, 1); got != "101" {
t.Errorf("第一名号码 = %q,期望 101", got)
}
if got := cell(rows, 4, 3); got != "张三" {
t.Errorf("第一名运动员 = %q,期望 张三", got)
}
if got := cell(rows, 4, 4); got != "11.58 秒" {
t.Errorf("第一名成绩 = %q,期望 11.58 秒", got)
}
if got := cell(rows, 4, 5); got != "10" {
t.Errorf("第一名积分 = %q,期望 10", got)
}
if got := cell(rows, 4, 6); got != "有效" {
t.Errorf("第一名状态 = %q,期望 有效", got)
}
}
func TestExportAllXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dst := filepath.Join(t.TempDir(), "全部.xlsx")
if err := ExportAllXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出全部: %v", err)
}
f, err := excelize.OpenFile(dst)
if err != nil {
t.Fatalf("重新打开 xlsx: %v", err)
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
}
func TestExportTeamDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dst := filepath.Join(t.TempDir(), "明细.xlsx")
if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出单位积分明细: %v", err)
}
f, err := excelize.OpenFile(dst)
if err != nil {
t.Fatalf("重新打开 xlsx: %v", err)
}
defer func() { _ = f.Close() }()
sheet := "第一分公司积分明细"
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s: %v", sheet, err)
}
// 4 行版式 + 1 条明细 + 1 行合计
if len(rows) != 6 {
t.Fatalf("%s 行数 = %d,期望 6", sheet, len(rows))
}
if got := cell(rows, 4, 1); got != "田径" {
t.Errorf("明细大项 = %q,期望 田径", got)
}
if got := cell(rows, 4, 5); got != "1" {
t.Errorf("明细名次 = %q,期望 1", got)
}
if got := cell(rows, 4, 7); got != "成绩" {
t.Errorf("明细来源 = %q,期望 成绩", got)
}
if got := cell(rows, 4, 8); got != "10" {
t.Errorf("明细积分 = %q,期望 10", got)
}
if got := cell(rows, 5, 0); got != "合计" {
t.Errorf("合计行首列 = %q,期望 合计", got)
}
if got := cell(rows, 5, 8); got != "10" {
t.Errorf("合计积分 = %q,期望 10", got)
}
}
// TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。
func TestLoadStandingsHonoursSettings(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
t.Fatalf("修改折算系数: %v", err)
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表数据: %v", err)
}
if len(wb.Team) != 2 {
t.Fatalf("单位数 = %d,期望 2", len(wb.Team))
}
if wb.Team[0].Points != 20 {
t.Errorf("系数 2 时 T1 总分 = %v,期望 20", wb.Team[0].Points)
}
}
func TestLoadEventWorkbookMissingEvent(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
if _, err := LoadEventWorkbook(db, 999, Options{}); err == nil {
t.Fatal("导出不存在的小项应报错")
}
}
func cell(rows [][]string, r, c int) string {
if r < 0 || r >= len(rows) {
return ""
}
if c < 0 || c >= len(rows[r]) {
return ""
}
return rows[r][c]
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+338
View File
@@ -0,0 +1,338 @@
package httpapi_test
import (
"database/sql"
"encoding/json"
"net/http"
"testing"
)
type seedIDs struct {
teamA, teamB int64
athleteA, athB int64
sport, event int64
category, entryA int64
entryB int64
}
func (e *testEnv) seed(t *testing.T) seedIDs {
t.Helper()
var ids seedIDs
ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB)
ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
ids.event = e.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event)
var profileID int64
if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID)
ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA)
ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB)
return ids
}
func (e *testEnv) exec(t *testing.T, query string, args ...any) {
t.Helper()
if _, err := e.ev.Exec(query, args...); err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
}
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
t.Helper()
res, err := e.ev.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}
// 未登录 = guest:赛事数据可读,系统配置一律 403。
func TestGuestReadsEventDataButNotConfig(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
client, _ := env.client()
if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK)
}
if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK {
t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK)
}
for _, path := range []string{"/api/settings", "/api/audit-logs"} {
status, env2 := env.do(client, http.MethodGet, path, nil, false)
if status != http.StatusForbidden {
t.Fatalf("guest 读 %s 应 403,得到 %d", path, status)
}
if env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error)
}
}
// 未登录写操作要求先登录。
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
}
// recorder 拥有录入权限,但没有系统配置权限。
func TestRecorderForbiddenOnAdminRoutes(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"name": "改名"},
}, true)
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读审计应 403,得到 %d", status)
}
// recorder 的合法写操作仍然可用。
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 建单位应 200,得到 %d %+v", status, env2.Error)
}
}
// 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。
func TestLoginCreatesSession(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var count int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil {
t.Fatalf("查询会话: %v", err)
}
if count != 1 {
t.Fatalf("登录后应有 1 条会话,得到 %d", count)
}
status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d", status)
}
me := decodeData[map[string]any](t, env2)
if me["username"] != "recorder" || me["role"] != "recorder" {
t.Fatalf("me 返回身份不对: %+v", me)
}
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false)
me = decodeData[map[string]any](t, env2)
if status != http.StatusOK || me["role"] != "guest" {
t.Fatalf("登出后应为 guest,得到 %d %+v", status, me)
}
}
// 首次启动创建默认管理员并打印随机初始密码。
func TestEnsureDefaultAdmin(t *testing.T) {
env := newTestEnv(t)
username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context())
if err != nil {
t.Fatalf("创建默认管理员: %v", err)
}
if !created || username == "" || len(password) < 12 {
t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password))
}
client, _ := env.client()
env.login(client, username, password)
}
// 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。
func TestPublishWritesLedgerAndAudit(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
results := decodeData[[]map[string]any](t, env2)
if len(results) != 2 {
t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results))
}
for _, r := range results {
if r["state"] != "published" {
t.Fatalf("成绩应为 published: %+v", r)
}
}
// 流水:A 10 分、B 8 分。
rows, err := env.ev.Query(
`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
points := map[int64]float64{}
for rows.Next() {
var team int64
var p float64
if err := rows.Scan(&team, &p); err != nil {
t.Fatalf("扫描流水: %v", err)
}
points[team] = p
}
if points[ids.teamA] != 10 || points[ids.teamB] != 8 {
t.Fatalf("流水分值不对: %+v", points)
}
// 积分榜:A 第一。
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 {
t.Fatalf("积分榜不对: %+v", standings)
}
if standings[0]["gold"].(float64) != 1 {
t.Fatalf("A 应记 1 金: %+v", standings[0])
}
// 审计:操作人、IP、request_id、before/after 都要有。
var (
action string
auditUser sql.NullInt64
ip, requestID sql.NullString
before, after sql.NullString
)
err = env.ev.QueryRow(
`SELECT action, user_id, ip, request_id, before_json, after_json
FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`).
Scan(&action, &auditUser, &ip, &requestID, &before, &after)
if err != nil {
t.Fatalf("查询审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser)
}
if !ip.Valid || ip.String == "" {
t.Fatalf("审计应记录 IP")
}
if !requestID.Valid || requestID.String == "" {
t.Fatalf("审计应记录 request_id")
}
if !before.Valid || !after.Valid {
t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after)
}
var parsed []map[string]any
if err := json.Unmarshal([]byte(after.String), &parsed); err != nil {
t.Fatalf("审计 after_json 应为成绩数组: %v", err)
}
}
// 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400;
// 同一个 X-Request-Id 重复提交应回放首次结果。
func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"}
status, first := env.do(client, http.MethodPost, "/api/teams", body, true)
if status != http.StatusOK || !first.OK {
t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error)
}
headers := map[string]string{"X-Request-Id": "replay-key-1"}
status, second := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers)
if status != http.StatusOK {
t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status)
}
status, replay := env.doH(client, http.MethodPost, "/api/teams",
map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers)
if status != http.StatusOK || string(replay.Data) != string(second.Data) {
t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data)
}
var teams int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil {
t.Fatalf("统计单位: %v", err)
}
if teams != 2 {
t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams)
}
}
// 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。
func TestPublishIdempotentReplay(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
body := map[string]any{"eventId": ids.event, "requestId": "publish-same"}
_, first := env.do(client, http.MethodPost, "/api/results/publish", body, true)
_, second := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if string(first.Data) != string(second.Data) {
t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data)
}
var ledgerRows int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil {
t.Fatalf("统计流水: %v", err)
}
if ledgerRows != 2 {
t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows)
}
}
+331
View File
@@ -0,0 +1,331 @@
package httpapi
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。
// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。
type teamDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
LogoPath *string `json:"logoPath"`
Color *string `json:"color"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
}
type athleteDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Gender *string `json:"gender"`
TeamID int64 `json:"teamId"`
EmployeeNo *string `json:"employeeNo"`
Active bool `json:"active"`
}
type sportDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
DefaultProfileID *int64 `json:"defaultProfileId"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
}
type categoryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Name string `json:"name"`
Multiplier float64 `json:"multiplier"`
Gender *string `json:"gender"`
AgeMin *int `json:"ageMin"`
AgeMax *int `json:"ageMax"`
SortOrder int `json:"sortOrder"`
}
type channelDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope string `json:"scope"`
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
}
type eventDTO struct {
ID int64 `json:"id"`
SportID int64 `json:"sportId"`
Code string `json:"code"`
Name string `json:"name"`
EventType string `json:"eventType"`
ResultMode string `json:"resultMode"`
ResultType string `json:"resultType"`
Compare string `json:"compare"`
RecordEligible bool `json:"recordEligible"`
Scored bool `json:"scored"`
Multiplier float64 `json:"multiplier"`
Status string `json:"status"`
ScheduledAt *string `json:"scheduledAt"`
Venue *string `json:"venue"`
SortOrder int `json:"sortOrder"`
Remark *string `json:"remark"`
Categories []categoryDTO `json:"categories,omitempty"`
Channels []channelDTO `json:"channels,omitempty"`
}
type profileRuleDTO struct {
Rank int `json:"rank"`
Points float64 `json:"points"`
}
type profileDTO struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TieMode string `json:"tieMode"`
RecordBonusMode string `json:"recordBonusMode"`
AwardRanks int `json:"awardRanks"`
LastPlaceZero bool `json:"lastPlaceZero"`
Rules []profileRuleDTO `json:"rules"`
}
type entryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"`
AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"`
Seed *string `json:"seed"`
Status string `json:"status"`
Remark *string `json:"remark"`
AthleteName *string `json:"athleteName,omitempty"`
TeamCode string `json:"teamCode,omitempty"`
TeamName string `json:"teamName,omitempty"`
ShortName *string `json:"shortName,omitempty"`
CategoryName string `json:"categoryName,omitempty"`
}
type entryWithResultDTO struct {
entryDTO
Result *resultDTO `json:"result"`
}
type resultDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit string `json:"unit"`
IsTie bool `json:"isTie"`
PointsOverride *float64 `json:"pointsOverride"`
Status string `json:"status"`
State string `json:"state"`
RecordedBy *int64 `json:"recordedBy"`
RecordedAt *string `json:"recordedAt"`
VerifiedBy *int64 `json:"verifiedBy"`
VerifiedAt *string `json:"verifiedAt"`
Note *string `json:"note"`
Points *float64 `json:"points,omitempty"`
}
type standingRowDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
Color *string `json:"color"`
LogoPath *string `json:"logoPath"`
Rank int `json:"rank"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
MedalTotal int `json:"medalTotal"`
TotalPoints float64 `json:"totalPoints"`
RankChange *int `json:"rankChange"`
}
type athleteStandingDTO struct {
Rank int `json:"rank"`
AthleteID int64 `json:"athleteId"`
AthleteName string `json:"athleteName"`
Gender *string `json:"gender"`
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
TotalPoints float64 `json:"totalPoints"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
}
type breakdownItemDTO struct {
LedgerID int64 `json:"ledgerId"`
SourceType string `json:"sourceType"`
EventID *int64 `json:"eventId"`
EventCode *string `json:"eventCode"`
EventName *string `json:"eventName"`
CategoryName *string `json:"categoryName"`
AthleteID *int64 `json:"athleteId"`
AthleteName *string `json:"athleteName"`
Rank *int `json:"rank"`
ResultText *string `json:"resultText"`
Points float64 `json:"points"`
CreatedAt string `json:"createdAt"`
}
type teamBreakdownDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
TotalPoints float64 `json:"totalPoints"`
Items []breakdownItemDTO `json:"items"`
}
type adjustmentDTO struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
Category string `json:"category"`
Reason string `json:"reason"`
EventID *int64 `json:"eventId"`
Voided bool `json:"voided"`
CreatedBy *int64 `json:"createdBy"`
CreatedAt string `json:"createdAt"`
}
type recordEntryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
HolderName *string `json:"holderName"`
HolderTeamID *int64 `json:"holderTeamId"`
AchievedAt *string `json:"achievedAt"`
IsCurrent bool `json:"isCurrent"`
Source string `json:"source"`
}
type recordBreakDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
BonusPoints float64 `json:"bonusPoints"`
State string `json:"state"`
ApprovedBy *int64 `json:"approvedBy"`
ApprovedAt *string `json:"approvedAt"`
}
type auditLogDTO struct {
ID int64 `json:"id"`
UserID *int64 `json:"userId"`
Action string `json:"action"`
EntityType string `json:"entityType"`
EntityID *int64 `json:"entityId"`
BeforeJSON *string `json:"beforeJson"`
AfterJSON *string `json:"afterJson"`
IP *string `json:"ip"`
RequestID *string `json:"requestId"`
CreatedAt string `json:"createdAt"`
}
type currentUserDTO struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
LastLoginAt *string `json:"lastLoginAt"`
MustChangePassword bool `json:"mustChangePassword"`
}
type backupResultDTO struct {
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
}
// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。
type eventSettingsDTO struct {
Event eventSettingsEvent `json:"event"`
Display eventSettingsDisplay `json:"display"`
Standings eventSettingsStandings `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval bool `json:"requireApproval"`
}
type eventSettingsEvent struct {
Name string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
type eventSettingsDisplay struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSecond int `json:"rotateSeconds"`
}
type eventSettingsStandings struct {
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
RankingMode string `json:"rankingMode"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
}
// ---------- domain → DTO ----------
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
out := make([]standingRowDTO, 0, len(rows))
for _, r := range rows {
t := teams[r.TeamID]
dto := standingRowDTO{
TeamID: r.TeamID,
TeamCode: r.Code,
TeamName: r.Name,
ShortName: nilIfEmpty(r.ShortName),
Rank: r.Rank,
Gold: r.Gold,
Silver: r.Silver,
Bronze: r.Bronze,
MedalTotal: r.Medals,
TotalPoints: r.Points,
RankChange: nil,
}
if t.ID != 0 {
dto.Color = t.Color
dto.LogoPath = t.LogoPath
if dto.TeamCode == "" {
dto.TeamCode = t.Code
}
if dto.TeamName == "" {
dto.TeamName = t.Name
}
if dto.ShortName == nil {
dto.ShortName = t.ShortName
}
}
out = append(out, dto)
}
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+150
View File
@@ -0,0 +1,150 @@
package httpapi
import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip))
mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip))
mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups))
}
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
func (s *server) streamDownload(w http.ResponseWriter, filename, contentType string, write func(io.Writer) error) (any, error) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filename))
if err := write(w); err != nil {
s.logger.Error("导出写出失败", "file", filename, "err", err)
}
return nil, errHandled
}
func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadStandingsWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteStandingsXLSX(out, wb)
})
}
func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteEventXLSX(out, wb)
})
}
func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAllWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAllXLSX(out, wb)
})
}
func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteTeamDetailXLSX(out, wb)
})
}
func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
dir := filepath.Join(s.dataDir, "exports")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{}); err != nil {
return nil, internal(err)
}
defer func() { _ = os.Remove(dst) }()
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.zip"))
http.ServeFile(w, r, dst)
return nil, errHandled
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少上传字段 file", nil)
}
defer func() { _ = file.Close() }()
tmpDir, err := os.MkdirTemp("", "scoreboard-import-")
if err != nil {
return nil, internal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmp := filepath.Join(tmpDir, "project.zip")
out, err := os.Create(tmp)
if err != nil {
return nil, internal(err)
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
return nil, internal(err)
}
if err := out.Close(); err != nil {
return nil, internal(err)
}
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
})
if err != nil {
return nil, badRequest(err.Error(), nil)
}
return res, nil
}
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
return nil, internal(err)
}
return items, nil
}
+652
View File
@@ -0,0 +1,652 @@
package httpapi
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- 认证 ----------
func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
var in loginInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if strings.TrimSpace(in.Username) == "" || in.Password == "" {
return nil, badRequest("username 与 password 必填", nil)
}
ip := middleware.ClientIP(r)
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
switch {
case err == auth.ErrRateLimited:
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
case err != nil:
return nil, internal(err)
}
s.auth.SetSessionCookie(w, token)
csrf, err := auth.NewCSRFToken()
if err != nil {
return nil, internal(err)
}
s.auth.SetCSRFCookie(w, csrf)
s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username})
return userDTO(u), nil
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
return nil, nil
}
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
return userDTO(u), nil
}
func userDTO(u *auth.User) currentUserDTO {
dto := currentUserDTO{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role.String(),
MustChangePassword: u.MustChangePassword,
}
if u.Role == auth.RoleGuest {
dto.Username = "guest"
dto.DisplayName = "访客"
}
if u.LastLoginAt != "" {
v := u.LastLoginAt
dto.LastLoginAt = &v
}
return dto
}
// ---------- settings ----------
func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.getSettings(r.Context())
}
func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in settingsPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getSettings(r.Context())
if err != nil {
return nil, err
}
updated, err := s.updateSettings(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "settings.update", "settings", 0, before, updated)
return updated, nil
})
}
// ---------- teams ----------
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listTeams(r.Context())
}
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getTeam(r.Context(), id)
}
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createTeam(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.create", "team", created.ID, nil, created)
return created, nil
}
func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateTeam(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.update", "team", id, before, updated)
return updated, nil
}
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteTeam(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "team.delete", "team", id, before, nil)
return nil
}
// ---------- sports ----------
func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listSports(r.Context())
}
func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getSport(r.Context(), id)
}
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createSport(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created)
return created, nil
}
func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateSport(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.update", "sport", id, before, updated)
return updated, nil
}
func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteSport(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "sport.delete", "sport", id, before, nil)
return nil
}
// ---------- events ----------
func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
sportID, _ := int64Query(r, "sportId")
return s.listEvents(r.Context(), sportID)
}
func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getEvent(r.Context(), id)
}
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createEvent(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.create", "event", created.ID, nil, created)
return created, nil
}
func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEvent(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.update", "event", id, before, updated)
return updated, nil
}
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteEvent(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "event.delete", "event", id, before, nil)
return nil
}
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listEntriesWithResults(r.Context(), id)
}
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listCategories(r.Context(), id)
}
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in categoriesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listCategories(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
return updated, nil
}
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listChannels(r.Context(), id)
}
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in channelsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listChannels(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
return updated, nil
}
// ---------- scoring profiles ----------
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listProfiles(r.Context())
}
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getProfile(r.Context(), id)
}
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createProfile(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
return created, nil
}
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
return updated, nil
}
// ---------- athletes ----------
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
return s.listAthletes(r.Context(), teamID)
}
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getAthlete(r.Context(), id)
}
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createAthlete(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
return created, nil
}
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateAthlete(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
return updated, nil
}
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteAthlete(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil
}
// ---------- 报名表导入 ----------
type entryImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", err.Error())
}
defer func() { _ = file.Close() }()
rows, err := readTable(filepath.Ext(header.Filename), file)
if err != nil {
return nil, err
}
result, err := s.importEntries(r.Context(), rows)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
return result, nil
}
func readTable(ext string, file io.Reader) ([][]string, error) {
switch strings.ToLower(ext) {
case ".xlsx":
f, err := excelize.OpenReader(file)
if err != nil {
return nil, badRequest("无法解析 xlsx", err.Error())
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, badRequest("xlsx 没有工作表", nil)
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return nil, badRequest("读取 xlsx 失败", err.Error())
}
return rows, nil
default:
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
return nil, badRequest("无法解析 CSV", err.Error())
}
return rows, nil
}
}
// importColumns 是报名表必须包含的表头(大小写不敏感)。
var importColumns = []string{
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
}
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
result := entryImportResult{Errors: []string{}}
if len(rows) < 2 {
return result, badRequest("报名表为空", nil)
}
index := make(map[string]int, len(rows[0]))
for i, h := range rows[0] {
index[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, col := range importColumns {
if _, ok := index[col]; !ok {
return result, badRequest("报名表缺少列 "+col, nil)
}
}
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
i := index[name]
if i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
eventCode := cell("event_code")
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
continue
}
var categoryID int64
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
continue
}
}
var athleteID *int64
if athleteName != "" {
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
athleteID = &id
}
}
created, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Status: orDefault(cell("status"), "registered"),
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
case created:
result.Created++
default:
result.Updated++
}
}
return result, nil
}
func splitNames(raw string) []string {
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
+222
View File
@@ -0,0 +1,222 @@
package httpapi
import (
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成绩 ----------
func (s *server) listResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state != "" && state != string(domain.ResultStateDraft) && state != string(domain.ResultStatePublished) {
return nil, badRequest("state 只能是 draft/published", state)
}
page, size := pageQuery(r)
return s.listResultsPage(r.Context(), eventID, state, page, size)
}
func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in saveDraftPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
if len(in.Results) == 0 {
return nil, badRequest("results 不能为空", nil)
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
saved, err := s.upsertDraft(r.Context(), in.EventID, middlewareUserID(r), in.Results)
if err != nil {
return nil, err
}
s.audit(r.Context(), "result.draft.save", "event", in.EventID, nil, saved)
return saved, nil
})
}
func (s *server) publishHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.publishResults(r.Context(), in, middlewareUserID(r))
})
}
func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.unpublishResults(r.Context(), in, middlewareUserID(r))
})
}
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in voidResultPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.voidResult(r.Context(), id, in.Reason, middlewareUserID(r))
})
}
// ---------- 积分与排名 ----------
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(r.Context())
if err != nil {
return nil, err
}
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
}
func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(r.Context())
if err != nil {
return nil, err
}
genders := make(map[int64]*string, len(in.Athletes))
for _, a := range in.Athletes {
if a.Gender != "" {
g := a.Gender
genders[a.ID] = &g
}
}
rows := domain.BuildAthleteStandings(in)
out := make([]athleteStandingDTO, 0, len(rows))
for _, row := range rows {
out = append(out, athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
Gender: genders[row.AthleteID],
TeamID: row.TeamID,
TeamCode: row.TeamCode,
TeamName: row.TeamName,
ShortName: nilIfEmpty(row.TeamName),
TotalPoints: row.Points,
Gold: row.Gold,
Silver: row.Silver,
Bronze: row.Bronze,
})
}
return out, nil
}
func (s *server) breakdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.teamBreakdown(r.Context(), id)
}
// ---------- 人工调整 ----------
func (s *server) listAdjustmentsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
page, size := pageQuery(r)
return s.listAdjustments(r.Context(), teamID, page, size)
}
func (s *server) createAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in adjustmentInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.createAdjustment(r.Context(), in, middlewareUserID(r))
})
}
func (s *server) voidAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in voidAdjustmentPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.voidAdjustment(r.Context(), id, middlewareUserID(r), in.Reason)
})
}
// ---------- 纪录 ----------
func (s *server) listRecordsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
return s.listRecords(r.Context(), eventID)
}
func (s *server) createRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in recordBreakInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
created, err := s.createRecordBreak(r.Context(), in, middlewareUserID(r))
if err != nil {
return nil, err
}
s.audit(r.Context(), "record_break.create", "record_break", created.ID, nil, created)
return created, nil
})
}
func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.approveRecordBreak(r.Context(), id, middlewareUserID(r))
}
// ---------- 大屏控制与运维 ----------
func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in displayCommandInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if in.Action == "" {
return nil, badRequest("action 必填", nil)
}
if s.display != nil {
if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil {
return nil, internal(err)
}
}
s.audit(r.Context(), "display.command", "display", 0, nil, in)
return nil, nil
}
func (s *server) listAuditLogsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
page, size := pageQuery(r)
entityType := strings.TrimSpace(r.URL.Query().Get("entityType"))
userID, _ := int64Query(r, "userId")
return s.listAuditLogs(r.Context(), page, size, entityType, userID)
}
func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.backup(r.Context())
}
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// errHandled 表示响应已经由 handler 自己写出(例如幂等回放),s.handle 不再重复写。
var errHandled = errors.New("httpapi: response already written")
// idempotencyStore 缓存写操作的首次响应(见 devdocs/05 第 5 节:重复提交返回首次结果)。
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
// 进程重启后客户端重试会重新执行,属于可接受的降级。
type idempotencyStore struct {
mu sync.Mutex
entries map[string]idempotentResponse
ttl time.Duration
}
type idempotentResponse struct {
body []byte
expires time.Time
}
func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
}
func (s *idempotencyStore) get(key string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[key]
if !ok || time.Now().After(e.expires) {
delete(s.entries, key)
return nil, false
}
return e.body, true
}
func (s *idempotencyStore) put(key string, body []byte) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if len(s.entries) > 4096 {
for k, e := range s.entries {
if now.After(e.expires) {
delete(s.entries, k)
}
}
}
s.entries[key] = idempotentResponse{body: body, expires: now.Add(s.ttl)}
}
// idempotent 以 requestId 为键执行写操作;重复请求直接回放首次响应。
// 返回值恒为 errHandled 或真正的错误——调用方写成 `return nil, s.idempotent(...)`。
func (s *server) idempotent(w http.ResponseWriter, r *http.Request, requestID string, fn func() (any, error)) error {
if requestID == "" || s.idem == nil {
data, err := fn()
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
return errHandled
}
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return errHandled
}
data, err := fn()
if err != nil {
return err
}
body, err := json.Marshal(map[string]any{"ok": true, "data": data})
if err != nil {
return internal(err)
}
s.idem.put(key, body)
writeCached(w, body)
return errHandled
}
// cached 给所有写路由加一层基于 X-Request-Id 的幂等缓存:
// handler 自己写响应,这里负责在成功后留存响应体、重复请求时回放。
func (s *server) cached(inner http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get(middleware.HeaderRequestID)
if requestID == "" || s.idem == nil {
inner.ServeHTTP(w, r)
return
}
key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID)
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return
}
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(cw, r)
if cw.status >= 200 && cw.status < 300 && cw.body.Len() > 0 {
s.idem.put(key, cw.body.Bytes())
}
}
}
func writeCached(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Idempotent-Replay", "1")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
type captureWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
}
func (w *captureWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *captureWriter) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
+108
View File
@@ -0,0 +1,108 @@
package httpapi
// 本文件是各写接口的请求体契约;字段名与 web/src/shared/types.ts 对齐。
type loginInput struct {
Username string `json:"username"`
Password string `json:"password"`
RequestID string `json:"requestId"`
}
type saveDraftPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
Results []draftResultInput `json:"results"`
RequestID string `json:"requestId"`
}
type draftResultInput struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText *string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit *string `json:"unit"`
IsTie bool `json:"isTie"`
Status string `json:"status"`
PointsOverride *float64 `json:"pointsOverride"`
Note *string `json:"note"`
}
type publishPayload struct {
EventID int64 `json:"eventId"`
CategoryID *int64 `json:"categoryId"`
RequestID string `json:"requestId"`
}
type voidResultPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type adjustmentInput struct {
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
Category string `json:"category"`
Reason string `json:"reason"`
EventID *int64 `json:"eventId"`
RequestID string `json:"requestId"`
}
type recordBreakInput struct {
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText"`
RequestID string `json:"requestId"`
}
type displayCommandInput struct {
Action string `json:"action"`
Payload any `json:"payload"`
RequestID string `json:"requestId"`
}
type voidAdjustmentPayload struct {
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type settingsPatch struct {
Event *settingsEventPatch `json:"event"`
Display *settingsDisplayPatch `json:"display"`
Standings *settingsStandingsPatch `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval *bool `json:"requireApproval"`
RequestID string `json:"requestId"`
}
type settingsEventPatch struct {
Name *string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
type settingsDisplayPatch struct {
Background *string `json:"background"`
Logo *string `json:"logo"`
Title *string `json:"title"`
Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"`
RotateSeconds *float64 `json:"rotateSeconds"`
}
type settingsStandingsPatch struct {
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
RankingMode *string `json:"rankingMode"`
ShowIndividualBoard *bool `json:"showIndividualBoard"`
}
type categoriesPayload struct {
Categories []categoryDTO `json:"categories"`
}
type channelsPayload struct {
Channels []channelDTO `json:"channels"`
}
+582
View File
@@ -0,0 +1,582 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
// 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。
func strPtr(ns sql.NullString) *string {
if !ns.Valid {
return nil
}
v := ns.String
return &v
}
func i64Ptr(ni sql.NullInt64) *int64 {
if !ni.Valid {
return nil
}
v := ni.Int64
return &v
}
func intPtr(ni sql.NullInt64) *int {
if !ni.Valid {
return nil
}
v := int(ni.Int64)
return &v
}
func f64Ptr(nf sql.NullFloat64) *float64 {
if !nf.Valid {
return nil
}
v := nf.Float64
return &v
}
func nullStr(p *string) any {
if p == nil || *p == "" {
return nil
}
return *p
}
func nullI64(p *int64) any {
if p == nil {
return nil
}
return *p
}
func nullInt(p *int) any {
if p == nil {
return nil
}
return *p
}
func nullF64(p *float64) any {
if p == nil {
return nil
}
return *p
}
func lastInsertID(res sql.Result) (int64, error) {
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("获取自增 id: %w", err)
}
return id, nil
}
// requireChanges 把「影响行数为 0」翻译成 404。
func requireChanges(res sql.Result, message string) error {
n, err := res.RowsAffected()
if err != nil {
return internal(err)
}
if n == 0 {
return notFound(message)
}
return nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed")
}
func isForeignKeyViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
}
func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) {
var one int
err := db.QueryRowContext(ctx, query, args...).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, err
}
return true, nil
}
// ---------- teams ----------
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active`
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
var (
t teamDTO
shortName, logoPath, color sql.NullString
)
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active)
t.ShortName = strPtr(shortName)
t.LogoPath = strPtr(logoPath)
t.Color = strPtr(color)
return t, err
}
func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]teamDTO, 0)
for rows.Next() {
t, err := scanTeam(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
list, err := s.listTeams(ctx)
if err != nil {
return nil, err
}
out := make(map[int64]teamDTO, len(list))
for _, t := range list {
out[t.ID] = t
}
return out, nil
}
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
t, err := scanTeam(s.ev.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return teamDTO{}, notFound("单位不存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
return t, nil
}
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
if isUniqueViolation(err) {
return teamDTO{}, conflict("单位编码已存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return teamDTO{}, internal(err)
}
return s.getTeam(ctx, id)
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
if isUniqueViolation(err) {
return teamDTO{}, conflict("单位编码已存在")
}
if err != nil {
return teamDTO{}, internal(err)
}
if err := requireChanges(res, "单位不存在"); err != nil {
return teamDTO{}, err
}
return s.getTeam(ctx, id)
}
func (s *server) deleteTeam(ctx context.Context, id int64) error {
// 业务数据软删除:置 active=0,保留历史成绩与流水。
res, err := s.ev.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "单位不存在")
}
// ---------- athletes ----------
const athleteColumns = `id, name, gender, team_id, employee_no, active`
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
var (
a athleteDTO
gender, empNo sql.NullString
teamID sql.NullInt64
)
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active)
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
if teamID.Valid {
a.TeamID = teamID.Int64
}
return a, err
}
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
query := `SELECT ` + athleteColumns + ` FROM athletes`
args := []any{}
if teamID > 0 {
query += ` WHERE team_id = ?`
args = append(args, teamID)
}
query += ` ORDER BY name`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]athleteDTO, 0)
for rows.Next() {
a, err := scanAthlete(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return athleteDTO{}, notFound("运动员不存在")
}
if err != nil {
return athleteDTO{}, internal(err)
}
return a, nil
}
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active)
if isForeignKeyViolation(err) {
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
return athleteDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return athleteDTO{}, internal(err)
}
return s.getAthlete(ctx, id)
}
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id)
if isForeignKeyViolation(err) {
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
return athleteDTO{}, internal(err)
}
if err := requireChanges(res, "运动员不存在"); err != nil {
return athleteDTO{}, err
}
return s.getAthlete(ctx, id)
}
func (s *server) deleteAthlete(ctx context.Context, id int64) error {
res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "运动员不存在")
}
// ---------- sports ----------
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) {
var (
sp sportDTO
shortName sql.NullString
defaultProfileID sql.NullInt64
)
err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active)
sp.ShortName = strPtr(shortName)
sp.DefaultProfileID = i64Ptr(defaultProfileID)
return sp, err
}
func (s *server) listSports(ctx context.Context) ([]sportDTO, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]sportDTO, 0)
for rows.Next() {
sp, err := scanSport(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, sp)
}
return out, rows.Err()
}
func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return sportDTO{}, notFound("大项不存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
return sp, nil
}
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return sportDTO{}, internal(err)
}
return s.getSport(ctx, id)
}
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
if err != nil {
return sportDTO{}, internal(err)
}
if err := requireChanges(res, "大项不存在"); err != nil {
return sportDTO{}, err
}
return s.getSport(ctx, id)
}
func (s *server) deleteSport(ctx context.Context, id int64) error {
// 大项有子项目时不允许软删,避免项目挂在不可见的大项下。
has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("大项下仍有小项,不能删除")
}
res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "大项不存在")
}
// ---------- scoring profiles ----------
func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]profileDTO, 0)
for rows.Next() {
var p profileDTO
if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil {
return nil, internal(err)
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
for i := range out {
rules, err := s.profileRules(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].Rules = rules
}
return out, nil
}
func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]profileRuleDTO, 0)
for rows.Next() {
var r profileRuleDTO
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
return nil, internal(err)
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
var p profileDTO
err := s.ev.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return profileDTO{}, notFound("积分模板不存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
rules, err := s.profileRules(ctx, p.ID)
if err != nil {
return profileDTO{}, err
}
p.Rules = rules
return p, nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return profileDTO{}, internal(err)
}
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
return profileDTO{}, err
}
if err := tx.Commit(); err != nil {
return profileDTO{}, internal(err)
}
return s.getProfile(ctx, id)
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return profileDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?,
award_ranks = ?, last_place_zero = ?, updated_at = datetime('now')
WHERE id = ?`,
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id)
if isUniqueViolation(err) {
return profileDTO{}, conflict("积分模板编码已存在")
}
if err != nil {
return profileDTO{}, internal(err)
}
if err := requireChanges(res, "积分模板不存在"); err != nil {
return profileDTO{}, err
}
if in.Rules != nil {
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
return profileDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return profileDTO{}, internal(err)
}
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
for _, r := range rules {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
profileID, r.Rank, r.Points); err != nil {
return internal(err)
}
}
return nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
+449
View File
@@ -0,0 +1,449 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"strings"
)
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
e eventDTO
scheduledAt, venue, note sql.NullString
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note)
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
return e, err
}
func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) {
query := `SELECT ` + eventColumns + ` FROM events`
args := []any{}
if sportID > 0 {
query += ` WHERE sport_id = ?`
args = append(args, sportID)
}
query += ` ORDER BY sort_order, code`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]eventDTO, 0)
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
e, err := scanEvent(s.ev.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
if err != nil {
return eventDTO{}, internal(err)
}
cats, err := s.listCategories(ctx, id)
if err != nil {
return eventDTO{}, err
}
chs, err := s.listChannels(ctx, id)
if err != nil {
return eventDTO{}, err
}
e.Categories = cats
e.Channels = chs
return e, nil
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark))
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
return s.getEvent(ctx, id)
}
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("小项已有报名数据,不能删除")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
`DELETE FROM events WHERE id = ?`,
} {
if _, err := tx.ExecContext(ctx, stmt, id); err != nil {
return internal(err)
}
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
func multiplierOrOne(v float64) float64 {
if v <= 0 {
return 1
}
return v
}
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]categoryDTO, 0)
for rows.Next() {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
out = append(out, c)
}
return out, rows.Err()
}
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
// 否则外键会把历史报名项一并带走。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
WHERE c.event_id = ? LIMIT 1`, eventID)
if err != nil {
return nil, internal(err)
}
if has {
return nil, conflict("该项目已有报名数据,不能整体替换组别")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if strings.TrimSpace(c.Name) == "" {
return nil, badRequest("组别 name 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("组别名称重复:" + c.Name)
}
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listCategories(ctx, eventID)
}
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]channelDTO, 0)
for rows.Next() {
var c channelDTO
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
return nil, internal(err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if c.Scope != "athlete" && c.Scope != "team" {
return nil, badRequest("通道 scope 只能是 athlete/team", i)
}
switch c.Basis {
case "result", "manual", "aggregate":
default:
return nil, badRequest("通道 basis 非法", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
if isForeignKeyViolation(err) {
return nil, badRequest("profileId 不存在", i)
}
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listChannels(ctx, eventID)
}
// ---------- entries ----------
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var (
e entryDTO
athleteID sql.NullInt64
memberNames sql.NullString
bib, seed, rem sql.NullString
athleteName sql.NullString
teamCode sql.NullString
teamName sql.NullString
shortName sql.NullString
categoryName sql.NullString
)
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
return e, err
}
e.AthleteID = i64Ptr(athleteID)
e.MemberNames = parseNames(memberNames)
e.Bib = strPtr(bib)
e.Seed = strPtr(seed)
e.Remark = strPtr(rem)
e.AthleteName = strPtr(athleteName)
e.TeamCode = teamCode.String
e.TeamName = teamName.String
e.ShortName = strPtr(shortName)
e.CategoryName = categoryName.String
return e, nil
}
const entrySelect = `SELECT ` + entryColumns + `
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
JOIN event_categories c ON c.id = e.category_id`
func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) {
rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]entryDTO, 0)
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。
func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) {
entries, err := s.listEntries(ctx, eventID)
if err != nil {
return nil, err
}
results, err := s.resultsByEvent(ctx, eventID)
if err != nil {
return nil, err
}
byEntry := make(map[int64]resultDTO, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
out := make([]entryWithResultDTO, 0, len(entries))
for _, e := range entries {
item := entryWithResultDTO{entryDTO: e}
if r, ok := byEntry[e.ID]; ok {
cp := r
item.Result = &cp
}
out = append(out, item)
}
return out, nil
}
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return entryDTO{}, notFound("报名项不存在")
}
if err != nil {
return entryDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark))
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, id)
}
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`,
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
if err := requireChanges(res, "报名项不存在"); err != nil {
return entryDTO{}, err
}
return s.getEntry(ctx, id)
}
func (s *server) deleteEntry(ctx context.Context, id int64) error {
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "报名项不存在")
}
+52
View File
@@ -0,0 +1,52 @@
package httpapi
import (
"context"
"database/sql"
"errors"
)
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
var id int64
if err := s.ev.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
var existing int64
var err error
if in.AthleteID != nil {
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing)
} else {
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
in.CategoryID, in.TeamID).Scan(&existing)
}
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil {
return false, err
}
return true, nil
case err != nil:
return false, err
default:
if _, err := s.ev.ExecContext(ctx,
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`,
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed),
orDefault(in.Status, "registered"), existing); err != nil {
return false, err
}
return false, nil
}
}
+760
View File
@@ -0,0 +1,760 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- settings ----------
func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) {
rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, internal(err)
}
out[k] = v
}
return out, rows.Err()
}
func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return eventSettingsDTO{}, err
}
var out eventSettingsDTO
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
out.Event.Host = nilIfEmpty(values["event.organizer"])
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
out.Display.Background = nilIfEmpty(values["display.background"])
out.Display.Logo = nilIfEmpty(values["display.logo"])
if out.Display.Logo == nil {
out.Display.Logo = out.Event.LogoPath
}
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
if out.Display.Subtitle == nil {
out.Display.Subtitle = out.Event.Subtitle
}
out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
out.Standings.ShowIndividualBoard = boolDefault(
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
out.RequireApproval = boolDefault(values["results.review_required"], false)
if code := values["scoring.default_profile"]; code != "" {
var id int64
err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
case err != nil:
return eventSettingsDTO{}, internal(err)
default:
out.DefaultProfileID = &id
}
}
return out, nil
}
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventSettingsDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
set := func(key, value string) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
key, value)
if err != nil {
return internal(err)
}
return nil
}
setPtr := func(key string, v *string) error {
if v == nil {
return nil
}
return set(key, *v)
}
if p := patch.Event; p != nil {
if err := setPtr("event.name", p.Name); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.organizer", p.Host); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
}
}
if p := patch.Display; p != nil {
if err := setPtr("display.background", p.Background); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.logo", p.Logo); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.title", p.Title); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
return eventSettingsDTO{}, err
}
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
return eventSettingsDTO{}, err
}
if p.RotateSeconds != nil {
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
}
}
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return eventSettingsDTO{}, err
}
}
if err := setPtr("standings.mode", p.RankingMode); err != nil {
return eventSettingsDTO{}, err
}
if p.ShowIndividualBoard != nil {
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
return eventSettingsDTO{}, err
}
}
}
if patch.DefaultProfileID != nil {
code := ""
if *patch.DefaultProfileID != 0 {
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil)
}
return eventSettingsDTO{}, internal(err)
}
}
if err := set("scoring.default_profile", code); err != nil {
return eventSettingsDTO{}, err
}
}
if patch.RequireApproval != nil {
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
return eventSettingsDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return eventSettingsDTO{}, internal(err)
}
return s.getSettings(ctx)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func atoiDefault(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return def
}
return n
}
func boolDefault(v string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return def
}
}
// ---------- standings ----------
func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) {
teams, err := s.domainTeams(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
athletes, err := s.domainAthletes(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
ledger, err := s.activeLedger(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
ranked, err := s.buildRanked(ctx)
if err != nil {
return domain.StandingsInput{}, err
}
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return domain.StandingsInput{}, err
}
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
exclude := !boolDefault(values["standings.medal_counts_team_events"], true)
return domain.StandingsInput{
Teams: teams,
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Team, 0)
for rows.Next() {
var (
t domain.Team
shortName sql.NullString
color sql.NullString
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil {
return nil, internal(err)
}
t.ShortName = shortName.String
t.Color = color.String
out = append(out, t)
}
return out, rows.Err()
}
func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Athlete, 0)
for rows.Next() {
var (
a domain.Athlete
gender sql.NullString
teamID sql.NullInt64
)
if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil {
return nil, internal(err)
}
a.Gender = gender.String
a.TeamID = teamID.Int64
out = append(out, a)
}
return out, rows.Err()
}
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。
func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RankedResult, 0)
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, internal(err)
}
rr.Rank = rank
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
}
return out, rows.Err()
}
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
team, err := s.getTeam(ctx, teamID)
if err != nil {
return teamBreakdownDTO{}, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
FROM score_ledger l
LEFT JOIN events ev ON ev.id = l.event_id
LEFT JOIN entries e ON e.id = l.entry_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN results r ON r.id = l.result_id
WHERE l.team_id = ? AND l.voided = 0
ORDER BY l.id DESC`, teamID)
if err != nil {
return teamBreakdownDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
for rows.Next() {
var (
item breakdownItemDTO
eventID, athleteID sql.NullInt64
eventCode, eventName sql.NullString
categoryName, athleteName sql.NullString
rank sql.NullInt64
resultText sql.NullString
)
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
return teamBreakdownDTO{}, internal(err)
}
item.EventID = i64Ptr(eventID)
item.EventCode = strPtr(eventCode)
item.EventName = strPtr(eventName)
item.CategoryName = strPtr(categoryName)
item.AthleteID = i64Ptr(athleteID)
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
return out, rows.Err()
}
// ---------- records ----------
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
holder_name, holder_team_id, achieved_at, is_current, source
FROM records WHERE is_current = 1`
args := []any{}
if eventID > 0 {
query += ` AND event_id = ?`
args = append(args, eventID)
}
query += ` ORDER BY event_id, level`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]recordEntryDTO, 0)
for rows.Next() {
var (
r recordEntryDTO
holderName sql.NullString
holderTeamID sql.NullInt64
achievedAt sql.NullString
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
return nil, internal(err)
}
r.HolderName = strPtr(holderName)
r.HolderTeamID = i64Ptr(holderTeamID)
r.AchievedAt = strPtr(achievedAt)
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) {
if in.EventID == 0 || in.EntryID == 0 || in.Level == "" {
return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil)
}
switch in.Level {
case "meet", "city", "province", "national":
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText)
if isForeignKeyViolation(err) {
return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil)
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return recordBreakDTO{}, internal(err)
}
return s.getRecordBreak(ctx, id)
}
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var (
b recordBreakDTO
value sql.NullFloat64
text sql.NullString
approvedBy sql.NullInt64
approvedAt sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
FROM record_breaks WHERE id = ?`, id).
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
if err != nil {
return recordBreakDTO{}, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
b.ApprovedBy = i64Ptr(approvedBy)
b.ApprovedAt = strPtr(approvedAt)
return b, nil
}
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return recordBreakDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var state string
var eventID int64
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
return recordBreakDTO{}, internal(err)
}
if state != "pending" {
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return recordBreakDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
return recordBreakDTO{}, err
}
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordBreakDTO{}, internal(err)
}
s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"})
return s.getRecordBreak(ctx, id)
}
// ---------- adjustments ----------
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if teamID > 0 {
where = append(where, "team_id = ?")
args = append(args, teamID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]adjustmentDTO, 0)
for rows.Next() {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[adjustmentDTO]{}, internal(err)
}
return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return adjustmentDTO{}, err
}
if !boolDefault(values["manual_adjustment.enabled"], false) {
return adjustmentDTO{}, stateInvalid("人工调整当前未启用")
}
if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" {
return adjustmentDTO{}, badRequest("reason 必填", nil)
}
switch in.Category {
case "penalty", "bonus", "correction":
default:
return adjustmentDTO{}, badRequest("category 非法", in.Category)
}
if in.TeamID == 0 {
return adjustmentDTO{}, badRequest("teamId 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID))
if isForeignKeyViolation(err) {
return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil)
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by)
VALUES (?, ?, 'adjustment', ?, ?, ?)`,
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.create", "adjustment", id, nil, in)
return s.getAdjustment(ctx, id)
}
func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) {
var (
a adjustmentDTO
eventID, createdBy sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
FROM point_adjustments WHERE id = ?`, id).
Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
if err != nil {
return adjustmentDTO{}, internal(err)
}
a.EventID = i64Ptr(eventID)
a.CreatedBy = i64Ptr(createdBy)
return a, nil
}
func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return adjustmentDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var voided bool
if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return adjustmentDTO{}, notFound("人工调整不存在")
}
return adjustmentDTO{}, internal(err)
}
if voided {
return adjustmentDTO{}, stateInvalid("该人工调整已作废")
}
if _, err := tx.ExecContext(ctx,
`UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`,
nullID(userID), id); err != nil {
return adjustmentDTO{}, internal(err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`,
nullID(userID), reason, id); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason})
return s.getAdjustment(ctx, id)
}
// ---------- audit logs ----------
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if entityType != "" {
where = append(where, "entity_type = ?")
args = append(args, entityType)
}
if userID > 0 {
where = append(where, "user_id = ?")
args = append(args, userID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at
FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]auditLogDTO, 0)
for rows.Next() {
var (
a auditLogDTO
uid, eid sql.NullInt64
before sql.NullString
after sql.NullString
ip, reqID sql.NullString
)
if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
a.UserID = i64Ptr(uid)
a.EntityID = i64Ptr(eid)
a.BeforeJSON = strPtr(before)
a.AfterJSON = strPtr(after)
a.IP = strPtr(ip)
a.RequestID = strPtr(reqID)
items = append(items, a)
}
if err := rows.Err(); err != nil {
return pageResult[auditLogDTO]{}, internal(err)
}
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// ---------- backup ----------
// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
if strings.TrimSpace(s.dataDir) == "" {
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
}
dir := filepath.Join(s.dataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return backupResultDTO{}, internal(err)
}
now := time.Now()
stamp := now.Format("20060102-150405")
path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp))
if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
return backupResultDTO{}, internal(err)
}
eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp))
if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil {
return backupResultDTO{}, internal(err)
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path})
return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil
}
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成员姓名 JSON 数组 ----------
func parseNames(ns sql.NullString) []string {
if !ns.Valid || ns.String == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(ns.String), &out); err != nil {
return nil
}
return out
}
func marshalNames(names []string) any {
if len(names) == 0 {
return nil
}
raw, err := json.Marshal(names)
if err != nil {
return nil
}
return string(raw)
}
// ---------- results ----------
const resultColumns = `id, event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, verified_by, verified_at, note`
func scanResult(row interface{ Scan(...any) error }) (resultDTO, error) {
var (
r resultDTO
rank sql.NullInt64
text, unit sql.NullString
value, override sql.NullFloat64
recordedBy, verifiedBy sql.NullInt64
recordedAt, verifiedAt, note sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie,
&override, &r.Status, &r.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note); err != nil {
return r, err
}
r.Rank = intPtr(rank)
r.ResultText = text.String
r.ResultValue = f64Ptr(value)
r.Unit = unit.String
r.PointsOverride = f64Ptr(override)
r.RecordedBy = i64Ptr(recordedBy)
r.RecordedAt = strPtr(recordedAt)
r.VerifiedBy = i64Ptr(verifiedBy)
r.VerifiedAt = strPtr(verifiedAt)
r.Note = strPtr(note)
return r, nil
}
func (s *server) resultsByEvent(ctx context.Context, eventID int64) ([]resultDTO, error) {
return queryResults(ctx, s.ev, `SELECT `+resultColumns+` FROM results WHERE event_id = ? ORDER BY id`, eventID)
}
func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]resultDTO, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]resultDTO, 0)
for rows.Next() {
r, err := scanResult(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
r, err := scanResult(s.ev.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, internal(err)
}
return r, nil
}
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if eventID > 0 {
where = append(where, "event_id = ?")
args = append(args, eventID)
}
if state != "" {
where = append(where, "state = ?")
args = append(args, state)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM results`+clause, args...).Scan(&total); err != nil {
return pageResult[resultDTO]{}, internal(err)
}
query := `SELECT ` + resultColumns + ` FROM results` + clause + ` ORDER BY id LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
items, err := queryResults(ctx, s.ev, query, args...)
if err != nil {
return pageResult[resultDTO]{}, err
}
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
for i, it := range items {
if it.EntryID == 0 {
return nil, badRequest("results[].entryId 必填", i)
}
var entryEvent int64
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("entryId 不存在", it.EntryID)
}
return nil, internal(err)
}
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, datetime('now'), ?, datetime('now'))
ON CONFLICT(entry_id) DO UPDATE SET
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = excluded.unit,
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
note = excluded.note,
recorded_by = excluded.recorded_by,
recorded_at = excluded.recorded_at,
updated_at = datetime('now')`,
eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"),
nullID(userID), nullStr(it.Note)); err != nil {
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.resultsByEvent(ctx, eventID)
}
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " AND "
}
out += p
}
return out
}
// ---------- ledger ----------
func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
}
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.LedgerEntry, 0)
for rows.Next() {
var (
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
return nil, internal(err)
}
l.AthleteID = athlete.Int64
l.EventID = event.Int64
l.EntryID = entry.Int64
l.ResultID = result.Int64
l.CreatedAt = created.String
out = append(out, l)
}
return out, rows.Err()
}
+193
View File
@@ -0,0 +1,193 @@
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// apiError 是可直接映射为统一响应结构的业务错误(见 devdocs/05 第 8 节错误码)。
type apiError struct {
status int
code string
message string
detail any
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
func badRequest(message string, detail any) *apiError {
return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail}
}
func notFound(message string) *apiError {
return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message}
}
func conflict(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message}
}
func stateInvalid(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message}
}
func internal(err error) *apiError {
return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()}
}
func notImplemented(message string) *apiError {
return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message}
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
type handler func(w http.ResponseWriter, r *http.Request) (any, error)
// handle 统一把业务结果/错误写成 REST 响应。
func (s *server) handle(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := fn(w, r)
if err != nil {
if errors.Is(err, errHandled) {
return
}
writeAPIError(w, s.logger, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}
}
// handleVoid 用于无返回体的写操作。
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeAPIError(w, s.logger, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败", "code", api.code, "err", api.message)
}
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": api.code,
"message": api.message,
},
}
if api.detail != nil {
body["error"].(map[string]any)["detail"] = api.detail
}
writeJSON(w, api.status, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
}
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
func pageQuery(r *http.Request) (page, size int) {
page = intQuery(r, "page", 1)
size = intQuery(r, "size", 50)
if page < 1 {
page = 1
}
switch {
case size < 1:
size = 50
case size > 500:
size = 500
}
return page, size
}
func intQuery(r *http.Request, key string, def int) int {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
return n
}
func int64Query(r *http.Request, key string) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// pathID 解析路由通配符 {id}。
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return 0, badRequest("路径参数 "+name+" 非法", raw)
}
return n, nil
}
// pageResult 是分页响应体(见 devdocs/05 第 6 节)。
type pageResult[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// mapDomainError 把 domain 引擎的哨兵错误映射为 REST 错误码。
func mapDomainError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrManualAdjustmentDisabled),
errors.Is(err, domain.ErrAdjustmentReasonRequired),
errors.Is(err, domain.ErrCompareRequired),
errors.Is(err, domain.ErrUnknownBasis),
errors.Is(err, domain.ErrUnknownLedgerOp):
return stateInvalid(err.Error())
case errors.Is(err, domain.ErrProfileNotFound):
return badRequest("计分通道引用的积分模板不存在", err.Error())
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
return stateInvalid("该积分流水已作废,不能重复作废")
case errors.Is(err, domain.ErrLedgerNotFound):
return notFound("积分流水不存在")
default:
return internal(err)
}
}
-107
View File
@@ -1,107 +0,0 @@
// Package httpapi 提供 HTTP 接口与静态资源托管。
package httpapi
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": opts.Version,
})
})
assets, err := webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "前端资源不可用", http.StatusServiceUnavailable)
})
} else {
mux.Handle("/", staticHandler(assets))
}
return withRequestLog(mux, logger)
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Debug("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"dur", time.Since(start),
)
})
}
+109
View File
@@ -0,0 +1,109 @@
package httpapi
import (
"context"
"io"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// rulesRoutes 挂载规则包的导出与导入(导入支持 ?preview=1 只预览差异)。
func (s *server) rulesRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
}
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
pack, err := rules.Export(s.ev, rules.Meta{})
if err != nil {
return nil, internal(err)
}
body, err := rules.Marshal(pack)
if err != nil {
return nil, internal(err)
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="rules.yaml"`)
if _, err := w.Write(body); err != nil {
s.logger.Error("规则包写出失败", "err", err)
}
return nil, errHandled
}
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
return nil, badRequest("读取请求体失败", nil)
}
if len(body) == 0 {
return nil, badRequest("请求体为空", nil)
}
pack, err := rules.Parse(body)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
cur, err := rules.Load(s.ev)
if err != nil {
return nil, internal(err)
}
cs, err := rules.Diff(cur, pack)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
return cs, nil
}
res, err := rules.Apply(s.ev, cs)
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分必须全量重算。
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
return nil, internal(err)
}
return res, nil
}
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
for _, eventID := range ids {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+642
View File
@@ -0,0 +1,642 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。
type queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// ---------- 加载:DB 行 → domain 输入 ----------
func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, error) {
var (
e domain.Event
scheduledAt sql.NullString
venue, remark sql.NullString
)
err := q.QueryRowContext(ctx,
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark
FROM events WHERE id = ?`, eventID).
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark)
if errors.Is(err, sql.ErrNoRows) {
return domain.Event{}, notFound("小项不存在")
}
if err != nil {
return domain.Event{}, internal(err)
}
e.ScheduledAt = scheduledAt.String
e.Venue = venue.String
e.Remark = remark.String
return e, nil
}
func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.EventCategory, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.EventCategory, 0)
for rows.Next() {
var (
c domain.EventCategory
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = gender.String
if ageMin.Valid {
v := int(ageMin.Int64)
c.AgeMin = &v
}
if ageMax.Valid {
v := int(ageMax.Int64)
c.AgeMax = &v
}
out = append(out, c)
}
return out, rows.Err()
}
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.ScoreChannel, 0)
for rows.Next() {
var c domain.ScoreChannel
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
return nil, internal(err)
}
out = append(out, c)
}
return out, rows.Err()
}
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
out := make(map[int64]domain.ScoringProfile, len(ids))
for id := range ids {
var p domain.ScoringProfile
err := q.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("积分模板不存在", id)
}
if err != nil {
return nil, internal(err)
}
rows, err := q.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id)
if err != nil {
return nil, internal(err)
}
rules := make([]domain.ScoringRule, 0)
for rows.Next() {
var r domain.ScoringRule
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
_ = rows.Close()
return nil, internal(err)
}
rules = append(rules, r)
}
err = rows.Err()
_ = rows.Close()
if err != nil {
return nil, internal(err)
}
p.Rules = rules
out[id] = p
}
return out, nil
}
func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) {
query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.status, COALESCE(a.name, '')
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE e.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY e.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Entry, 0)
for rows.Next() {
var (
e domain.Entry
athleteID sql.NullInt64
memberNames sql.NullString
)
if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil {
return nil, internal(err)
}
if athleteID.Valid {
e.AthleteID = athleteID.Int64
}
e.MemberNames = parseNames(memberNames)
out = append(out, e)
}
return out, rows.Err()
}
func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) {
query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit,
r.is_tie, r.points_override, r.status, r.state, r.note
FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY r.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Result, 0)
for rows.Next() {
var (
r domain.Result
rank sql.NullInt64
text, unit, note sql.NullString
value, override sql.NullFloat64
)
if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit,
&r.IsTie, &override, &r.Status, &r.State, &note); err != nil {
return nil, internal(err)
}
if rank.Valid {
r.Rank = int(rank.Int64)
}
r.ResultText = text.String
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.Unit = unit.String
if override.Valid {
v := override.Float64
r.PointsOverride = &v
}
r.Note = note.String
out = append(out, r)
}
return out, rows.Err()
}
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax}
values, err := loadSettingsMap(ctx, q)
if err != nil {
return cfg, err
}
cfg.Enabled = values["record_bonus.enabled"] == "true"
if values["record_bonus.mode"] == string(domain.RecordBonusSum) {
cfg.Mode = domain.RecordBonusSum
}
return cfg, nil
}
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state
FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RecordBreak, 0)
for rows.Next() {
var (
b domain.RecordBreak
value sql.NullFloat64
text sql.NullString
)
if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil {
return nil, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
out = append(out, b)
}
return out, rows.Err()
}
// ---------- 计分 ----------
// computeEventScore 计算某小项(或某组别)的流水与名次明细。
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
ev, err := loadEventQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
channels, err := loadChannelsQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
profileIDs := make(map[int64]bool)
for _, ch := range channels {
profileIDs[ch.ProfileID] = true
}
profiles, err := loadProfilesQ(ctx, q, profileIDs)
if err != nil {
return domain.EventScore{}, err
}
entries, err := loadEntriesQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
results, err := loadResultsQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
bonusCfg, err := loadRecordBonusQ(ctx, q)
if err != nil {
return domain.EventScore{}, err
}
breaks, err := loadRecordBreaksQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
var total domain.EventScore
for _, cat := range cats {
if categoryID > 0 && cat.ID != categoryID {
continue
}
score, err := domain.ScoreEvent(domain.EventScoreInput{
Event: ev,
Category: cat,
Channels: channels,
Profiles: profiles,
Entries: entries,
Results: results,
RecordBonus: &bonusCfg,
RecordBreaks: breaks,
})
if err != nil {
return domain.EventScore{}, mapDomainError(err)
}
total.Ledger = append(total.Ledger, score.Ledger...)
total.Ranked = append(total.Ranked, score.Ranked...)
}
return total, nil
}
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
scope := `source_type = 'result' AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, categoryID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND `+scope,
append([]any{nullID(actorID), reason}, args...)...); err != nil {
return domain.EventScore{}, internal(err)
}
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
for _, l := range score.Ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
return domain.EventScore{}, internal(err)
}
}
return score, nil
}
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
cur, ok := best[rr.ResultID]
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
best[rr.ResultID] = rr
}
}
for id, rr := range best {
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = ? WHERE id = ?`, rr.Rank, id); err != nil {
return internal(err)
}
}
return nil
}
// ---------- 发布 / 撤销 / 作废 ----------
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
updated_at = datetime('now')
WHERE event_id = ?`
args := []any{nullID(userID), in.EventID}
if catID > 0 {
setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil {
return nil, internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.publish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
WHERE event_id = ? AND state = 'published'`
args := []any{in.EventID}
if catID > 0 {
query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.unpublish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
before resultDTO
eventID int64
catID int64
)
row := tx.QueryRowContext(ctx,
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
var (
rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64
)
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
return resultDTO{}, internal(err)
}
before.EventID = eventID
before.Rank = intPtr(rank)
before.ResultText = text.String
before.ResultValue = f64Ptr(value)
before.Unit = unit.String
before.PointsOverride = f64Ptr(override)
before.RecordedBy = i64Ptr(recordedBy)
before.RecordedAt = strPtr(recordedAt)
before.VerifiedBy = i64Ptr(verifiedBy)
before.VerifiedAt = strPtr(verifiedAt)
before.Note = strPtr(note)
if _, err := tx.ExecContext(ctx,
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
return resultDTO{}, err
}
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
if err != nil {
return resultDTO{}, internal(err)
}
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
return resultDTO{}, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil {
return resultDTO{}, err
}
if err := tx.Commit(); err != nil {
return resultDTO{}, internal(err)
}
s.audit(ctx, "result.void", "result", resultID, before, after)
return s.getResult(ctx, resultID)
}
// ---------- 变更日志与版本 ----------
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
snapshot, err := json.Marshal(results)
if err != nil {
return internal(err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, '[]', ?, ?)`,
eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
}
return nil
}
func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error {
for _, item := range items {
payload, err := json.Marshal(item)
if err != nil {
return internal(err)
}
if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil {
return err
}
}
return nil
}
// bumpVersion 写一条 change_log;id 即实时协议的 version。
func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error {
raw := []byte("{}")
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return internal(err)
}
raw = b
}
var eid any
if entityID != 0 {
eid = entityID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id)
VALUES (?, ?, ?, ?, ?)`,
entityType, eid, action, string(raw), nullID(actorID)); err != nil {
return internal(err)
}
return nil
}
// audit 登记审计(由 middleware.Auditor 在请求成功后落库)。
func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) {
middleware.Record(ctx, middleware.AuditEntry{
Action: action,
EntityType: entityType,
EntityID: entityID,
Before: before,
After: after,
})
}
+251
View File
@@ -0,0 +1,251 @@
// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。
package httpapi
import (
"database/sql"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。
type DisplaySender interface {
SendDisplayCommand(action string, payload any) error
}
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
// System 是系统库连接(users/sessions)。
System *sql.DB
// Event 是赛事库连接(teams/events/results/ledger/audit…)。
Event *sql.DB
// Auth 可选;为空时用 System 自动构建。
Auth *auth.Service
// DataDir 用于备份目录 data/backups。
DataDir string
// Display 可选,实时层接入后传入。
Display DisplaySender
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
WS http.Handler
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
type server struct {
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
display DisplaySender
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
svc := opts.Auth
if svc == nil {
svc = auth.New(opts.System, auth.Options{Logger: logger})
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
display: opts.Display,
idem: newIdempotencyStore(10 * time.Minute),
}
mux := http.NewServeMux()
s.routes(mux)
s.exportRoutes(mux)
s.rulesRoutes(mux)
if opts.WS != nil {
mux.Handle("/ws", opts.WS)
}
assets := opts.WebFS
if assets == nil {
var err error
assets, err = webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
}
}
if assets != nil {
mux.Handle("/", staticHandler(assets))
}
var h http.Handler = mux
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.Authenticate(svc)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
return h
}
func middlewareUserID(r *http.Request) int64 {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return 0
}
return u.ID
}
// public 注册公开接口(guest 可读)。
func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version})
})
// 认证
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
// 系统配置(guest 一律 403)
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
mux.HandleFunc("GET /api/sports/{id}", s.public(s.getSportHandler))
mux.HandleFunc("PUT /api/sports/{id}", s.write(auth.RoleRecorder, s.updateSportHandler))
mux.HandleFunc("DELETE /api/sports/{id}", s.writeVoid(auth.RoleRecorder, s.deleteSportHandler))
mux.HandleFunc("GET /api/events", s.public(s.listEventsHandler))
mux.HandleFunc("POST /api/events", s.write(auth.RoleRecorder, s.createEventHandler))
mux.HandleFunc("GET /api/events/{id}", s.public(s.getEventHandler))
mux.HandleFunc("PUT /api/events/{id}", s.write(auth.RoleRecorder, s.updateEventHandler))
mux.HandleFunc("DELETE /api/events/{id}", s.writeVoid(auth.RoleRecorder, s.deleteEventHandler))
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
mux.HandleFunc("GET /api/scoring-profiles/{id}", s.public(s.getProfileHandler))
mux.HandleFunc("PUT /api/scoring-profiles/{id}", s.write(auth.RoleRecorder, s.updateProfileHandler))
mux.HandleFunc("GET /api/athletes", s.public(s.listAthletesHandler))
mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler))
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 积分与排名
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
mux.HandleFunc("GET /api/standings/athletes", s.public(s.athleteStandingsHandler))
mux.HandleFunc("GET /api/standings/teams/{id}/breakdown", s.public(s.breakdownHandler))
mux.HandleFunc("GET /api/adjustments", s.public(s.listAdjustmentsHandler))
mux.HandleFunc("POST /api/adjustments", s.write(auth.RoleRecorder, s.createAdjustmentHandler))
mux.HandleFunc("POST /api/adjustments/{id}/void", s.write(auth.RoleRecorder, s.voidAdjustmentHandler))
// 纪录
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入"))
}
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}
+195
View File
@@ -0,0 +1,195 @@
package httpapi_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。
type testEnv struct {
t *testing.T
server *httptest.Server
sys *sql.DB
ev *sql.DB
auth *auth.Service
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db")
if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
ev.SetMaxOpenConns(1)
t.Cleanup(func() { _ = ev.Close() })
if err := store.MigrateEvent(ev); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
handler := httpapi.New(httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
}
func (e *testEnv) createUser(username, password, role string) int64 {
e.t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
e.t.Fatalf("生成密码哈希: %v", err)
}
res, err := e.sys.Exec(
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role)
if err != nil {
e.t.Fatalf("创建用户 %s: %v", username, err)
}
id, _ := res.LastInsertId()
return id
}
func (e *testEnv) client() (*http.Client, *cookiejar.Jar) {
e.t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
e.t.Fatalf("创建 cookie jar: %v", err)
}
return &http.Client{Jar: jar}, jar
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiErrorBody `json:"error"`
}
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
}
// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。
func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) {
return e.doH(client, method, path, body, csrf, nil)
}
// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。
func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) {
e.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
e.t.Fatalf("序列化请求体: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, e.server.URL+path, reader)
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if csrf {
token := e.csrfToken(client)
if token == "" {
e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path)
}
req.Header.Set("X-CSRF-Token", token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw)
}
}
return resp.StatusCode, env
}
func (e *testEnv) csrfToken(client *http.Client) string {
u, err := url.Parse(e.server.URL)
if err != nil {
e.t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == auth.CookieCSRF {
return c.Value
}
}
return ""
}
func (e *testEnv) login(client *http.Client, username, password string) envelope {
e.t.Helper()
status, env := e.do(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": username, "password": password}, false)
if status != http.StatusOK || !env.OK {
e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error)
}
return env
}
func decodeData[T any](t *testing.T, env envelope) T {
t.Helper()
var out T
if err := json.Unmarshal(env.Data, &out); err != nil {
t.Fatalf("解析 data: %v (%s)", err, env.Data)
}
return out
}
+104
View File
@@ -0,0 +1,104 @@
package middleware
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// AuditEntry 是一条待落库的审计记录;Before/After 为任意可 JSON 序列化的快照。
type AuditEntry struct {
// Action 用点号分层,关键动作单独成段,如 result.publish / result.void / user.create。
Action string
EntityType string
EntityID int64
Before any
After any
}
type auditCollector struct {
entries []AuditEntry
}
// Record 由 handler 调用,登记一条审计;仅在请求成功(2xx)时落库。
func Record(ctx context.Context, e AuditEntry) {
if c, ok := ctx.Value(ctxKeyAudit).(*auditCollector); ok {
c.entries = append(c.entries, e)
}
}
// Auditor 把成功写操作的审计记录写入赛事库 audit_logs(只追加、不修改)。
type Auditor struct {
db *sql.DB
log *slog.Logger
}
// NewAuditor 创建审计中间件;db 为赛事库(event.db)。
func NewAuditor(db *sql.DB, logger *slog.Logger) *Auditor {
if logger == nil {
logger = slog.Default()
}
return &Auditor{db: db, log: logger}
}
// Middleware 收集 handler 登记的审计并在成功后落库。
func (a *Auditor) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
c := &auditCollector{}
ctx := context.WithValue(r.Context(), ctxKeyAudit, c)
next.ServeHTTP(sw, r.WithContext(ctx))
if sw.status >= http.StatusBadRequest || len(c.entries) == 0 {
return
}
a.flush(ctx, r, c.entries)
})
}
func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEntry) {
if a.db == nil {
return
}
u := User(ctx)
requestID := GetRequestID(ctx)
ip := ClientIP(r)
for _, e := range entries {
var entityID any
if e.EntityID != 0 {
entityID = e.EntityID
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nullableID(u), e.Action, e.EntityType, entityID,
marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID)
if err != nil {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID)
}
}
}
func nullableID(u *auth.User) any {
if u == nil || u.Role == auth.RoleGuest || u.ID == 0 {
return nil
}
return u.ID
}
func marshalOrNil(v any) any {
if v == nil {
return nil
}
raw, err := json.Marshal(v)
if err != nil {
return nil
}
return string(raw)
}
+57
View File
@@ -0,0 +1,57 @@
// Package middleware 提供请求日志、错误恢复、认证/授权与审计等 HTTP 中间件。
package middleware
import (
"context"
"net"
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
type ctxKey int
const (
ctxKeyUser ctxKey = iota
ctxKeyRequestID
ctxKeyAudit
)
// User 返回当前请求的身份;未经 Authenticate 中间件时是 guest。
func User(ctx context.Context) *auth.User {
if u, ok := ctx.Value(ctxKeyUser).(*auth.User); ok {
return u
}
return &auth.User{Role: auth.RoleGuest}
}
// WithUser 把身份写入 context,供测试或非标准链路使用。
func WithUser(ctx context.Context, u *auth.User) context.Context {
return context.WithValue(ctx, ctxKeyUser, u)
}
// GetRequestID 返回本次请求的 request_id。
func GetRequestID(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyRequestID).(string); ok {
return v
}
return ""
}
// ClientIP 从代理头或连接地址解析客户端 IP。
func ClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
return first
}
}
if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" {
return rip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+232
View File
@@ -0,0 +1,232 @@
package middleware
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net"
"net/http"
"runtime/debug"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
const HeaderRequestID = "X-Request-Id"
// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(HeaderRequestID)
if id == "" {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err == nil {
id = hex.EncodeToString(buf)
} else {
id = time.Now().UTC().Format("20060102150405.000000")
}
}
w.Header().Set(HeaderRequestID, id)
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
})
}
func contextWithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKeyRequestID, id)
}
// Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("请求处理 panic",
"err", rec,
"path", r.URL.Path,
"requestId", GetRequestID(r.Context()),
"stack", string(debug.Stack()),
)
writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil)
}
}()
next.ServeHTTP(w, r)
})
}
}
// Log 记录访问日志(方法、路径、状态、耗时、request_id)。
func Log(logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger.Debug("http",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"dur", time.Since(start),
"requestId", GetRequestID(r.Context()),
"ip", ClientIP(r),
)
})
}
}
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &auth.User{Role: auth.RoleGuest}
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
u = found
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
})
}
}
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !User(r.Context()).Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest {
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
return
}
if !u.Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if User(r.Context()).Role == auth.RoleGuest {
// 未登录请求由 RequireWrite 处理,无需 CSRF。
next.ServeHTTP(w, r)
return
}
var cookie string
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
cookie = c.Value
}
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
return
}
next.ServeHTTP(w, r)
})
}
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
u := User(r.Context())
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// statusWriter 记录响应状态码,供日志与审计判断是否成功。
type statusWriter struct {
http.ResponseWriter
status int
written bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.written {
w.status = code
w.written = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if !w.written {
w.written = true
}
return w.ResponseWriter.Write(b)
}
// Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker,
// 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return hj.Hijack()
}
// Flush 透传,保证流式响应不被缓冲。
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": code,
"message": message,
},
}
if detail != nil {
body["error"].(map[string]any)["detail"] = detail
}
_ = json.NewEncoder(w).Encode(body)
}
+107
View File
@@ -0,0 +1,107 @@
package realtime
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
var v sql.NullInt64
if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
return 0, fmt.Errorf("读取 change_log 版本号: %w", err)
}
if !v.Valid {
return 0, nil
}
return v.Int64, nil
}
// currentVersionCached 在数据库不可用时退回内存里的版本,保证心跳与广播不会因一次查询失败而中断。
func (h *Hub) currentVersionCached(ctx context.Context) int64 {
if h.db == nil {
return h.Version()
}
v, err := h.currentVersion(ctx)
if err != nil {
h.log.Warn("读取版本号失败,沿用内存版本", "err", err, "version", h.Version())
return h.Version()
}
h.setVersion(v)
return v
}
func (h *Hub) countChangesSince(ctx context.Context, from int64) (int, error) {
var n int
err := h.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM change_log WHERE id > ?`, from).Scan(&n)
if err != nil {
return 0, fmt.Errorf("统计增量变更: %w", err)
}
return n, nil
}
// changesSince 按版本升序取增量;limit <= 0 表示不限量。
func (h *Hub) changesSince(ctx context.Context, from int64, limit int) ([]Change, error) {
q := `SELECT id, entity_type, entity_id, action, payload_json
FROM change_log WHERE id > ? ORDER BY id`
args := []any{from}
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("读取增量变更: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Change, 0, 16)
for rows.Next() {
var (
ch Change
entity string
entityID sql.NullInt64
action string
payload string
)
if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil {
return nil, fmt.Errorf("解析变更行: %w", err)
}
ch.Entity = entity
ch.ID = entityID.Int64
ch.Action = action
ch.Data = payloadJSON(payload)
out = append(out, ch)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历变更行: %w", err)
}
return out, nil
}
// LoadChanges 供写操作完成后读取「自 sinceVersion 起的变更」,再交给 Broadcast 推送。
func (h *Hub) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]Change, error) {
if h.db == nil {
return nil, nil
}
return h.changesSince(ctx, sinceVersion, limit)
}
// payloadJSON 保证 data 永远是合法 JSON:change_log 允许写入任意文本,
// 空串或坏 JSON 一律降级成空对象,避免把坏数据传播给客户端。
func payloadJSON(payload string) json.RawMessage {
if payload == "" {
return json.RawMessage(`{}`)
}
if !json.Valid([]byte(payload)) {
quoted, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return quoted
}
return json.RawMessage(payload)
}
+162
View File
@@ -0,0 +1,162 @@
package realtime
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// client 是一条 WebSocket 连接。读写各一个 goroutine,
// 写通过带缓冲的 send 通道串行化,通道满即视为慢客户端。
type client struct {
hub *Hub
conn *websocket.Conn
remoteAddr string
send chan []byte
done chan struct{}
closeOnce sync.Once
closeCode websocket.StatusCode
closeReason string
mu sync.Mutex
greeted bool
role string
userID int64
instanceID string
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client {
return &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
}
}
// stop 只负责让两条 goroutine 退出;真正的关闭动作由 serve 统一执行,
// 避免在持有广播锁时做可能阻塞的关闭握手。
func (c *client) stop(code websocket.StatusCode, reason string) {
c.closeOnce.Do(func() {
c.mu.Lock()
c.closeCode = code
c.closeReason = reason
c.mu.Unlock()
close(c.done)
})
}
func (c *client) sendEvent(event string, data any) bool {
msg, err := encode(event, data)
if err != nil {
c.hub.log.Error("编码实时消息失败", "event", event, "err", err)
return false
}
return c.sendRaw(msg)
}
func (c *client) sendRaw(msg []byte) bool {
select {
case <-c.done:
return false
default:
}
select {
case c.send <- msg:
return true
default:
c.hub.markSlow(c)
return false
}
}
// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
for {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
readCtx := ctx
var cancel context.CancelFunc
if !greeted {
readCtx, cancel = context.WithTimeout(ctx, c.hub.helloTimeout())
}
_, data, err := c.conn.Read(readCtx)
if cancel != nil {
cancel()
}
if err != nil {
return
}
c.handleMessage(ctx, data)
}
}
func (c *client) handleMessage(ctx context.Context, data []byte) {
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
c.hub.log.Debug("忽略无法解析的实时消息", "remote", c.remoteAddr, "err", err)
return
}
switch env.Event {
case EventHello:
var p helloPayload
if err := json.Unmarshal(env.Data, &p); err != nil {
c.hub.log.Debug("hello 载荷不合法", "remote", c.remoteAddr, "err", err)
return
}
c.hub.handleHello(ctx, c, p)
case EventAck:
var p ackPayload
if err := json.Unmarshal(env.Data, &p); err != nil {
c.hub.log.Debug("ack 载荷不合法", "remote", c.remoteAddr, "err", err)
return
}
if p.Version > c.ackVersion.Load() {
c.ackVersion.Store(p.Version)
}
c.hub.touchInstance(c, time.Now())
case EventResync:
c.hub.sendInitial(ctx, c, nil)
default:
c.hub.log.Debug("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr)
}
}
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
func (c *client) writeLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-c.done:
return
case msg := <-c.send:
writeCtx, cancel := context.WithTimeout(ctx, c.hub.writeTimeout())
err := c.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
if err != nil {
c.stop(websocket.StatusInternalError, "写入失败")
return
}
}
}
}
+542
View File
@@ -0,0 +1,542 @@
package realtime
import (
"context"
"database/sql"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
)
// 默认参数;现场可通过 Set* 调整。
const (
defaultHeartbeatInterval = 10 * time.Second
defaultMaxCatchup = 500
defaultSlowLag = 500
defaultHelloTimeout = 15 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultSendBuffer = 64
)
// Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。
//
// 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更,
// 断线客户端用 hello.lastVersion 请求增量补齐。
type Hub struct {
db *sql.DB
log *slog.Logger
mu sync.RWMutex
rooms map[string]map[*client]struct{}
clients map[*client]struct{}
instances map[string]*instanceState
version int64
closed bool
// sendMu 串行化「版本推进 + 下发」,保证同一客户端收到的 change 与版本严格递增,
// 也避免初始快照与并发广播交错导致漏变更。
sendMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
hbWG sync.WaitGroup // 心跳循环
connWG sync.WaitGroup // 连接处理
heartbeatInterval time.Duration
heartbeatReset chan struct{}
maxCatchup int
slowLag int64
helloTO time.Duration
writeTO time.Duration
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
// 调用方在服务关闭时应调用 Hub.Close。
func NewHub(db *sql.DB, log *slog.Logger) *Hub {
if log == nil {
log = slog.Default()
}
ctx, cancel := context.WithCancel(context.Background())
h := &Hub{
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
h.version = v
} else {
log.Warn("读取初始版本号失败", "err", err)
}
}
h.hbWG.Add(1)
go h.heartbeatLoop()
return h
}
// Close 停止心跳、断开所有连接并等待处理中的连接退出。
func (h *Hub) Close() error {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return nil
}
h.closed = true
h.mu.Unlock()
h.cancel()
for _, c := range h.roomClients("") {
c.stop(websocket.StatusGoingAway, "服务关闭")
}
h.connWG.Wait()
h.hbWG.Wait()
return nil
}
// Version 返回内存中记录的当前版本号。
func (h *Hub) Version() int64 {
h.mu.RLock()
defer h.mu.RUnlock()
return h.version
}
func (h *Hub) setVersion(v int64) {
if v <= 0 {
return
}
h.mu.Lock()
if v > h.version {
h.version = v
}
h.mu.Unlock()
}
// SetHeartbeatInterval 调整心跳间隔并立即生效;用于现场调优与测试。
func (h *Hub) SetHeartbeatInterval(d time.Duration) {
if d <= 0 {
return
}
h.mu.Lock()
h.heartbeatInterval = d
h.mu.Unlock()
select {
case h.heartbeatReset <- struct{}{}:
default:
}
}
// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。
func (h *Hub) SetMaxCatchup(n int) {
if n <= 0 {
return
}
h.mu.Lock()
h.maxCatchup = n
h.mu.Unlock()
}
// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。
func (h *Hub) SetSlowLag(n int64) {
h.mu.Lock()
h.slowLag = n
h.mu.Unlock()
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.heartbeatInterval
}
func (h *Hub) maxCatchupValue() int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.maxCatchup
}
func (h *Hub) slowLagValue() int64 {
h.mu.RLock()
defer h.mu.RUnlock()
return h.slowLag
}
func (h *Hub) helloTimeout() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.helloTO
}
func (h *Hub) writeTimeout() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.writeTO
}
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
func (h *Hub) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。
InsecureSkipVerify: true,
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err)
return
}
h.mu.Lock()
if h.closed {
h.mu.Unlock()
_ = conn.CloseNow()
return
}
h.connWG.Add(1)
h.mu.Unlock()
defer h.connWG.Done()
h.serve(conn, r.RemoteAddr)
})
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
defer cancel()
var loops sync.WaitGroup
loops.Add(2)
go func() { defer loops.Done(); c.readLoop(ctx) }()
go func() { defer loops.Done(); c.writeLoop(ctx) }()
<-c.done
cancel()
loops.Wait()
h.removeClient(c)
// 客户端会自动重连并走 catchup,这里直接断开,避免关闭握手阻塞。
_ = conn.CloseNow()
}
func (h *Hub) addClient(c *client) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *Hub) removeClient(c *client) {
h.mu.Lock()
delete(h.clients, c)
for room, set := range h.rooms {
delete(set, c)
if len(set) == 0 {
delete(h.rooms, room)
}
}
h.mu.Unlock()
h.releaseDisplay(c)
}
// join 把连接加入房间;房间用内存 map 维护,不引入实时框架。
func (h *Hub) join(c *client, rooms ...string) {
h.mu.Lock()
defer h.mu.Unlock()
for _, room := range rooms {
if room == "" {
continue
}
set := h.rooms[room]
if set == nil {
set = make(map[*client]struct{})
h.rooms[room] = set
}
set[c] = struct{}{}
}
}
// roomClients 返回房间成员的快照;room 为空表示全部连接。
func (h *Hub) roomClients(room string) []*client {
h.mu.RLock()
defer h.mu.RUnlock()
if room == "" {
out := make([]*client, 0, len(h.clients))
for c := range h.clients {
out = append(out, c)
}
return out
}
set := h.rooms[room]
out := make([]*client, 0, len(set))
for c := range set {
out = append(out, c)
}
return out
}
func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
if p.Role != RoleDisplay && p.Role != RoleAdmin {
h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "role 非法")
return
}
c.mu.Lock()
c.role = p.Role
c.userID = p.UserID
c.mu.Unlock()
if p.Role == RoleAdmin {
rooms := []string{RoomAdmin}
if p.UserID > 0 {
rooms = append(rooms, AdminRoom(p.UserID))
}
h.join(c, rooms...)
} else {
id := p.InstanceID
if id == "" {
id = p.DisplayKey
}
h.registerDisplay(c, id, p.Page)
h.join(c, RoomDisplay)
}
h.sendInitial(ctx, c, p.LastVersion)
}
// sendInitial 决定首包是 snapshot 还是 catchup:
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.sendMu.Lock()
defer h.sendMu.Unlock()
c.mu.Lock()
c.greeted = true
c.mu.Unlock()
cur, err := h.currentVersion(ctx)
if err != nil {
h.log.Error("读取版本号失败,无法下发首包", "err", err)
c.stop(websocket.StatusInternalError, "版本号不可用")
return
}
h.setVersion(cur)
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
h.sendSnapshotLocked(ctx, c)
return
}
n, err := h.countChangesSince(ctx, *lastVersion)
if err != nil {
h.log.Warn("统计增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
return
}
if n > h.maxCatchupValue() {
h.sendSnapshotLocked(ctx, c)
return
}
changes, err := h.changesSince(ctx, *lastVersion, 0)
if err != nil {
h.log.Warn("读取增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
return
}
if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) {
return
}
c.lastSent.Store(cur)
c.ackVersion.Store(cur)
}
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
// 必须原子完成,否则并发的 change 会插到快照前面。
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
snap, err := h.Snapshot(ctx)
if err != nil {
h.log.Error("构建快照失败", "err", err)
c.stop(websocket.StatusInternalError, "快照不可用")
return
}
if !c.sendEvent(EventSnapshot, snap) {
return
}
c.lastSent.Store(snap.Version)
c.ackVersion.Store(snap.Version)
h.setVersion(snap.Version)
}
// Broadcast 把变更推给所有已握手的连接(大屏与管理端)。
func (h *Hub) Broadcast(changes []Change) { h.fanout("", changes) }
// BroadcastTo 把变更推给指定房间(如 RoomAdmin、AdminRoom(uid))。
func (h *Hub) BroadcastTo(room string, changes []Change) { h.fanout(room, changes) }
// fanout 在 sendMu 下按版本去重后下发:已经发过的版本不再重复投递。
func (h *Hub) fanout(room string, changes []Change) {
if len(changes) == 0 {
return
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
version := int64(0)
for _, ch := range changes {
if ch.Version > version {
version = ch.Version
}
}
if version == 0 {
version = h.currentVersionCached(h.ctx)
}
h.setVersion(version)
for _, c := range h.roomClients(room) {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
last := c.lastSent.Load()
filtered := make([]Change, 0, len(changes))
for _, ch := range changes {
if ch.Version > 0 && ch.Version <= last {
continue
}
filtered = append(filtered, ch)
}
if len(filtered) == 0 {
continue
}
if !c.sendEvent(EventChange, changePayload{Version: version, Changes: filtered}) {
continue
}
c.lastSent.Store(version)
h.checkSlow(c, version)
}
}
// SendCommand 向所有大屏下发 display:command。
func (h *Hub) SendCommand(cmd DisplayCommand) { h.SendCommandTo(RoomDisplay, cmd) }
// SendCommandTo 向指定房间下发 display:command;非法 action 直接丢弃并记录。
func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) {
if !ValidDisplayAction(cmd.Action) {
h.log.Warn("忽略非法的大屏指令", "action", cmd.Action, "room", room)
return
}
if cmd.Action == CmdGoto && (room == "" || room == RoomDisplay) {
h.setInstancePage(pageFromPayload(cmd.Payload))
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
for _, c := range h.roomClients(room) {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
c.sendEvent(EventDisplayCommand, cmd)
}
}
// checkSlow 用 ack 版本差识别慢客户端;超阈值直接断开,由客户端重连后补齐。
func (h *Hub) checkSlow(c *client, version int64) {
lag := h.slowLagValue()
if lag <= 0 {
return
}
if version-c.ackVersion.Load() <= lag {
return
}
h.markSlow(c)
}
func (h *Hub) markSlow(c *client) {
if c.slow.Swap(true) {
return
}
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
h.mu.Lock()
if st := h.instances[id]; st != nil {
st.slow = true
}
h.mu.Unlock()
h.log.Warn("慢客户端:ack 长时间落后,断开后由重连补齐",
"instance", id, "ackVersion", c.ackVersion.Load(), "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "ack 落后")
}
// heartbeatLoop 每 10s 下发一次心跳(version + serverTime)。
func (h *Hub) heartbeatLoop() {
defer h.hbWG.Done()
timer := time.NewTimer(h.heartbeatIntervalValue())
defer timer.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-timer.C:
h.beat()
timer.Reset(h.heartbeatIntervalValue())
case <-h.heartbeatReset:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(h.heartbeatIntervalValue())
}
}
}
func (h *Hub) beat() {
version := h.currentVersionCached(h.ctx)
now := time.Now()
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
for _, c := range h.roomClients("") {
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
continue
}
c.sendEvent(EventHeartbeat, data)
h.touchInstance(c, now)
h.checkSlow(c, version)
}
}
+502
View File
@@ -0,0 +1,502 @@
package realtime
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/coder/websocket"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// ---------- 测试脚手架 ----------
type testEnv struct {
hub *Hub
db *sql.DB
srv *httptest.Server
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
srv := httptest.NewServer(h.Handler())
t.Cleanup(func() {
srv.Close()
_ = h.Close()
_ = db.Close()
})
return &testEnv{hub: h, db: db, srv: srv}
}
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "event.db")
db, err := sql.Open("sqlite", "file:"+path+
"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开测试库: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
return db
}
func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
url := "ws" + strings.TrimPrefix(e.srv.URL, "http") + "/ws"
conn, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
cancel()
t.Fatalf("连接 /ws: %v", err)
}
c := &wsClient{t: t, ctx: ctx, cancel: cancel, conn: conn, msgs: make(chan wsMessage, 256)}
go c.readLoop()
t.Cleanup(c.close)
if hello != nil {
c.send(EventHello, *hello)
}
return c
}
type wsMessage struct {
event string
data json.RawMessage
err error
}
type wsClient struct {
t *testing.T
ctx context.Context
cancel context.CancelFunc
conn *websocket.Conn
msgs chan wsMessage
}
// readLoop 单独读取:测试里等待「没有消息」时不能靠取消 Read 的 context,
// 那会把整条连接关掉,后续用例就没法继续读了。
func (c *wsClient) readLoop() {
defer close(c.msgs)
for {
_, data, err := c.conn.Read(c.ctx)
if err != nil {
c.msgs <- wsMessage{err: err}
return
}
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
c.msgs <- wsMessage{err: err}
return
}
c.msgs <- wsMessage{event: env.Event, data: env.Data}
}
}
func (c *wsClient) close() {
_ = c.conn.CloseNow()
c.cancel()
}
func (c *wsClient) send(event string, data any) {
c.t.Helper()
msg, err := encode(event, data)
if err != nil {
c.t.Fatalf("编码 %s: %v", event, err)
}
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
defer cancel()
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
c.t.Fatalf("发送 %s: %v", event, err)
}
}
// recvWithin 在 d 内取一条消息;超时返回 ok=false(连接保持可用)。
func (c *wsClient) recvWithin(d time.Duration) (string, json.RawMessage, bool) {
c.t.Helper()
timer := time.NewTimer(d)
defer timer.Stop()
select {
case msg, ok := <-c.msgs:
if !ok || msg.err != nil {
c.t.Fatalf("连接已断开: %v", msg.err)
}
return msg.event, msg.data, true
case <-timer.C:
return "", nil, false
}
}
// recvEvent 等待指定事件,跳过心跳等无关消息。
func (c *wsClient) recvEvent(want string) json.RawMessage {
c.t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
ev, data, ok := c.recvWithin(time.Until(deadline))
if !ok {
break
}
if ev == want {
return data
}
}
c.t.Fatalf("等待事件 %s 超时", want)
return nil
}
func decode[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("解析载荷 %s: %v", raw, err)
}
return v
}
func insertChange(t *testing.T, db *sql.DB, entity string, entityID int64, action, payload string) int64 {
t.Helper()
res, err := db.Exec(
`INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES (?, ?, ?, ?)`,
entity, entityID, action, payload)
if err != nil {
t.Fatalf("写入 change_log: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("读取 change_log id: %v", err)
}
return id
}
func loadChanges(t *testing.T, h *Hub, since int64, limit int) []Change {
t.Helper()
changes, err := h.LoadChanges(context.Background(), since, limit)
if err != nil {
t.Fatalf("读取增量变更: %v", err)
}
return changes
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等待条件超时: %s", what)
}
func ptrInt64(v int64) *int64 { return &v }
// ---------- 客户端状态归约(模拟前端按 version 应用变更) ----------
type clientState map[string]json.RawMessage
func stateKey(ch Change) string { return fmt.Sprintf("%s/%d", ch.Entity, ch.ID) }
func applyChanges(s clientState, changes []Change) clientState {
for _, ch := range changes {
switch ch.Action {
case ActionVoid:
delete(s, stateKey(ch))
case ActionReplace:
s[ch.Entity] = ch.Data
default:
s[stateKey(ch)] = ch.Data
}
}
return s
}
// ---------- 用例 ----------
func TestHelloSnapshotThenChange(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
if snap.Version != 0 {
t.Fatalf("空库快照版本应为 0,得到 %d", snap.Version)
}
v := insertChange(t, env.db, "result", 42, ActionUpsert, `{"rank":1}`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
got := decode[changePayload](t, c.recvEvent(EventChange))
if got.Version != v {
t.Fatalf("change 版本应为 %d,得到 %d", v, got.Version)
}
if len(got.Changes) != 1 {
t.Fatalf("change 条数应为 1,得到 %d", len(got.Changes))
}
ch := got.Changes[0]
if ch.Entity != "result" || ch.ID != 42 || ch.Action != ActionUpsert {
t.Fatalf("change 内容不符: %+v", ch)
}
if string(ch.Data) != `{"rank":1}` {
t.Fatalf("change data 不符: %s", ch.Data)
}
if env.hub.Version() != v {
t.Fatalf("Hub 版本应推进到 %d,得到 %d", v, env.hub.Version())
}
}
// 断线期间的变更通过 catchup 补齐,且终态与全量重放的终态一致。
func TestReconnectCatchupTerminalState(t *testing.T) {
env := newTestEnv(t)
v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
v2 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":10}]`)
c1 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
snap := decode[Snapshot](t, c1.recvEvent(EventSnapshot))
if snap.Version != v2 || snap.Version != v1+1 {
t.Fatalf("首次快照版本应为 %d,得到 %d", v2, snap.Version)
}
c1.close()
v3 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
v4 := insertChange(t, env.db, "result", 1, ActionVoid, `{}`)
v5 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":12}]`)
if v4 != v3+1 || v5 != v4+1 {
t.Fatalf("change_log 版本应连续递增: %d %d %d", v3, v4, v5)
}
c2 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version)})
cp := decode[catchupPayload](t, c2.recvEvent(EventCatchup))
if cp.FromVersion != snap.Version {
t.Fatalf("fromVersion 应为 %d,得到 %d", snap.Version, cp.FromVersion)
}
if cp.Version != v5 {
t.Fatalf("catchup 版本应为 %d,得到 %d", v5, cp.Version)
}
// 版本号不上线路(协议只在消息层带 version),因此按内容核对补发的三条变更。
wantChanges := []struct {
entity string
id int64
action string
}{
{"result", 2, ActionUpsert},
{"result", 1, ActionVoid},
{"standings", 0, ActionReplace},
}
if len(cp.Changes) != len(wantChanges) {
t.Fatalf("catchup 条数应为 %d,得到 %d", len(wantChanges), len(cp.Changes))
}
for i, want := range wantChanges {
got := cp.Changes[i]
if got.Entity != want.entity || got.ID != want.id || got.Action != want.action {
t.Fatalf("第 %d 条变更应为 %+v,得到 %+v", i, want, got)
}
}
// 断线前已应用的变更 + catchup 的变更 == 从头全量重放,终态必须一致。
base := applyChanges(clientState{}, loadChanges(t, env.hub, 0, int(snap.Version)))
got := applyChanges(base, cp.Changes)
want := applyChanges(clientState{}, loadChanges(t, env.hub, 0, 0))
if !reflect.DeepEqual(got, want) {
t.Fatalf("catchup 终态与全量重放不一致:\n got=%v\nwant=%v", got, want)
}
// 新连接的整包快照版本必须与 catchup 的终点一致。
fresh, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if fresh.Version != cp.Version {
t.Fatalf("快照版本 %d 与 catchup 版本 %d 不一致", fresh.Version, cp.Version)
}
}
// 增量超过阈值时回落全量快照。
func TestCatchupFallsBackToSnapshotWhenTooOld(t *testing.T) {
env := newTestEnv(t)
env.hub.SetMaxCatchup(2)
first := insertChange(t, env.db, "result", 1, ActionUpsert, `{}`)
for i := int64(2); i <= 5; i++ {
insertChange(t, env.db, "result", i, ActionUpsert, `{}`)
}
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(first - 1)})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
if snap.Version != 5 {
t.Fatalf("应回落全量快照且版本为 5,得到 %d", snap.Version)
}
}
func TestDuplicateDeliveryIgnored(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
changes := loadChanges(t, env.hub, 0, 0)
env.hub.Broadcast(changes)
got := decode[changePayload](t, c.recvEvent(EventChange))
if got.Version != v1 {
t.Fatalf("首投版本应为 %d,得到 %d", v1, got.Version)
}
c.send(EventAck, ackPayload{Version: v1})
// 同一批变更再投一次,客户端不应再收到。
env.hub.Broadcast(changes)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("重复投递未被忽略,收到事件 %s", ev)
}
// 新变更仍能正常下发。
v2 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
env.hub.Broadcast(loadChanges(t, env.hub, v1, 0))
got = decode[changePayload](t, c.recvEvent(EventChange))
if got.Version != v2 {
t.Fatalf("新变更版本应为 %d,得到 %d", v2, got.Version)
}
}
// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。
func TestHeartbeatAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
// 只写库不广播:心跳版本会领先于客户端,客户端据此发起 resync。
insertChange(t, env.db, "result", 1, ActionUpsert, `{}`)
insertChange(t, env.db, "result", 2, ActionUpsert, `{}`)
hb := decode[heartbeatPayload](t, c.recvEvent(EventHeartbeat))
if hb.Version != 2 {
t.Fatalf("心跳版本应为 2,得到 %d", hb.Version)
}
if _, err := time.Parse(time.RFC3339Nano, hb.ServerTime); err != nil {
t.Fatalf("serverTime 不是 RFC3339: %q (%v)", hb.ServerTime, err)
}
c.send(EventResync, struct{}{})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
if snap.Version != 2 {
t.Fatalf("resync 快照版本应为 2,得到 %d", snap.Version)
}
}
// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。
func TestDisplayInstancesRegistry(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
c.recvEvent(EventSnapshot)
insts := env.hub.Instances()
if len(insts) != 1 {
t.Fatalf("实例数应为 1,得到 %d", len(insts))
}
if insts[0].ID != "screen-1" || !insts[0].Online || insts[0].Page != "standings" {
t.Fatalf("实例注册内容不符: %+v", insts[0])
}
if insts[0].LastHeartbeat.IsZero() {
t.Fatalf("最后心跳不应为零值: %+v", insts[0])
}
// 重复 hello 不应把实例引用计数加两次,否则断开后仍会显示在线。
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
c.recvEvent(EventSnapshot)
if insts := env.hub.Instances(); len(insts) != 1 || !insts[0].Online {
t.Fatalf("重复 hello 后实例应仍只有一个且在线: %+v", insts)
}
gotoCmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"})
if err != nil {
t.Fatalf("构造 goto: %v", err)
}
env.hub.SendCommand(gotoCmd)
cmd := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand))
if cmd.Action != CmdGoto {
t.Fatalf("指令 action 应为 goto,得到 %s", cmd.Action)
}
if insts := env.hub.Instances(); insts[0].Page != "medals" {
t.Fatalf("goto 后当前页应为 medals,得到 %s", insts[0].Page)
}
detail, err := NewDisplayCommand(CmdShowDetail, map[string]any{"eventId": 7})
if err != nil {
t.Fatalf("构造 show-detail: %v", err)
}
env.hub.SendCommand(detail)
if got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)); got.Action != CmdShowDetail {
t.Fatalf("指令 action 应为 show-detail,得到 %s", got.Action)
}
c.close()
waitFor(t, "实例离线", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && !insts[0].Online
})
}
// 房间隔离:admin:<userId> 只发给对应管理端。
func TestAdminRoomTargeting(t *testing.T) {
env := newTestEnv(t)
display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
display.recvEvent(EventSnapshot)
admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7})
admin.recvEvent(EventSnapshot)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
env.hub.BroadcastTo(AdminRoom(7), loadChanges(t, env.hub, 0, 0))
got := decode[changePayload](t, admin.recvEvent(EventChange))
if got.Version != 1 {
t.Fatalf("定向 change 版本应为 1,得到 %d", got.Version)
}
if ev, _, ok := display.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("大屏不应收到 admin:<uid> 房间的消息,收到 %s", ev)
}
}
// 慢客户端:ack 落后超过阈值即被识别并断开。
func TestSlowClientDetected(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSlowLag(1)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "slow-1"})
c.recvEvent(EventSnapshot)
for i := int64(1); i <= 3; i++ {
insertChange(t, env.db, "result", i, ActionUpsert, `{}`)
}
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
waitFor(t, "慢客户端被标记", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && insts[0].Slow
})
waitFor(t, "慢客户端连接被断开", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && !insts[0].Online
})
}
+158
View File
@@ -0,0 +1,158 @@
package realtime
import (
"crypto/rand"
"encoding/hex"
"sort"
"strconv"
"time"
)
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
type Instance struct {
ID string `json:"id"`
Online bool `json:"online"`
Page string `json:"page,omitempty"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
ConnectedAt time.Time `json:"connectedAt"`
RemoteAddr string `json:"remoteAddr,omitempty"`
AckVersion int64 `json:"ackVersion"`
Slow bool `json:"slow"`
}
// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。
type instanceState struct {
id string
page string
connectedAt time.Time
lastHeartbeat time.Time
remoteAddr string
ackVersion int64
slow bool
conns int
}
// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成,
// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。
func (h *Hub) registerDisplay(c *client, id, page string) string {
if id == "" {
id = "display-" + randomID()
}
now := time.Now()
c.mu.Lock()
prev := c.instanceID
c.instanceID = id
c.mu.Unlock()
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
if prev != "" && prev != id {
h.releaseDisplayID(prev, c)
}
h.mu.Lock()
defer h.mu.Unlock()
st := h.instances[id]
if st == nil {
st = &instanceState{id: id, connectedAt: now}
h.instances[id] = st
}
if prev != id {
st.conns++
}
if page != "" {
st.page = page
}
st.lastHeartbeat = now
st.remoteAddr = c.remoteAddr
return id
}
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查。
func (h *Hub) releaseDisplay(c *client) {
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
if id == "" {
return
}
h.releaseDisplayID(id, c)
}
func (h *Hub) releaseDisplayID(id string, c *client) {
h.mu.Lock()
defer h.mu.Unlock()
st := h.instances[id]
if st == nil {
return
}
if st.conns > 0 {
st.conns--
}
st.lastHeartbeat = time.Now()
st.ackVersion = c.ackVersion.Load()
st.slow = c.slow.Load()
}
// touchInstance 在收到客户端消息或下发心跳时刷新「最后心跳」。
func (h *Hub) touchInstance(c *client, now time.Time) {
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
if id == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if st := h.instances[id]; st != nil {
st.lastHeartbeat = now
st.ackVersion = c.ackVersion.Load()
st.slow = c.slow.Load()
}
}
// setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。
func (h *Hub) setInstancePage(page string) {
if page == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
for _, st := range h.instances {
if st.conns > 0 {
st.page = page
}
}
}
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
func (h *Hub) Instances() []Instance {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]Instance, 0, len(h.instances))
for _, st := range h.instances {
out = append(out, Instance{
ID: st.id,
Online: st.conns > 0,
Page: st.page,
LastHeartbeat: st.lastHeartbeat,
ConnectedAt: st.connectedAt,
RemoteAddr: st.remoteAddr,
AckVersion: st.ackVersion,
Slow: st.slow,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func randomID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b[:])
}
+171
View File
@@ -0,0 +1,171 @@
// Package realtime 实现 /ws 实时同步中心。
//
// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范:
// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。
package realtime
import (
"encoding/json"
"fmt"
"time"
)
// 事件名与协议第 3 节的表格一一对应。
const (
EventHello = "hello"
EventSnapshot = "snapshot"
EventCatchup = "catchup"
EventChange = "change"
EventResync = "resync"
EventAck = "ack"
EventHeartbeat = "heartbeat"
EventDisplayCommand = "display:command"
)
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
const (
RoomDisplay = "display"
RoomAdmin = "admin"
)
// AdminRoom 返回某个管理端用户的定向房间名。
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
// 连接角色。
const (
RoleDisplay = "display"
RoleAdmin = "admin"
)
// change.action 取值。
const (
ActionUpsert = "upsert"
ActionVoid = "void"
ActionReplace = "replace"
)
// display:command 支持的 action。
const (
CmdGoto = "goto"
CmdLock = "lock"
CmdUnlock = "unlock"
CmdReload = "reload"
CmdAnnounce = "announce"
CmdShowDetail = "show-detail"
CmdHideDetail = "hide-detail"
)
var displayActions = map[string]bool{
CmdGoto: true,
CmdLock: true,
CmdUnlock: true,
CmdReload: true,
CmdAnnounce: true,
CmdShowDetail: true,
CmdHideDetail: true,
}
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
func ValidDisplayAction(action string) bool { return displayActions[action] }
// envelope 是所有消息的统一外壳:{ "event": "...", "data": { ... } }。
type envelope struct {
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
}
// encode 把事件名与载荷打包成一条文本帧。
func encode(event string, data any) ([]byte, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("编码 %s 载荷: %w", event, err)
}
return json.Marshal(envelope{Event: event, Data: raw})
}
// helloPayload 是 C→S 的 hello 载荷。
// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」,
// 老客户端不带这两个字段也能正常工作。
type helloPayload struct {
Role string `json:"role"`
LastVersion *int64 `json:"lastVersion,omitempty"`
DisplayKey string `json:"displayKey,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
UserID int64 `json:"userId,omitempty"`
Page string `json:"page,omitempty"`
}
// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。
type ackPayload struct {
Version int64 `json:"version"`
}
// Change 是变更的最小单元;Version 只用于服务端排序与去重,不上线路。
type Change struct {
Entity string `json:"entity"`
ID int64 `json:"id,omitempty"`
Action string `json:"action"`
Data json.RawMessage `json:"data,omitempty"`
Version int64 `json:"-"`
}
// changePayload 是 change 事件的载荷。
type changePayload struct {
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// catchupPayload 是 catchup 事件的载荷。
type catchupPayload struct {
FromVersion int64 `json:"fromVersion"`
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// heartbeatPayload 是 heartbeat 事件的载荷。
type heartbeatPayload struct {
Version int64 `json:"version"`
ServerTime string `json:"serverTime"`
}
// DisplayCommand 是下发给大屏的 display:command 载荷。
type DisplayCommand struct {
Action string `json:"action"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewDisplayCommand 构造一条大屏指令;action 不在协议清单内时返回错误。
func NewDisplayCommand(action string, payload any) (DisplayCommand, error) {
if !ValidDisplayAction(action) {
return DisplayCommand{}, fmt.Errorf("未知的大屏指令 %q", action)
}
cmd := DisplayCommand{Action: action}
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return DisplayCommand{}, fmt.Errorf("编码大屏指令 %s 的 payload: %w", action, err)
}
cmd.Payload = raw
}
return cmd, nil
}
// pageFromPayload 从 goto 指令的 payload 里取目标页名,兼容字符串与对象两种写法。
func pageFromPayload(payload json.RawMessage) string {
if len(payload) == 0 {
return ""
}
var s string
if err := json.Unmarshal(payload, &s); err == nil {
return s
}
var obj struct {
Page string `json:"page"`
}
if err := json.Unmarshal(payload, &obj); err == nil {
return obj.Page
}
return ""
}
func nowISO(t time.Time) string { return t.Format(time.RFC3339Nano) }
+348
View File
@@ -0,0 +1,348 @@
package realtime
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。
const latestResultsLimit = 20
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致。
type Snapshot struct {
Version int64 `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Teams []domain.Team `json:"teams"`
Events []domain.Event `json:"events"`
Standings []domain.StandingsRow `json:"standings"`
Records []Record `json:"records"`
LatestResults []LatestResult `json:"latestResults"`
}
// Record 是纪录榜的一行。
type Record struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Level string `json:"level"`
ResultValue *float64 `json:"resultValue,omitempty"`
ResultText string `json:"resultText,omitempty"`
HolderName string `json:"holderName,omitempty"`
HolderTeamID int64 `json:"holderTeamId,omitempty"`
AchievedAt string `json:"achievedAt,omitempty"`
IsCurrent bool `json:"isCurrent"`
Source string `json:"source"`
}
// LatestResult 是已发布成绩的展示行,供大屏滚动与详情页使用。
type LatestResult struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
TeamID int64 `json:"teamId"`
AthleteID int64 `json:"athleteId,omitempty"`
Rank int `json:"rank,omitempty"`
ResultText string `json:"resultText,omitempty"`
ResultValue *float64 `json:"resultValue,omitempty"`
Unit string `json:"unit,omitempty"`
IsTie bool `json:"isTie,omitempty"`
Status string `json:"status"`
State string `json:"state"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// Snapshot 读取一份一致的完整状态。
//
// 全部查询放在同一个只读事务里,且版本号取自同一事务,
// 否则快照里的数据与版本号会错位,重连补齐时就会漏变更。
func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if h.db == nil {
return nil, fmt.Errorf("实时中心未绑定赛事库")
}
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("开启快照事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
snap := &Snapshot{
Settings: map[string]json.RawMessage{},
Teams: []domain.Team{},
Events: []domain.Event{},
Standings: []domain.StandingsRow{},
Records: []Record{},
LatestResults: []LatestResult{},
}
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil {
return nil, fmt.Errorf("读取快照版本号: %w", err)
}
if err := loadSettings(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadTeams(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadStandings(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadRecords(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadLatestResults(ctx, tx, snap); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交快照事务: %w", err)
}
return snap, nil
}
func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`)
if err != nil {
return fmt.Errorf("读取赛事配置: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return fmt.Errorf("解析赛事配置: %w", err)
}
snap.Settings[key] = rawJSONOrString(value)
}
return rows.Err()
}
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE active = 1
ORDER BY sort_order, code`)
if err != nil {
return fmt.Errorf("读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
t domain.Team
active int
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
return fmt.Errorf("解析单位: %w", err)
}
t.Active = active != 0
snap.Teams = append(snap.Teams, t)
}
return rows.Err()
}
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status,
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '')
FROM events
ORDER BY sort_order, id`)
if err != nil {
return fmt.Errorf("读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
e domain.Event
eventType string
resultMode string
resultType string
compare string
status string
recordEligible int
scored int
)
if err := rows.Scan(
&e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare,
&recordEligible, &scored, &e.Multiplier, &status,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark,
); err != nil {
return fmt.Errorf("解析小项: %w", err)
}
e.EventType = domain.EventType(eventType)
e.ResultMode = domain.ResultMode(resultMode)
e.ResultType = domain.ResultType(resultType)
e.Compare = domain.CompareMode(compare)
e.Status = domain.EventStatus(status)
e.RecordEligible = recordEligible != 0
e.Scored = scored != 0
snap.Events = append(snap.Events, e)
}
return rows.Err()
}
// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次,
// 保证大屏看到的榜单与 REST /api/standings 一致。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
ledger, err := loadLedger(ctx, tx)
if err != nil {
return err
}
ranked, err := loadRanked(ctx, tx)
if err != nil {
return err
}
snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{
Teams: snap.Teams,
Ledger: ledger,
Ranked: ranked,
})
return nil
}
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points
FROM score_ledger WHERE voided = 0 ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("读取积分流水: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.LedgerEntry
for rows.Next() {
var (
e domain.LedgerEntry
sourceType string
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil {
return nil, fmt.Errorf("解析积分流水: %w", err)
}
e.SourceType = domain.SourceType(sourceType)
out = append(out, e)
}
return out, rows.Err()
}
// loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.QueryContext(ctx, `
SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank
FROM results r JOIN entries e ON e.id = r.entry_id
WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3
ORDER BY r.id`)
if err != nil {
return nil, fmt.Errorf("读取奖牌名次: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.RankedResult
for rows.Next() {
var (
teamID, athleteID int64
rank int
)
if err := rows.Scan(&teamID, &athleteID, &rank); err != nil {
return nil, fmt.Errorf("解析奖牌名次: %w", err)
}
out = append(out, domain.RankedResult{
TeamID: teamID,
AthleteID: athleteID,
Rank: rank,
Scope: domain.ScopeTeam,
Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}},
})
}
return out, rows.Err()
}
func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, event_id, level, result_value, COALESCE(result_text, ''),
COALESCE(holder_name, ''), COALESCE(holder_team_id, 0),
COALESCE(achieved_at, ''), is_current, source
FROM records WHERE is_current = 1
ORDER BY event_id, level, id`)
if err != nil {
return fmt.Errorf("读取纪录榜: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
r Record
value sql.NullFloat64
isCurrent int
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &value, &r.ResultText,
&r.HolderName, &r.HolderTeamID, &r.AchievedAt, &isCurrent, &r.Source); err != nil {
return fmt.Errorf("解析纪录: %w", err)
}
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.IsCurrent = isCurrent != 0
snap.Records = append(snap.Records, r)
}
return rows.Err()
}
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0),
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value,
COALESCE(r.unit, ''), r.is_tie, r.status, r.state,
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '')
FROM results r JOIN entries e ON e.id = r.entry_id
WHERE r.state = 'published'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC
LIMIT ?`, latestResultsLimit)
if err != nil {
return fmt.Errorf("读取最新成绩: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
lr LatestResult
value sql.NullFloat64
isTie int
)
if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID,
&lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil {
return fmt.Errorf("解析最新成绩: %w", err)
}
if value.Valid {
v := value.Float64
lr.ResultValue = &v
}
lr.IsTie = isTie != 0
snap.LatestResults = append(snap.LatestResults, lr)
}
return rows.Err()
}
// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传,
// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。
func rawJSONOrString(value string) json.RawMessage {
if json.Valid([]byte(value)) {
return json.RawMessage(value)
}
quoted, err := json.Marshal(value)
if err != nil {
return json.RawMessage(`null`)
}
return quoted
}
+103
View File
@@ -0,0 +1,103 @@
package realtime
import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 快照必须与数据库同源:版本号取自 change_log,各列表取真实表,
// 且空库时序列化为 [] 而不是 null,避免前端判空逻辑踩坑。
func TestSnapshotContents(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `"测试运动会"`)
mustExec(t, db, `INSERT INTO settings(key, value) VALUES ('display.theme', 'dark')`)
mustExec(t, db, `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一单位', '一队', 1, 1)`)
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('S1', '田径')`)
mustExec(t, db, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米', 'individual', 'auto', 'time', 'asc', 'published')`)
mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`)
mustExec(t, db, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`)
mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`)
mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status)
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published', 'ok')`)
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 9)`)
mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
VALUES (1, 'meet', 11.2, '11.20', '李四', 1, '2025-09-01', 1, 'history')`)
version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if snap.Version != version {
t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version)
}
if got := string(snap.Settings["event.name"]); got != `"测试运动会"` {
t.Fatalf("settings JSON 值应透传,得到 %s", got)
}
if got := string(snap.Settings["display.theme"]); got != `"dark"` {
t.Fatalf("settings 纯文本应编码为 JSON 字符串,得到 %s", got)
}
if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active {
t.Fatalf("单位不符: %+v", snap.Teams)
}
if len(snap.Events) != 1 {
t.Fatalf("小项数应为 1,得到 %d", len(snap.Events))
}
ev := snap.Events[0]
if ev.Name != "100米" || ev.EventType != domain.EventTypeIndividual || !ev.Scored || ev.Compare != domain.CompareAsc {
t.Fatalf("小项不符: %+v", ev)
}
if len(snap.Standings) != 1 {
t.Fatalf("榜单行数应为 1,得到 %d", len(snap.Standings))
}
row := snap.Standings[0]
if row.TeamID != 1 || row.Points != 9 || row.Gold != 1 || row.Rank != 1 {
t.Fatalf("榜单不符: %+v", row)
}
if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil {
t.Fatalf("纪录不符: %+v", snap.Records)
}
if len(snap.LatestResults) != 1 {
t.Fatalf("最新成绩数应为 1,得到 %d", len(snap.LatestResults))
}
lr := snap.LatestResults[0]
if lr.ResultText != "11.58" || lr.TeamID != 1 || lr.AthleteID != 1 || lr.Rank != 1 || lr.State != "published" {
t.Fatalf("最新成绩不符: %+v", lr)
}
}
func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
env := newTestEnv(t)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建空快照: %v", err)
}
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
for _, want := range []string{`"teams":[]`, `"events":[]`, `"standings":[]`, `"records":[]`, `"latestResults":[]`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("空快照应包含 %s,实际 %s", want, raw)
}
}
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("执行 SQL %q: %v", q, err)
}
}
+248
View File
@@ -0,0 +1,248 @@
package rules
import (
"database/sql"
"fmt"
"sort"
"strings"
)
// ApplyResult 汇总一次导入实际落库的行数。
type ApplyResult struct {
ProfilesWritten int `json:"profilesWritten"`
ProfilesDeleted int `json:"profilesDeleted"`
ChannelsWritten int `json:"channelsWritten"`
EventsUpdated int `json:"eventsUpdated"`
SettingsWritten int `json:"settingsWritten"`
EventsSkipped []string `json:"eventsSkipped,omitempty"`
}
// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,
// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。
// 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。
//
// 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。
// 通道先改完再删模板,避免「模板被换掉」时误判引用。
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if cs == nil || cs.Next == nil {
return nil, ErrNilChangeSet
}
next := cs.Next.Canonical()
if err := next.Validate(); err != nil {
return nil, err
}
cur, err := Load(db)
if err != nil {
return nil, err
}
targets, unknown, err := resolveTargets(cur, &next)
if err != nil {
return nil, err
}
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
res := &ApplyResult{EventsSkipped: unknown}
// 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。
profileIDs := map[string]int64{}
for _, prof := range next.Profiles {
id, err := upsertProfile(tx, prof)
if err != nil {
return nil, err
}
profileIDs[prof.Code] = id
res.ProfilesWritten++
}
// 2. 通道:只覆盖规则包明确提到的项目。
eventIDs := map[string]int64{}
for _, ev := range cur.Events {
eventIDs[ev.Code] = ev.ID
}
codes := make([]string, 0, len(targets))
for code := range targets {
codes = append(codes, code)
}
sort.Strings(codes)
for _, code := range codes {
eventID, ok := eventIDs[code]
if !ok {
continue
}
if _, err := tx.Exec(`DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, fmt.Errorf("清理项目 %s 的计分通道: %w", code, err)
}
for i, ch := range targets[code] {
profileID, ok := profileIDs[ch.Profile]
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
res.ChannelsWritten++
}
// 3. 删除目标规则包里已没有的模板;仍被引用就报错,绝不静默拆掉别人的配置。
for _, prof := range cur.Pack.Profiles {
if _, keep := profileIDs[prof.Code]; keep {
continue
}
if err := deleteProfile(tx, prof.Code); err != nil {
return nil, err
}
res.ProfilesDeleted++
}
// 4. 项目开关:只改 override 里显式给出的字段。
for _, ov := range next.EventOverrides {
eventID, ok := eventIDs[ov.EventCode]
if !ok || (ov.Scored == nil && ov.Multiplier == nil) {
continue
}
scored, err := currentScored(tx, eventID)
if err != nil {
return nil, err
}
if ov.Scored != nil {
scored = *ov.Scored
}
multiplier, err := currentMultiplier(tx, eventID)
if err != nil {
return nil, err
}
if ov.Multiplier != nil {
multiplier = *ov.Multiplier
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`,
boolToInt(scored), multiplier, eventID); err != nil {
return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err)
}
res.EventsUpdated++
}
// 5. 赛事设置。
settings := settingsFromPack(&next)
for _, key := range settingsKeys() {
if _, err := tx.Exec(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
key, settings[key]); err != nil {
return nil, fmt.Errorf("写入赛事设置 %s: %w", key, err)
}
res.SettingsWritten++
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
func upsertProfile(tx *sql.Tx, prof Profile) (int64, error) {
if _, err := tx.Exec(`INSERT INTO scoring_profiles
(code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
tie_mode = excluded.tie_mode,
record_bonus_mode = excluded.record_bonus_mode,
award_ranks = excluded.award_ranks,
last_place_zero = excluded.last_place_zero,
updated_at = datetime('now')`,
prof.Code, prof.Name, string(prof.TieMode), string(prof.RecordBonusMode), prof.AwardRanks, boolToInt(prof.LastPlaceZero)); err != nil {
return 0, fmt.Errorf("写入积分模板 %s: %w", prof.Code, err)
}
var id int64
if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, prof.Code).Scan(&id); err != nil {
return 0, fmt.Errorf("读取积分模板 %s 的 id: %w", prof.Code, err)
}
if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil {
return 0, fmt.Errorf("清理积分模板 %s 的明细: %w", prof.Code, err)
}
for _, r := range prof.Rules {
if _, err := tx.Exec(`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
id, r.Rank, r.Points); err != nil {
return 0, fmt.Errorf("写入积分模板 %s 的名次 %d: %w", prof.Code, r.Rank, err)
}
}
return id, nil
}
func deleteProfile(tx *sql.Tx, code string) error {
var id int64
if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil {
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("读取积分模板 %s: %w", code, err)
}
refs, err := profileRefs(tx, id)
if err != nil {
return err
}
if len(refs) > 0 {
return fmt.Errorf("%w: 模板 %s 仍被 %s 引用", ErrProfileInUse, code, strings.Join(refs, "、"))
}
if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil {
return fmt.Errorf("删除积分模板 %s 的明细: %w", code, err)
}
if _, err := tx.Exec(`DELETE FROM scoring_profiles WHERE id = ?`, id); err != nil {
return fmt.Errorf("删除积分模板 %s: %w", code, err)
}
return nil
}
func profileRefs(tx *sql.Tx, profileID int64) ([]string, error) {
var refs []string
var channels int
if err := tx.QueryRow(`SELECT COUNT(*) FROM event_score_channels WHERE profile_id = ?`, profileID).Scan(&channels); err != nil {
return nil, fmt.Errorf("统计模板引用: %w", err)
}
if channels > 0 {
refs = append(refs, fmt.Sprintf("%d 个计分通道", channels))
}
var sports int
if err := tx.QueryRow(`SELECT COUNT(*) FROM sports WHERE default_profile_id = ?`, profileID).Scan(&sports); err != nil {
return nil, fmt.Errorf("统计模板引用: %w", err)
}
if sports > 0 {
refs = append(refs, fmt.Sprintf("%d 个大项的默认模板", sports))
}
return refs, nil
}
func currentScored(tx *sql.Tx, eventID int64) (bool, error) {
var v int
if err := tx.QueryRow(`SELECT scored FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return false, fmt.Errorf("读取项目计分开关: %w", err)
}
return v != 0, nil
}
func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) {
var v float64
if err := tx.QueryRow(`SELECT multiplier FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return 0, fmt.Errorf("读取项目权重: %w", err)
}
return v, nil
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+381
View File
@@ -0,0 +1,381 @@
package rules
import (
"database/sql"
"errors"
"path/filepath"
"reflect"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
const applyPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 导入目标 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 12 }
- { rank: 2, points: 9 }
- { rank: 3, points: 7 }
- { rank: 4, points: 4 }
- { rank: 5, points: 3 }
- { rank: 6, points: 2 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
- { rank: 3, points: 8 }
- { rank: 4, points: 4 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: special_top3
name: 特别项目前三名
awardRanks: 3
tieMode: higher
rules:
- { rank: 1, points: 5 }
- { rank: 2, points: 3 }
- { rank: 3, points: 1 }
recordBonus:
enabled: false
mode: max
levels:
- { code: meet, name: 赛会纪录, points: 2 }
manualAdjustment:
enabled: false
reasonRequired: true
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: team_top6 }
eventOverrides:
- eventCode: EVT_IND
name: 个人示例
multiplier: 2
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- eventCode: EVT_TEAM
channels: []
`
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() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return db
}
// seedEvents 建一个示例大项与两个项目,并给团体项目挂一条通道,用于验证通道删除。
func seedEvents(t *testing.T, db *sql.DB) {
t.Helper()
res, err := db.Exec(`INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
if err != nil {
t.Fatalf("插入大项: %v", err)
}
sportID, err := res.LastInsertId()
if err != nil {
t.Fatalf("读取大项 id: %v", err)
}
for _, ev := range []struct {
code, name, eventType, resultType, compare string
}{
{"EVT_IND", "个人示例", "individual", "time", "asc"},
{"EVT_TEAM", "团体示例", "team", "none", "none"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, ?, ?, ?, 1, 1)`, sportID, ev.code, ev.name, ev.eventType, ev.resultType, ev.compare); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
var teamProfileID int64
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfileID); err != nil {
t.Fatalf("查询团体模板: %v", err)
}
var teamEventID int64
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_TEAM'`).Scan(&teamEventID); err != nil {
t.Fatalf("查询团体项目: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 0)`, teamEventID, teamProfileID); err != nil {
t.Fatalf("插入团体通道: %v", err)
}
}
func TestApplyWritesChangesAndIsIdempotent(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cur.Pack.Profiles) != 2 {
t.Fatalf("迁移后应有 2 个模板,实际 %d", len(cur.Pack.Profiles))
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if cs.Empty() {
t.Fatal("差异不应为空")
}
// 预览阶段不落库。
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Fatalf("Diff 不应写库,模板数 = %d", got)
}
res, err := Apply(db, cs)
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.ProfilesWritten != 3 || res.ProfilesDeleted != 0 || res.ChannelsWritten != 2 {
t.Errorf("落库统计异常: %+v", res)
}
if got := countRows(t, db, "scoring_profiles"); got != 3 {
t.Errorf("模板数 = %d,期望 3", got)
}
if got := queryFloat(t, db, `SELECT points FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6' AND r.rank = 1`); got != 12 {
t.Errorf("个人模板第 1 名分值 = %v,期望 12", got)
}
if got := countRows(t, db, "event_score_channels"); got != 2 {
t.Errorf("通道数 = %d,期望 2(EVT_IND 双通道、EVT_TEAM 清空)", got)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 2 {
t.Errorf("EVT_IND 通道数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_TEAM'`); got != 0 {
t.Errorf("EVT_TEAM 通道数 = %d,期望 0", got)
}
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_IND'`); got != 2 {
t.Errorf("EVT_IND 权重 = %v,期望 2", got)
}
if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'record_bonus.levels'`); got == "" {
t.Error("破纪录档位应写入 settings")
}
// 重复导入必须幂等:第二次差异为空,Apply 不再写任何东西。
cur2, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs2, err := Diff(cur2, next)
if err != nil {
t.Fatalf("再次 Diff: %v", err)
}
if !cs2.Empty() {
t.Fatalf("重复导入应无差异: %+v", cs2)
}
if _, err := Apply(db, cs2); err != nil {
t.Fatalf("重复 Apply: %v", err)
}
}
func TestExportRoundTripThroughDB(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); err != nil {
t.Fatalf("Apply: %v", err)
}
meta := Meta{Code: "demo-rules", Name: "导入目标", Version: "1.0.0", UpdatedAt: "2026-01-01", Author: "tester"}
exported, err := Export(db, meta)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exported.Meta != meta {
t.Errorf("导出 meta 错误: %+v", exported.Meta)
}
if len(exported.Profiles) != 3 {
t.Fatalf("导出模板数 = %d,期望 3", len(exported.Profiles))
}
if exported.Profiles[0].Code != "individual_top6" || exported.Profiles[0].Rules[0].Points != 12 {
t.Errorf("导出模板内容错误: %+v", exported.Profiles[0])
}
var indOverride *EventOverride
for i := range exported.EventOverrides {
if exported.EventOverrides[i].EventCode == "EVT_IND" {
indOverride = &exported.EventOverrides[i]
}
}
if indOverride == nil {
t.Fatal("导出的项目例外里应包含 EVT_IND")
}
if indOverride.Multiplier == nil || *indOverride.Multiplier != 2 {
t.Errorf("EVT_IND 权重导出错误: %+v", indOverride.Multiplier)
}
if indOverride.Channels == nil || len(*indOverride.Channels) != 2 {
t.Errorf("EVT_IND 通道导出错误: %+v", indOverride.Channels)
}
raw, err := Marshal(exported)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
reparsed, err := Parse(raw)
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
if !reflect.DeepEqual(exported, reparsed) {
t.Errorf("导出结果往返不一致:\n%+v\n%+v", exported, reparsed)
}
}
func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
// 这份规则包没有 team_top6,而 EVT_TEAM 的通道仍引用它,删除必须被拒绝。
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 精简规则包 }
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 1 }
`
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); !errors.Is(err, ErrProfileInUse) {
t.Fatalf("期望 ErrProfileInUse,实际 %v", err)
}
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Errorf("事务应回滚,模板数 = %d,期望 2", got)
}
}
func TestApplyNilChangeSet(t *testing.T) {
db := openEventDB(t)
if _, err := Apply(db, nil); !errors.Is(err, ErrNilChangeSet) {
t.Fatalf("期望 ErrNilChangeSet,实际 %v", err)
}
}
// 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。
func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) {
db := openEventDB(t)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res, err := Apply(db, &ChangeSet{Next: next})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.ProfilesWritten != 3 {
t.Errorf("模板写入数 = %d,期望 3", res.ProfilesWritten)
}
if got := countRows(t, db, "scoring_profiles"); got != 3 {
t.Errorf("模板数 = %d,期望 3", got)
}
}
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 queryInt(t *testing.T, db *sql.DB, query string) int {
t.Helper()
var n int
if err := db.QueryRow(query).Scan(&n); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return n
}
func queryFloat(t *testing.T, db *sql.DB, query string) float64 {
t.Helper()
var v float64
if err := db.QueryRow(query).Scan(&v); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return v
}
func queryString(t *testing.T, db *sql.DB, query string) string {
t.Helper()
var v string
if err := db.QueryRow(query).Scan(&v); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ""
}
t.Fatalf("查询 %q: %v", query, err)
}
return v
}
+120
View File
@@ -0,0 +1,120 @@
package rules
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 本文件是规则包类型与 domain 类型之间的唯一转换入口;
// 转换只搬字段、不填默认值(默认值统一由 Canonical 负责),避免两处口径打架。
// ToDomain 把模板转成 domain.ScoringProfile;id 由调用方(仓储层)决定。
func (p Profile) ToDomain(id int64) domain.ScoringProfile {
tie := p.TieMode
if tie == "" {
tie = domain.TieSkip
}
bonus := p.RecordBonusMode
if bonus == "" {
bonus = domain.RecordBonusMax
}
rules := make([]domain.ScoringRule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, domain.ScoringRule{Rank: r.Rank, Points: r.Points})
}
return domain.ScoringProfile{
ID: id,
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: tie,
RecordBonusMode: bonus,
Rules: rules,
}
}
// ProfileFromDomain 把 domain 模板转回规则包类型。
func ProfileFromDomain(p domain.ScoringProfile) Profile {
rules := make([]Rule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, Rule{Rank: r.Rank, Points: r.Points})
}
return Profile{
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: p.TieMode,
RecordBonusMode: p.RecordBonusMode,
Rules: rules,
}
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
Aggregate: c.Aggregate,
SortOrder: sortOrder,
}
}
// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。
func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel {
return Channel{
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
Aggregate: c.Aggregate,
}
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
func (p *RulePack) ProfileIndex() map[string]Profile {
out := make(map[string]Profile, len(p.Profiles))
for _, prof := range p.Profiles {
out[prof.Code] = prof
}
return out
}
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
}
// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。
func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
mode := r.Mode
if mode == "" {
mode = domain.RecordBonusMax
}
levels := make([]domain.RecordLevel, 0, len(r.Levels))
for _, lv := range r.Levels {
levels = append(levels, domain.RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels}
}
// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。
func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus {
levels := make([]RecordLevel, 0, len(c.Levels))
for _, lv := range c.Levels {
levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels}
}
// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。
func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig {
return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired}
}
// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。
func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment {
return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired}
}
+256
View File
@@ -0,0 +1,256 @@
package rules
import (
"errors"
"fmt"
"sort"
)
// ChangeKind 是差异类型。
type ChangeKind string
const (
ChangeAdded ChangeKind = "added"
ChangeModified ChangeKind = "modified"
ChangeRemoved ChangeKind = "removed"
)
// ProfileChange 是一个积分模板的增/改/删。
type ProfileChange struct {
Code string `json:"code"`
Kind ChangeKind `json:"kind"`
Before *Profile `json:"before,omitempty"`
After *Profile `json:"after,omitempty"`
// Fields 列出被修改的字段,便于前端只高亮变化项。
Fields []string `json:"fields,omitempty"`
}
// ChannelChange 是一个项目计分通道的变化。
type ChannelChange struct {
EventCode string `json:"eventCode"`
EventName string `json:"eventName,omitempty"`
Kind ChangeKind `json:"kind"`
Before []Channel `json:"before,omitempty"`
After []Channel `json:"after,omitempty"`
}
// SettingChange 是一条赛事设置的变化。
type SettingChange struct {
Key string `json:"key"`
Before string `json:"before"`
After string `json:"after"`
}
// ChangeSet 是导入预览的结果,也是 Apply 的输入。
type ChangeSet struct {
Profiles []ProfileChange `json:"profiles,omitempty"`
Channels []ChannelChange `json:"channels,omitempty"`
Settings []SettingChange `json:"settings,omitempty"`
// UnknownEvents 是规则包里出现、但赛事库中不存在的项目编码;Apply 会跳过它们。
UnknownEvents []string `json:"unknownEvents,omitempty"`
// Next 是目标规则包;Apply 以它为完整目标状态落库。不参与 JSON 序列化。
Next *RulePack `json:"-"`
}
// Empty 判断这次导入是否真的会改动任何东西。
// 只用于预览展示;Apply 始终以 Next 为目标全量同步,不依赖它做短路。
func (cs *ChangeSet) Empty() bool {
return cs == nil || (len(cs.Profiles) == 0 && len(cs.Channels) == 0 && len(cs.Settings) == 0)
}
// Diff 计算「当前赛事库 → 目标规则包」的差异,只读不落库。
func Diff(cur *Snapshot, next *RulePack) (*ChangeSet, error) {
if cur == nil || cur.Pack == nil {
return nil, errors.New("rules: 当前快照为空,无法比对差异")
}
if next == nil {
return nil, fmt.Errorf("%w: 目标规则包为空", ErrNotRulePack)
}
target := next.Canonical()
if err := target.Validate(); err != nil {
return nil, err
}
cs := &ChangeSet{Next: &target}
cs.Profiles = diffProfiles(cur.Pack, &target)
targets, unknown, err := resolveTargets(cur, &target)
if err != nil {
return nil, err
}
cs.UnknownEvents = unknown
cs.Channels = diffChannels(cur, targets)
cs.Settings = diffSettings(cur.Pack, &target)
return cs, nil
}
func diffProfiles(cur, next *RulePack) []ProfileChange {
curIndex := cur.ProfileIndex()
nextIndex := next.ProfileIndex()
codes := make([]string, 0, len(curIndex)+len(nextIndex))
seen := map[string]bool{}
for code := range curIndex {
if !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
for code := range nextIndex {
if !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
sort.Strings(codes)
var out []ProfileChange
for _, code := range codes {
before, hasBefore := curIndex[code]
after, hasAfter := nextIndex[code]
switch {
case !hasBefore:
out = append(out, ProfileChange{Code: code, Kind: ChangeAdded, After: &after})
case !hasAfter:
out = append(out, ProfileChange{Code: code, Kind: ChangeRemoved, Before: &before})
default:
if fields := profileDiffFields(before, after); len(fields) > 0 {
out = append(out, ProfileChange{Code: code, Kind: ChangeModified, Before: &before, After: &after, Fields: fields})
}
}
}
return out
}
func profileDiffFields(a, b Profile) []string {
var fields []string
if a.Name != b.Name {
fields = append(fields, "name")
}
if a.AwardRanks != b.AwardRanks {
fields = append(fields, "awardRanks")
}
if a.LastPlaceZero != b.LastPlaceZero {
fields = append(fields, "lastPlaceZero")
}
if a.TieMode != b.TieMode {
fields = append(fields, "tieMode")
}
if a.RecordBonusMode != b.RecordBonusMode {
fields = append(fields, "recordBonusMode")
}
if !rulesEqual(a.Rules, b.Rules) {
fields = append(fields, "rules")
}
return fields
}
func rulesEqual(a, b []Rule) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// resolveTargets 把规则包展开成「项目编码 → 目标通道」。
// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。
// 没被规则包提到的项目不出现在结果里,表示「保持原样」。
func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) {
overrideByCode := make(map[string]EventOverride, len(next.EventOverrides))
for _, o := range next.EventOverrides {
overrideByCode[o.EventCode] = o
}
targets := make(map[string][]Channel)
for _, ev := range cur.Events {
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
}
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
}
}
}
var unknown []string
for _, o := range next.EventOverrides {
if _, ok := cur.EventByCode(o.EventCode); !ok {
unknown = append(unknown, o.EventCode)
}
}
sort.Strings(unknown)
return targets, unknown, nil
}
func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
curByCode := make(map[string]EventInfo, len(cur.Events))
for _, ev := range cur.Events {
curByCode[ev.Code] = ev
}
codes := make([]string, 0, len(targets))
for code := range targets {
codes = append(codes, code)
}
sort.Strings(codes)
var out []ChannelChange
for _, code := range codes {
ev := curByCode[code]
before := ev.Channels
after := targets[code]
if channelsEqual(before, after) {
continue
}
kind := ChangeModified
switch {
case len(before) == 0:
kind = ChangeAdded
case len(after) == 0:
kind = ChangeRemoved
}
out = append(out, ChannelChange{
EventCode: code,
EventName: ev.Name,
Kind: kind,
Before: append([]Channel(nil), before...),
After: append([]Channel(nil), after...),
})
}
return out
}
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func diffSettings(cur, next *RulePack) []SettingChange {
curSettings := settingsFromPack(cur)
nextSettings := settingsFromPack(next)
var out []SettingChange
for _, key := range settingsKeys() {
before, after := curSettings[key], nextSettings[key]
if before != after {
out = append(out, SettingChange{Key: key, Before: before, After: after})
}
}
return out
}
+203
View File
@@ -0,0 +1,203 @@
package rules
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
func testSnapshot(t *testing.T, profiles []Profile, events []EventInfo) *Snapshot {
t.Helper()
pack := RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
Meta: Meta{Code: "demo", Name: "当前规则"},
Standings: Standings{IndividualToTeamWeight: 1, TieBreak: DefaultTieBreak},
Profiles: profiles,
}
canonical := pack.Canonical()
return &Snapshot{Pack: &canonical, Events: events}
}
func currentSnapshot(t *testing.T) *Snapshot {
t.Helper()
return testSnapshot(t,
[]Profile{
{
Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 6, TieMode: domain.TieSkip,
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}},
},
{
Code: "team_top6", Name: "团体项目前六名", AwardRanks: 2, TieMode: domain.TieSkip,
Rules: []Rule{{1, 15}, {2, 10}},
},
},
[]EventInfo{
{ID: 1, Code: "EVT_IND", Name: "个人示例", EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
{ID: 2, Code: "EVT_TEAM", Name: "团体示例", EventType: domain.EventTypeTeam, ResultType: domain.ResultTypeNone,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
{ID: 3, Code: "EVT_MATCH", Name: "对抗示例", EventType: domain.EventTypeMatch, ResultType: domain.ResultTypeNone,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
})
}
const nextPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 目标规则包 }
standings:
individualToTeamWeight: 2
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 12 }
- { rank: 2, points: 9 }
- { rank: 3, points: 7 }
- { rank: 4, points: 4 }
- { rank: 5, points: 3 }
- { rank: 6, points: 2 }
- code: special_top3
name: 特别项目前三名
awardRanks: 3
tieMode: higher
rules:
- { rank: 1, points: 5 }
- { rank: 2, points: 3 }
- { rank: 3, points: 1 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: special_top3 }
eventOverrides:
- eventCode: EVT_MATCH
channels: []
- eventCode: EVT_GHOST
channels:
- { scope: team, basis: manual, profile: special_top3 }
`
func TestDiffDetectsAddModifyRemove(t *testing.T) {
cur := currentSnapshot(t)
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if cs.Empty() {
t.Fatal("差异不应为空")
}
kinds := map[string]ChangeKind{}
for _, p := range cs.Profiles {
kinds[p.Code] = p.Kind
}
if kinds["individual_top6"] != ChangeModified {
t.Errorf("individual_top6 应为 modified,实际 %q", kinds["individual_top6"])
}
if kinds["team_top6"] != ChangeRemoved {
t.Errorf("team_top6 应为 removed,实际 %q", kinds["team_top6"])
}
if kinds["special_top3"] != ChangeAdded {
t.Errorf("special_top3 应为 added,实际 %q", kinds["special_top3"])
}
for _, p := range cs.Profiles {
if p.Code == "individual_top6" && len(p.Fields) == 0 {
t.Error("修改的模板应列出变化字段")
}
if p.Code == "special_top3" && (p.Before != nil || p.After == nil) {
t.Error("新增模板的 Before 应为空、After 应有值")
}
}
channels := map[string]ChannelChange{}
for _, c := range cs.Channels {
channels[c.EventCode] = c
}
if c, ok := channels["EVT_TEAM"]; !ok || c.Kind != ChangeModified {
t.Errorf("EVT_TEAM 通道应变为 special_top3: %+v", c)
}
if c, ok := channels["EVT_MATCH"]; !ok || c.Kind != ChangeRemoved || len(c.After) != 0 {
t.Errorf("EVT_MATCH 通道应被清空: %+v", c)
}
if _, ok := channels["EVT_IND"]; ok {
t.Error("EVT_IND 通道未变化,不应出现在差异里")
}
if len(cs.UnknownEvents) != 1 || cs.UnknownEvents[0] != "EVT_GHOST" {
t.Errorf("未知项目识别错误: %v", cs.UnknownEvents)
}
if len(cs.Settings) != 1 || cs.Settings[0].Key != settingIndividualToTeamWeight {
t.Errorf("设置差异错误: %+v", cs.Settings)
}
if cs.Settings[0].Before != "1" || cs.Settings[0].After != "2" {
t.Errorf("权重差异值错误: %+v", cs.Settings[0])
}
}
func TestDiffIdenticalIsEmpty(t *testing.T) {
cur := currentSnapshot(t)
same, err := Diff(cur, cur.Pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !same.Empty() {
t.Errorf("同一份规则包不应产生差异: %+v", same)
}
}
func TestDiffRejectsInvalidPack(t *testing.T) {
cur := currentSnapshot(t)
bad, err := Parse([]byte(minimalPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
bad.FormatVersion = 99
if _, err := Diff(cur, bad); err == nil {
t.Fatal("非法规则包应报错")
}
}
func TestResolveTargetsPrecedence(t *testing.T) {
cur := currentSnapshot(t)
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
targets, unknown, err := resolveTargets(cur, next)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Profile != "individual_top6" {
t.Errorf("模板展开错误: %+v", got)
}
if got := targets["EVT_TEAM"]; len(got) != 1 || got[0].Profile != "special_top3" {
t.Errorf("模板展开错误: %+v", got)
}
if got, ok := targets["EVT_MATCH"]; !ok || len(got) != 0 {
t.Errorf("override 的 channels: [] 应清空通道: %+v", got)
}
if _, ok := targets["EVT_GHOST"]; ok {
t.Error("库里不存在的项目不应出现在目标状态里")
}
if len(unknown) != 1 || unknown[0] != "EVT_GHOST" {
t.Errorf("未知项目识别错误: %v", unknown)
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package rules 实现积分规则包(YAML)的解析、校验、导出与导入落地。
//
// 规则包是积分的唯一权威来源:分值、录取名次、并列口径、计分通道全部来自规则包,
// 代码里不写死任何数字(见 devdocs/02-积分与排名规则.md 第 7 节)。
//
// 典型用法:
//
// pack, err := rules.Parse(raw) // 解析 + 校验
// cur, err := rules.Load(db) // 读取赛事库当前规则
// cs, err := rules.Diff(cur, pack) // 预览差异,不落库
// if !cs.Empty() {
// res, err := rules.Apply(db, cs) // 确认后写入;全量重算由调用方触发
// }
// out, err := rules.Export(db, meta) // 导出当前规则
// yaml, err := rules.Marshal(out)
//
// 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。
package rules
+71
View File
@@ -0,0 +1,71 @@
package rules
import (
"bytes"
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
// 导出文件的注释头:规则包可能含赛事敏感信息,不进版本库(rules/README.md)。
const header = `# 积分规则包 · scoreboard-rules v1
# 由系统导出,可手工编辑后再导入;导入前请先预览差异。
# 注意:本文件可能含赛事敏感信息,请勿提交到版本库。
`
// Parse 解析并校验规则包 YAML。未知字段一律报错,避免拼错的配置被静默忽略。
func Parse(data []byte) (*RulePack, error) {
if len(bytes.TrimSpace(data)) == 0 {
return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var pack RulePack
if err := dec.Decode(&pack); err != nil {
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack)
}
return nil, fmt.Errorf("解析规则包 YAML: %w", err)
}
// 规则包只允许一个文档;多文档容易让人以为后面的也会生效。
var extra any
if err := dec.Decode(&extra); err == nil {
return nil, fmt.Errorf("%w: 只允许一个 YAML 文档", ErrNotRulePack)
} else if !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("解析规则包 YAML: %w", err)
}
canonical := pack.Canonical()
if err := canonical.Validate(); err != nil {
return nil, err
}
return &canonical, nil
}
// Marshal 把规则包序列化成带注释头的 YAML;输出是规范化后的结果,可安全往返。
func Marshal(pack *RulePack) ([]byte, error) {
if pack == nil {
return nil, fmt.Errorf("%w: 规则包为空", ErrNotRulePack)
}
canonical := pack.Canonical()
if err := canonical.Validate(); err != nil {
return nil, err
}
var buf bytes.Buffer
buf.WriteString(header)
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&canonical); err != nil {
return nil, fmt.Errorf("序列化规则包 YAML: %w", err)
}
if err := enc.Close(); err != nil {
return nil, fmt.Errorf("序列化规则包 YAML: %w", err)
}
return buf.Bytes(), nil
}
+294
View File
@@ -0,0 +1,294 @@
package rules
import (
"errors"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 测试一律用内联 YAML,不读 rules/ 目录(该目录含敏感信息且不进版本库)。
const validPackYAML = `format: scoreboard-rules
formatVersion: 1
meta:
code: demo-rules
name: 示例规则包
version: 1.0.0
updatedAt: "2026-01-01"
author: tester
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
lastPlaceZero: false
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 3 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: team_top6
name: 团体项目前六名
awardRanks: 2
tieMode: average
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
recordBonus:
enabled: false
mode: max
levels:
- { code: meet, name: 赛会纪录, points: 2 }
manualAdjustment:
enabled: false
reasonRequired: true
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: team_top6 }
eventOverrides:
- eventCode: EVT_DEMO
name: 示例项目
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- eventCode: EVT_NOSCORE
name: 只排名不计分
scored: false
`
const minimalPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
`
func TestParseValidPack(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if pack.Format != FormatName || pack.FormatVersion != FormatVersion {
t.Fatalf("格式字段解析错误: %+v", pack)
}
if pack.Meta.Code != "demo-rules" || pack.Meta.Name != "示例规则包" {
t.Errorf("meta 解析错误: %+v", pack.Meta)
}
if len(pack.Profiles) != 2 {
t.Fatalf("模板数量 = %d,期望 2", len(pack.Profiles))
}
if got := pack.Profiles[0].Rules[0]; got != (Rule{Rank: 1, Points: 10}) {
t.Errorf("名次 1 分值 = %+v,期望 10", got)
}
if pack.Profiles[0].TieMode != domain.TieSkip || pack.Profiles[1].TieMode != domain.TieAverage {
t.Errorf("并列口径解析错误: %q / %q", pack.Profiles[0].TieMode, pack.Profiles[1].TieMode)
}
// 未配置 recordBonusMode 时按 max 补齐(与 domain 默认一致)。
if pack.Profiles[0].RecordBonusMode != domain.RecordBonusMax {
t.Errorf("recordBonusMode 缺省值 = %q,期望 max", pack.Profiles[0].RecordBonusMode)
}
if len(pack.ChannelTemplates) != 2 || len(pack.EventOverrides) != 2 {
t.Errorf("通道模板/项目例外数量错误: %d / %d", len(pack.ChannelTemplates), len(pack.EventOverrides))
}
if pack.EventOverrides[1].Scored == nil || *pack.EventOverrides[1].Scored {
t.Errorf("EVT_NOSCORE 的 scored 应为 false: %+v", pack.EventOverrides[1].Scored)
}
if !pack.Standings.MedalCountsTeamEventsEnabled() || !pack.Standings.ShowIndividualBoardEnabled() {
t.Error("榜单开关缺省值应为 true")
}
}
func TestParseFillsDefaults(t *testing.T) {
pack, err := Parse([]byte(minimalPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if pack.Standings.IndividualToTeamWeight != 1 {
t.Errorf("未配置权重时应补 1,实际 %v", pack.Standings.IndividualToTeamWeight)
}
if !reflect.DeepEqual(pack.Standings.TieBreak, DefaultTieBreak) {
t.Errorf("未配置 tieBreak 时应补默认值,实际 %v", pack.Standings.TieBreak)
}
if pack.RecordBonus.Mode != domain.RecordBonusMax {
t.Errorf("未配置 recordBonus.mode 时应补 max,实际 %q", pack.RecordBonus.Mode)
}
}
func TestMarshalRoundTripStable(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
first, err := Marshal(pack)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if !strings.HasPrefix(string(first), "# 积分规则包") {
t.Errorf("导出缺少注释头:\n%s", first)
}
again, err := Parse(first)
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
second, err := Marshal(again)
if err != nil {
t.Fatalf("再次 Marshal: %v", err)
}
if string(first) != string(second) {
t.Errorf("往返序列化不稳定:\n--- 第一次 ---\n%s\n--- 第二次 ---\n%s", first, second)
}
if !reflect.DeepEqual(pack, again) {
t.Errorf("往返后规则包内容不一致:\n%+v\n%+v", pack, again)
}
}
func TestParseRejectsBadInput(t *testing.T) {
cases := []struct {
name string
yaml string
sentinel error
}{
{"空文件", " \n", ErrNotRulePack},
{"格式标识不符", strings.Replace(validPackYAML, "format: scoreboard-rules", "format: something-else", 1), ErrNotRulePack},
{"版本不兼容", strings.Replace(validPackYAML, "formatVersion: 1", "formatVersion: 2", 1), ErrIncompatibleVersion},
{"未知字段", validPackYAML + "\nbogusField: 1\n", nil},
{"缺 meta.code", strings.Replace(validPackYAML, " code: demo-rules\n", "", 1), ErrValidation},
{"缺 meta.name", strings.Replace(validPackYAML, " name: 示例规则包\n", "", 1), ErrValidation},
{"模板编码重复", strings.Replace(validPackYAML, " - code: team_top6", " - code: individual_top6", 1), ErrValidation},
{"录取名次为负", strings.Replace(validPackYAML, "awardRanks: 6", "awardRanks: -1", 1), ErrValidation},
{"并列口径非法", strings.Replace(validPackYAML, "tieMode: skip", "tieMode: random", 1), ErrValidation},
{"分值非负", strings.Replace(validPackYAML, " - { rank: 1, points: 10 }", " - { rank: 1, points: -10 }", 1), ErrValidation},
{"名次重复", strings.Replace(validPackYAML, " - { rank: 2, points: 8 }", " - { rank: 1, points: 8 }", 1), ErrValidation},
{"通道 scope 非法", strings.Replace(validPackYAML, "{ scope: athlete, basis: result, profile: individual_top6 }", "{ scope: unit, basis: result, profile: individual_top6 }", 1), ErrValidation},
{"通道 basis 非法", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: guess, profile: team_top6 }", 1), ErrValidation},
{"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation},
{"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation},
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Parse([]byte(tc.yaml))
if err == nil {
t.Fatal("期望报错,实际解析成功")
}
if tc.sentinel != nil && !errors.Is(err, tc.sentinel) {
t.Fatalf("期望 %v,实际 %v", tc.sentinel, err)
}
if tc.sentinel == nil && errors.Is(err, ErrIncompatibleVersion) {
t.Fatalf("未知字段不应报版本错误: %v", err)
}
})
}
}
func TestParseRejectsStructuralProblems(t *testing.T) {
cases := map[string]string{
"没有模板": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles: []
`,
"模板没有通道": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: { eventType: team }
channels: []
`,
"模板匹配条件为空": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: {}
channels:
- { scope: team, basis: manual, profile: p1 }
`,
"同一项目通道重复": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
eventOverrides:
- eventCode: EVT_X
channels:
- { scope: team, basis: manual, profile: p1 }
- { scope: team, basis: manual, profile: p1 }
`,
}
for name, yaml := range cases {
t.Run(name, func(t *testing.T) {
if _, err := Parse([]byte(yaml)); !errors.Is(err, ErrValidation) {
t.Fatalf("期望 ErrValidation,实际 %v", err)
}
})
}
}
func TestParseRejectsMultipleDocuments(t *testing.T) {
if _, err := Parse([]byte(validPackYAML + "---\nformat: scoreboard-rules\n")); !errors.Is(err, ErrNotRulePack) {
t.Fatalf("多文档应报 ErrNotRulePack,实际 %v", err)
}
}
func TestProfileDomainConversion(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
dp := pack.Profiles[0].ToDomain(7)
if dp.ID != 7 || dp.Code != "individual_top6" || dp.TieMode != domain.TieSkip {
t.Errorf("ToDomain 结果错误: %+v", dp)
}
if len(dp.Rules) != 6 || dp.Rules[0] != (domain.ScoringRule{Rank: 1, Points: 10}) {
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
}
back := ProfileFromDomain(dp)
if !reflect.DeepEqual(back, pack.Profiles[0]) {
t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
}
if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
t.Errorf("通道往返不一致: %+v", got)
}
in := pack.Standings.ToStandingsInput()
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
t.Errorf("榜单口径转换错误: %+v", in)
}
rb := pack.RecordBonus.ToDomain()
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
t.Errorf("破纪录配置转换错误: %+v", rb)
}
if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
+155
View File
@@ -0,0 +1,155 @@
package rules
import (
"encoding/json"
"sort"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 赛事库 settings 表里的键名;与 migrations/event/0011_seed.sql 保持一致。
const (
settingIndividualToTeamWeight = "standings.individual_to_team_weight"
settingTieBreak = "standings.tie_break"
settingMedalCountsTeamEvents = "standings.medal_counts_team_events"
settingShowIndividualBoard = "standings.show_individual_board"
settingShowIndividualMedals = "standings.show_individual_medals"
settingRecordBonusEnabled = "record_bonus.enabled"
settingRecordBonusMode = "record_bonus.mode"
settingRecordBonusLevels = "record_bonus.levels"
settingManualEnabled = "manual_adjustment.enabled"
settingManualReasonRequired = "manual_adjustment.reason_required"
)
// settingsFromPack 把规则包里能落到 settings 表的部分拍平成键值对。
// 破纪录档位库里没有表,用 JSON 存在 record_bonus.levels 里,避免导入时丢配置。
func settingsFromPack(pack *RulePack) map[string]string {
levels, err := json.Marshal(pack.RecordBonus.Levels)
if err != nil {
levels = []byte("[]")
}
return map[string]string{
settingIndividualToTeamWeight: formatFloat(pack.Standings.IndividualToTeamWeight),
settingTieBreak: strings.Join(pack.Standings.TieBreak, ","),
settingMedalCountsTeamEvents: formatBool(pack.Standings.MedalCountsTeamEventsEnabled()),
settingShowIndividualBoard: formatBool(pack.Standings.ShowIndividualBoardEnabled()),
settingShowIndividualMedals: formatBool(pack.Standings.ShowIndividualMedalsEnabled()),
settingRecordBonusEnabled: formatBool(pack.RecordBonus.Enabled),
settingRecordBonusMode: string(pack.RecordBonus.Mode),
settingRecordBonusLevels: string(levels),
settingManualEnabled: formatBool(pack.ManualAdjustment.Enabled),
settingManualReasonRequired: formatBool(pack.ManualAdjustment.ReasonRequired),
}
}
// settingsKeys 返回固定顺序的键名,保证差异预览与落库顺序稳定。
func settingsKeys() []string {
keys := []string{
settingIndividualToTeamWeight,
settingTieBreak,
settingMedalCountsTeamEvents,
settingShowIndividualBoard,
settingShowIndividualMedals,
settingRecordBonusEnabled,
settingRecordBonusMode,
settingRecordBonusLevels,
settingManualEnabled,
settingManualReasonRequired,
}
sort.Strings(keys)
return keys
}
func standingsFromSettings(m map[string]string) Standings {
s := Standings{
IndividualToTeamWeight: settingFloat(m, settingIndividualToTeamWeight, 1),
TieBreak: splitList(m[settingTieBreak]),
MedalCountsTeamEvents: BoolPtr(settingBool(m, settingMedalCountsTeamEvents, true)),
ShowIndividualBoard: BoolPtr(settingBool(m, settingShowIndividualBoard, true)),
ShowIndividualMedals: BoolPtr(settingBool(m, settingShowIndividualMedals, true)),
}
if len(s.TieBreak) == 0 {
s.TieBreak = append([]string(nil), DefaultTieBreak...)
}
return s
}
func recordBonusFromSettings(m map[string]string) RecordBonus {
rb := RecordBonus{
Enabled: settingBool(m, settingRecordBonusEnabled, false),
Mode: modeOrDefault(m[settingRecordBonusMode]),
}
if raw := strings.TrimSpace(m[settingRecordBonusLevels]); raw != "" {
var levels []RecordLevel
if err := json.Unmarshal([]byte(raw), &levels); err == nil {
rb.Levels = levels
}
}
return rb
}
func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment {
return ManualAdjustment{
Enabled: settingBool(m, settingManualEnabled, false),
ReasonRequired: settingBool(m, settingManualReasonRequired, true),
}
}
func settingFloat(m map[string]string, key string, def float64) float64 {
raw, ok := m[key]
if !ok {
return def
}
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return def
}
return v
}
func settingBool(m map[string]string, key string, def bool) bool {
raw, ok := m[key]
if !ok {
return def
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return def
}
}
func formatFloat(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
func formatBool(v bool) string {
if v {
return "true"
}
return "false"
}
func splitList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func modeOrDefault(raw string) domain.RecordBonusMode {
if strings.TrimSpace(raw) == "" {
return domain.RecordBonusMax
}
return domain.RecordBonusMode(strings.TrimSpace(raw))
}
+279
View File
@@ -0,0 +1,279 @@
package rules
import (
"database/sql"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// EventInfo 是展开通道模板所需的最小项目信息。
type EventInfo struct {
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
Channels []Channel
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
// 差异预览需要项目目录才能把 channelTemplates 展开到具体项目。
type Snapshot struct {
Pack *RulePack
Events []EventInfo
}
// EventByCode 按项目编码查找项目。
func (s *Snapshot) EventByCode(code string) (EventInfo, bool) {
for _, e := range s.Events {
if e.Code == code {
return e, true
}
}
return EventInfo{}, false
}
// Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。
func Load(db *sql.DB) (*Snapshot, error) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
}
profileByID, profiles, err := loadProfiles(db)
if err != nil {
return nil, err
}
pack.Profiles = profiles
settings, err := loadSettings(db)
if err != nil {
return nil, err
}
pack.Standings = standingsFromSettings(settings)
pack.RecordBonus = recordBonusFromSettings(settings)
pack.ManualAdjustment = manualAdjustmentFromSettings(settings)
events, err := loadEvents(db, profileByID)
if err != nil {
return nil, err
}
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目
// 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。
for _, e := range events {
needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1
if !needOverride {
continue
}
ov := EventOverride{EventCode: e.Code, Name: e.Name}
if !e.Scored {
ov.Scored = BoolPtr(false)
}
if e.Multiplier != 1 {
ov.Multiplier = Float64Ptr(e.Multiplier)
}
if len(e.Channels) > 0 {
ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...))
}
pack.EventOverrides = append(pack.EventOverrides, ov)
}
canonical := pack.Canonical()
return &Snapshot{Pack: &canonical, Events: events}, nil
}
// Export 从赛事库生成规则包;meta 由调用方提供(库里不存规则包元数据)。
func Export(db *sql.DB, meta Meta) (*RulePack, error) {
snap, err := Load(db)
if err != nil {
return nil, err
}
pack := *snap.Pack
pack.Meta = meta
if strings.TrimSpace(pack.Meta.Code) == "" {
pack.Meta.Code = "exported-rules"
}
if strings.TrimSpace(pack.Meta.Name) == "" {
if name, err := settingByKey(db, "event.name"); err == nil && strings.TrimSpace(name) != "" {
pack.Meta.Name = name
} else {
pack.Meta.Name = "未命名赛事 · 积分规则"
}
}
canonical := pack.Canonical()
return &canonical, nil
}
func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
rows, err := db.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板: %w", err)
}
defer func() { _ = rows.Close() }()
byID := map[int64]string{}
byCode := map[string]int{}
var profiles []Profile
for rows.Next() {
var (
id, awardRanks int64
code, name string
tie, bonus string
lastPlaceZeroRaw int
)
if err := rows.Scan(&id, &code, &name, &tie, &bonus, &awardRanks, &lastPlaceZeroRaw); err != nil {
return nil, nil, fmt.Errorf("读取积分模板: %w", err)
}
byID[id] = code
byCode[code] = len(profiles)
profiles = append(profiles, Profile{
Code: code,
Name: name,
AwardRanks: int(awardRanks),
LastPlaceZero: lastPlaceZeroRaw != 0,
TieMode: domain.TieMode(tie),
RecordBonusMode: domain.RecordBonusMode(bonus),
})
}
if err := rows.Err(); err != nil {
return nil, nil, fmt.Errorf("读取积分模板: %w", err)
}
ruleRows, err := db.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板明细: %w", err)
}
defer func() { _ = ruleRows.Close() }()
for ruleRows.Next() {
var (
profileID int64
rank int
points float64
)
if err := ruleRows.Scan(&profileID, &rank, &points); err != nil {
return nil, nil, fmt.Errorf("读取积分模板明细: %w", err)
}
code, ok := byID[profileID]
if !ok {
continue
}
idx := byCode[code]
profiles[idx].Rules = append(profiles[idx].Rules, Rule{Rank: rank, Points: points})
}
if err := ruleRows.Err(); err != nil {
return nil, nil, fmt.Errorf("读取积分模板明细: %w", err)
}
return byID, profiles, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("查询赛事设置: %w", err)
}
defer func() { _ = rows.Close() }()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("读取赛事设置: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取赛事设置: %w", err)
}
return out, nil
}
func settingByKey(db *sql.DB, key string) (string, error) {
var value string
if err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value); err != nil {
return "", err
}
return value, nil
}
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
return nil, fmt.Errorf("查询项目: %w", err)
}
defer func() { _ = rows.Close() }()
events := []EventInfo{}
index := map[int64]int{}
for rows.Next() {
var (
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
events = append(events, EventInfo{
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("查询计分通道: %w", err)
}
defer func() { _ = chRows.Close() }()
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
if !ok {
continue
}
events[idx].Channels = append(events[idx].Channels, Channel{
Scope: domain.Scope(scope),
Basis: domain.Basis(basis),
Profile: code,
})
}
if err := chRows.Err(); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
return events, nil
}
+221
View File
@@ -0,0 +1,221 @@
package rules
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 格式标识与版本:不符即报错,不做隐式转换(devdocs 02 §7.3)。
const (
FormatName = "scoreboard-rules"
FormatVersion = 1
)
// 榜单排序键;规则包里写错键名要报错,而不是静默忽略。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
)
// DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。
var DefaultTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
var knownTieBreakKeys = map[string]bool{
TieBreakPoints: true,
TieBreakGold: true,
TieBreakSilver: true,
TieBreakBronze: true,
TieBreakTeamCode: true,
TieBreakName: true,
}
// RulePack 是一个完整的积分规则包,对应 rules/<赛事>.yaml。
type RulePack struct {
Format string `yaml:"format" json:"format"`
FormatVersion int `yaml:"formatVersion" json:"formatVersion"`
Meta Meta `yaml:"meta" json:"meta"`
Standings Standings `yaml:"standings" json:"standings"`
Profiles []Profile `yaml:"profiles" json:"profiles"`
RecordBonus RecordBonus `yaml:"recordBonus" json:"recordBonus"`
ManualAdjustment ManualAdjustment `yaml:"manualAdjustment" json:"manualAdjustment"`
ChannelTemplates []ChannelTemplate `yaml:"channelTemplates,omitempty" json:"channelTemplates,omitempty"`
EventOverrides []EventOverride `yaml:"eventOverrides,omitempty" json:"eventOverrides,omitempty"`
}
// Meta 是规则包的标识信息;库中不存规则包元数据,导出时由调用方提供。
type Meta struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
UpdatedAt string `yaml:"updatedAt,omitempty" json:"updatedAt,omitempty"`
Author string `yaml:"author,omitempty" json:"author,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
}
// Standings 是榜单口径。三个开关用指针,未配置时才能区分「缺省」与「显式 false」。
type Standings struct {
IndividualToTeamWeight float64 `yaml:"individualToTeamWeight" json:"individualToTeamWeight"`
TieBreak []string `yaml:"tieBreak" json:"tieBreak"`
MedalCountsTeamEvents *bool `yaml:"medalCountsTeamEvents,omitempty" json:"medalCountsTeamEvents,omitempty"`
ShowIndividualBoard *bool `yaml:"showIndividualBoard,omitempty" json:"showIndividualBoard,omitempty"`
ShowIndividualMedals *bool `yaml:"showIndividualMedals,omitempty" json:"showIndividualMedals,omitempty"`
}
// MedalCountsTeamEventsEnabled 缺省为 true(团体奖牌榜计入团体项目奖牌)。
func (s Standings) MedalCountsTeamEventsEnabled() bool {
return boolOrDefault(s.MedalCountsTeamEvents, true)
}
// ShowIndividualBoardEnabled 缺省为 true。
func (s Standings) ShowIndividualBoardEnabled() bool {
return boolOrDefault(s.ShowIndividualBoard, true)
}
// ShowIndividualMedalsEnabled 缺省为 true。
func (s Standings) ShowIndividualMedalsEnabled() bool {
return boolOrDefault(s.ShowIndividualMedals, true)
}
// Profile 是积分模板;rules 是「名次 → 分值」明细,分值一律来自规则包。
type Profile struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
AwardRanks int `yaml:"awardRanks" json:"awardRanks"`
LastPlaceZero bool `yaml:"lastPlaceZero,omitempty" json:"lastPlaceZero,omitempty"`
TieMode domain.TieMode `yaml:"tieMode" json:"tieMode"`
RecordBonusMode domain.RecordBonusMode `yaml:"recordBonusMode,omitempty" json:"recordBonusMode,omitempty"`
Rules []Rule `yaml:"rules" json:"rules"`
}
// Rule 是积分模板的一条明细。
type Rule struct {
Rank int `yaml:"rank" json:"rank"`
Points float64 `yaml:"points" json:"points"`
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"`
}
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`
ResultType domain.ResultType `yaml:"resultType,omitempty" json:"resultType,omitempty"`
SportCode string `yaml:"sportCode,omitempty" json:"sportCode,omitempty"`
}
// ChannelTemplate 是按项目类型套用的计分通道。
type ChannelTemplate struct {
Match ChannelMatch `yaml:"match" json:"match"`
Channels []Channel `yaml:"channels" json:"channels"`
}
// EventOverride 是单个项目的例外配置。
//
// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分
// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。
type EventOverride struct {
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
}
// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。
type RecordBonus struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Mode domain.RecordBonusMode `yaml:"mode,omitempty" json:"mode,omitempty"`
Levels []RecordLevel `yaml:"levels,omitempty" json:"levels,omitempty"`
}
// RecordLevel 是破纪录的一个档位及其加分值。
type RecordLevel struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
Points float64 `yaml:"points" json:"points"`
}
// ManualAdjustment 是人工调整开关。
type ManualAdjustment struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ReasonRequired bool `yaml:"reasonRequired" json:"reasonRequired"`
}
// BoolPtr 便于构造三态布尔(导出时写显式值)。
func BoolPtr(v bool) *bool { return &v }
// Float64Ptr 便于构造三态数值。
func Float64Ptr(v float64) *float64 { return &v }
// ChannelListPtr 便于构造三态通道列表。
func ChannelListPtr(v []Channel) *[]Channel { return &v }
// Canonical 返回补齐缺省值后的副本。缺省口径集中在这里,保证 Parse / Marshal / Apply 三处一致。
func (p RulePack) Canonical() RulePack {
out := p
out.Standings.TieBreak = append([]string(nil), p.Standings.TieBreak...)
if len(out.Standings.TieBreak) == 0 {
out.Standings.TieBreak = append([]string(nil), DefaultTieBreak...)
}
// 权重 0 与未填等价:引擎对 <=0 按 1 处理,这里先对齐,避免导出与解析来回变。
if out.Standings.IndividualToTeamWeight == 0 {
out.Standings.IndividualToTeamWeight = 1
}
out.Profiles = make([]Profile, len(p.Profiles))
for i, prof := range p.Profiles {
out.Profiles[i] = prof
out.Profiles[i].Rules = append([]Rule{}, prof.Rules...)
if out.Profiles[i].TieMode == "" {
out.Profiles[i].TieMode = domain.TieSkip
}
if out.Profiles[i].RecordBonusMode == "" {
out.Profiles[i].RecordBonusMode = domain.RecordBonusMax
}
}
if out.RecordBonus.Mode == "" {
out.RecordBonus.Mode = domain.RecordBonusMax
}
out.RecordBonus.Levels = append([]RecordLevel{}, p.RecordBonus.Levels...)
out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates))
for i, t := range p.ChannelTemplates {
out.ChannelTemplates[i] = t
out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels)
}
out.EventOverrides = make([]EventOverride, len(p.EventOverrides))
for i, o := range p.EventOverrides {
out.EventOverrides[i] = o
if o.Channels != nil {
cs := canonicalChannels(*o.Channels)
out.EventOverrides[i].Channels = &cs
}
}
return out
}
func canonicalChannels(in []Channel) []Channel {
out := make([]Channel, len(in))
for i, ch := range in {
out[i] = ch
if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" {
out[i].Aggregate = domain.AggregateSum
}
}
return out
}
func boolOrDefault(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
+272
View File
@@ -0,0 +1,272 @@
package rules
import (
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 哨兵错误:调用方据此区分「格式不对」「版本不兼容」「内容非法」。
var (
ErrNotRulePack = errors.New("rules: 不是积分规则包")
ErrIncompatibleVersion = errors.New("rules: 规则包格式版本不兼容")
ErrValidation = errors.New("rules: 规则包校验失败")
ErrProfileInUse = errors.New("rules: 积分模板仍被引用,不能删除")
ErrNilChangeSet = errors.New("rules: 变更集或目标规则包为空,无法导入")
)
// Issue 是一条校验问题;Path 用点号路径定位到 YAML 中的位置。
type Issue struct {
Path string `json:"path"`
Msg string `json:"msg"`
}
// ValidationError 汇总一次校验中的全部问题,避免「改一个报一个」。
type ValidationError struct {
Issues []Issue `json:"issues"`
}
func (e *ValidationError) Error() string {
if len(e.Issues) == 0 {
return ErrValidation.Error()
}
parts := make([]string, 0, len(e.Issues))
for _, is := range e.Issues {
parts = append(parts, is.Path+": "+is.Msg)
}
return ErrValidation.Error() + ": " + strings.Join(parts, "; ")
}
// Unwrap 让 errors.Is(err, ErrValidation) 成立。
func (e *ValidationError) Unwrap() error { return ErrValidation }
type issueList struct {
issues []Issue
}
func (l *issueList) addf(path, format string, args ...any) {
l.issues = append(l.issues, Issue{Path: path, Msg: fmt.Sprintf(format, args...)})
}
// Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
func (p *RulePack) Validate() error {
if p.Format != FormatName {
return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName)
}
if p.FormatVersion != FormatVersion {
return fmt.Errorf("%w: formatVersion=%d,本程序只支持 %d,不做隐式转换",
ErrIncompatibleVersion, p.FormatVersion, FormatVersion)
}
var l issueList
if strings.TrimSpace(p.Meta.Code) == "" {
l.addf("meta.code", "不能为空")
}
if strings.TrimSpace(p.Meta.Name) == "" {
l.addf("meta.name", "不能为空")
}
if p.Standings.IndividualToTeamWeight < 0 {
l.addf("standings.individualToTeamWeight", "不能为负,当前 %v", p.Standings.IndividualToTeamWeight)
}
validateTieBreak(&l, p.Standings.TieBreak)
profiles := make(map[string]bool, len(p.Profiles))
if len(p.Profiles) == 0 {
l.addf("profiles", "至少要有一个积分模板")
}
for i, prof := range p.Profiles {
path := fmt.Sprintf("profiles[%d]", i)
validateProfile(&l, path, prof, profiles)
}
validateRecordBonus(&l, p.RecordBonus)
validateTemplates(&l, p.ChannelTemplates, profiles)
validateOverrides(&l, p.EventOverrides, profiles)
if len(l.issues) > 0 {
return &ValidationError{Issues: l.issues}
}
return nil
}
func validateTieBreak(l *issueList, tieBreak []string) {
seen := make(map[string]bool, len(tieBreak))
for i, key := range tieBreak {
path := fmt.Sprintf("standings.tieBreak[%d]", i)
if !knownTieBreakKeys[key] {
l.addf(path, "未知的排序键 %q", key)
continue
}
if seen[key] {
l.addf(path, "排序键 %q 重复", key)
}
seen[key] = true
}
}
func validateProfile(l *issueList, path string, prof Profile, seen map[string]bool) {
if strings.TrimSpace(prof.Code) == "" {
l.addf(path+".code", "不能为空")
} else if seen[prof.Code] {
l.addf(path+".code", "模板编码 %q 重复", prof.Code)
} else {
seen[prof.Code] = true
}
if strings.TrimSpace(prof.Name) == "" {
l.addf(path+".name", "不能为空")
}
if prof.AwardRanks < 0 {
l.addf(path+".awardRanks", "不能为负,当前 %d", prof.AwardRanks)
}
if prof.TieMode != domain.TieSkip && prof.TieMode != domain.TieAverage && prof.TieMode != domain.TieHigher {
l.addf(path+".tieMode", "只能是 skip / average / higher,当前 %q", prof.TieMode)
}
if prof.RecordBonusMode != "" && prof.RecordBonusMode != domain.RecordBonusMax && prof.RecordBonusMode != domain.RecordBonusSum {
l.addf(path+".recordBonusMode", "只能是 max / sum,当前 %q", prof.RecordBonusMode)
}
if prof.AwardRanks > 0 && len(prof.Rules) == 0 {
l.addf(path+".rules", "awardRanks=%d 却没有分值明细", prof.AwardRanks)
}
ranks := make(map[int]bool, len(prof.Rules))
for j, r := range prof.Rules {
rpath := fmt.Sprintf("%s.rules[%d]", path, j)
if r.Rank < 1 {
l.addf(rpath+".rank", "名次必须 >= 1,当前 %d", r.Rank)
} else if ranks[r.Rank] {
l.addf(rpath+".rank", "名次 %d 重复", r.Rank)
}
ranks[r.Rank] = true
if r.Points < 0 {
l.addf(rpath+".points", "分值不能为负,当前 %v", r.Points)
}
}
}
func validateRecordBonus(l *issueList, rb RecordBonus) {
if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum {
l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode)
}
if rb.Enabled && len(rb.Levels) == 0 {
l.addf("recordBonus.levels", "启用破纪录加分却没有配置档位")
}
seen := make(map[string]bool, len(rb.Levels))
for i, lv := range rb.Levels {
path := fmt.Sprintf("recordBonus.levels[%d]", i)
if strings.TrimSpace(lv.Code) == "" {
l.addf(path+".code", "不能为空")
} else if seen[lv.Code] {
l.addf(path+".code", "档位编码 %q 重复", lv.Code)
} else {
seen[lv.Code] = true
}
if strings.TrimSpace(lv.Name) == "" {
l.addf(path+".name", "不能为空")
}
if lv.Points < 0 {
l.addf(path+".points", "分值不能为负,当前 %v", lv.Points)
}
}
}
func validateTemplates(l *issueList, templates []ChannelTemplate, profiles map[string]bool) {
seen := make(map[string]bool, len(templates))
for i, t := range templates {
path := fmt.Sprintf("channelTemplates[%d]", i)
key := matchKey(t.Match)
if key == "" {
l.addf(path+".match", "至少要指定 eventType / resultType / sportCode 之一")
} else if seen[key] {
l.addf(path+".match", "匹配条件 %s 重复", key)
}
seen[key] = true
if len(t.Channels) == 0 {
l.addf(path+".channels", "模板至少要有一个计分通道")
}
validateChannels(l, path+".channels", t.Channels, profiles)
}
}
func validateOverrides(l *issueList, overrides []EventOverride, profiles map[string]bool) {
seen := make(map[string]bool, len(overrides))
for i, o := range overrides {
path := fmt.Sprintf("eventOverrides[%d]", i)
if strings.TrimSpace(o.EventCode) == "" {
l.addf(path+".eventCode", "不能为空")
} else if seen[o.EventCode] {
l.addf(path+".eventCode", "项目编码 %q 重复", o.EventCode)
} else {
seen[o.EventCode] = true
}
if o.Multiplier != nil && *o.Multiplier < 0 {
l.addf(path+".multiplier", "不能为负,当前 %v", *o.Multiplier)
}
if o.Channels != nil {
validateChannels(l, path+".channels", *o.Channels, profiles)
}
}
}
func validateChannels(l *issueList, path string, channels []Channel, profiles map[string]bool) {
seen := make(map[string]bool, len(channels))
for i, ch := range channels {
cpath := fmt.Sprintf("%s[%d]", path, i)
if ch.Scope != domain.ScopeAthlete && ch.Scope != domain.ScopeTeam {
l.addf(cpath+".scope", "只能是 athlete / team,当前 %q", ch.Scope)
}
switch ch.Basis {
case domain.BasisResult, domain.BasisManual, domain.BasisAggregate:
default:
l.addf(cpath+".basis", "只能是 result / manual / aggregate,当前 %q", ch.Basis)
}
if strings.TrimSpace(ch.Profile) == "" {
l.addf(cpath+".profile", "不能为空")
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
switch ch.Aggregate {
case "":
case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount:
if ch.Basis != domain.BasisAggregate {
l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate")
}
default:
l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
if seen[key] {
l.addf(cpath, "同一组配置里出现重复通道 %s", key)
}
seen[key] = true
}
}
func matchKey(m ChannelMatch) string {
var parts []string
if m.EventType != "" {
parts = append(parts, "eventType="+string(m.EventType))
}
if m.ResultType != "" {
parts = append(parts, "resultType="+string(m.ResultType))
}
if m.SportCode != "" {
parts = append(parts, "sportCode="+m.SportCode)
}
return strings.Join(parts, ",")
}
func matchEvent(m ChannelMatch, e EventInfo) bool {
if m.EventType != "" && m.EventType != e.EventType {
return false
}
if m.ResultType != "" && m.ResultType != e.ResultType {
return false
}
if m.SportCode != "" && m.SportCode != e.SportCode {
return false
}
return true
}
+147 -7
View File
@@ -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
}
+90
View File
@@ -0,0 +1,90 @@
# vendor/fonts —— 本地字体(离线部署,禁止 CDN)
现场部署无外网,所有中文字体必须随仓库分发、从本目录本地加载。本目录只放**子集化后的 woff2**,
原始大字体(每套 8–12 MB)不入库。
## 字体清单
| id | 显示名 | font-family | 字重 | 单套合计 |
| --- | --- | --- | --- | --- |
| `noto-sans` | Noto Sans SC | `Noto Sans SC` | 400 / 700 | 2.72 MB |
| `noto-serif` | Noto Serif SC | `Noto Serif SC` | 400 / 700 | 3.71 MB |
| `misans` | MiSans | `MiSans` | 400 / 700 | 1.76 MB |
| `source-han-sans` | 思源黑体 Source Han Sans CN | `Source Han Sans CN` | 400 / 700 | 2.72 MB |
合计 8 个 woff2、10.91 MB。逐文件体积与 sha256 见 [`manifest.json`](./manifest.json),
每个文件均 ≤ 4 MB / 套的目标(最大为 Noto Serif SC 的 3.71 MB)。
## 来源与授权
| 字体 | 来源 | 授权 |
| --- | --- | --- |
| Noto Sans SC | Google Noto(`~/Fonts/noto-sans/sc`) | SIL Open Font License 1.1 |
| Noto Serif SC | Google Noto(`~/Fonts/noto-serif/sc/SubsetOTF/SC`) | SIL Open Font License 1.1 |
| MiSans | 小米(`~/Fonts/MiSans/ttf`) | MiSans 字体知识产权许可协议 |
| 思源黑体 Source Han Sans CN | Adobe(`~/Fonts/思源黑体`) | SIL Open Font License 1.1 |
> **授权说明**:本项目为**内部赛事**使用,不对外公开发布,字体授权不构成障碍
> (见 `devdocs/06-大屏端设计.md` 9.1、`devdocs/07-管理面板设计.md` 9)。
> 若将来对外分发,需逐字体复核许可:Noto / 思源黑体为 OFL,可自由分发;
> **MiSans 为厂商许可,公开分发前必须另行确认**。OFL 字体保留其原始版权声明,
> 本目录未附 LICENSE 副本,需要时从 `~/Fonts/<字体目录>/LICENSE` 取回。
## 子集化命令
依赖 `fonttools` + `brotli`(生成 woff2 必需):
```bash
pip install fonttools brotli
# 系统 Python 受 PEP 668 保护时,用带系统包的虚拟环境:
python3 -m venv --system-site-packages ~/.tmp/fontenv
~/.tmp/fontenv/bin/pip install brotli
PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh
```
脚本会:生成字符集 → 逐字体跑 `fontTools.subset` → 重新生成 `manifest.json`。
**子集字符集**:ASCII 可打印字符 + GB2312 全部合法码位(一级 3755 + 二级 3008 汉字,
含全角标点、希腊/俄文、全角数字)+ 常用中文标点与数字符号,共 **7553 字符**。
**子集化参数**:
```bash
python -m fontTools.subset <源字体> \
--flavor=woff2 \
--layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk' \
--name-IDs='*' --name-languages='*' \
--recommended-glyphs --drop-tables+=DSIG \
--text="$(字符集)" --output-file=<id>-<weight>.woff2
```
去掉 CJK 竖排(`vert`/`vrt2`)等用不到的 OpenType 特性以压体积;保留 `kern`/`liga` 等排版特性。
## 如何使用
`web/src/shared/styles/fonts.css` 为每套字体写好 `@font-face`(`font-display: swap`)
与 `--font-<id>` 变量。在全局样式入口加一行即可生效:
```css
@import './fonts.css';
```
CSS 里的 `url()` 是相对 `web/src/shared/styles/` 的 `../../../../vendor/fonts/...`。
Vite 构建时会把这些 woff2 作为资源打进产物(输出到 `server/internal/webui/assets/`),
运行时不再依赖仓库外的路径。
## 如何新增字体
1. 把源字体(`.otf`/`.ttf`)放到 `~/Fonts/<目录>/`(不要直接复制进仓库)。
2. 在 `tools/fonts.conf` 追加一行:`id|显示名|font-family|分类|授权|来源说明|Regular 源文件|Bold 源文件`。
3. 重跑 `PYTHON=... vendor/fonts/tools/subset-fonts.sh`,它会生成 `<id>-regular.woff2`、
`<id>-bold.woff2` 并更新 `manifest.json`。
4. 在 `web/src/shared/styles/fonts.css` 补对应 `@font-face` 和 `--font-<id>` 变量。
5. 检查单套合计 ≤ 4 MB;超了就精简字符集或换更轻的字重。
## 已知限制
- MiSans 缺 `・`(U+30FB,日文片假名中点,GB2312 内含),其余 7552 个字符齐全;
中文场景无影响。其余三套字符集全覆盖。
- 只做 Regular + Bold 两档;如需 Medium/Light,按上节流程加字重(需扩展 `fonts.conf` 的字段格式)。
- 字体文件已子集化,**不要**用原始 `~/Fonts` 大字体覆盖本目录文件。
+139
View File
@@ -0,0 +1,139 @@
{
"version": 1,
"generatedAt": "2026-09-09",
"generator": "vendor/fonts/tools/subset-fonts.sh",
"subset": {
"charset": "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号",
"charCount": 7553,
"format": "woff2",
"layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk"
},
"totalBytes": 11434920,
"totalSize": "10.91 MB",
"fonts": [
{
"id": "noto-sans",
"displayName": "Noto Sans SC",
"fontFamily": "Noto Sans SC",
"cssVar": "--font-noto-sans",
"category": "sans-serif",
"license": "OFL-1.1",
"source": "Google Noto Sans SC (noto-sans/sc)",
"totalBytes": 2849388,
"totalSize": "2.72 MB",
"weights": [
{
"weight": 400,
"style": "normal",
"file": "noto-sans-regular.woff2",
"path": "vendor/fonts/noto-sans-regular.woff2",
"bytes": 1399176,
"size": "1.33 MB",
"sha256": "a126c29405324acddf2cf3d47ba76762f1fa39cd7b1c0be6dc98f4fd26cfafb8"
},
{
"weight": 700,
"style": "normal",
"file": "noto-sans-bold.woff2",
"path": "vendor/fonts/noto-sans-bold.woff2",
"bytes": 1450212,
"size": "1.38 MB",
"sha256": "206e958255a9e961243fbbd44c52ba05fb166e452de315fa3967e3c78bb8d499"
}
]
},
{
"id": "noto-serif",
"displayName": "Noto Serif SC",
"fontFamily": "Noto Serif SC",
"cssVar": "--font-noto-serif",
"category": "serif",
"license": "OFL-1.1",
"source": "Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)",
"totalBytes": 3887184,
"totalSize": "3.71 MB",
"weights": [
{
"weight": 400,
"style": "normal",
"file": "noto-serif-regular.woff2",
"path": "vendor/fonts/noto-serif-regular.woff2",
"bytes": 1892872,
"size": "1.81 MB",
"sha256": "666a88ddc3f50f8ae999991d9ebd5eeb58ad267f6f9b38b75a580599e0fe72c2"
},
{
"weight": 700,
"style": "normal",
"file": "noto-serif-bold.woff2",
"path": "vendor/fonts/noto-serif-bold.woff2",
"bytes": 1994312,
"size": "1.90 MB",
"sha256": "7a8fc67787af8d4a8ff88b63f672b888ff29746ad08af0c4b4b31273d15ffce3"
}
]
},
{
"id": "misans",
"displayName": "MiSans",
"fontFamily": "MiSans",
"cssVar": "--font-misans",
"category": "sans-serif",
"license": "MiSans Font License",
"source": "小米 MiSans (MiSans/ttf)",
"totalBytes": 1841888,
"totalSize": "1.76 MB",
"weights": [
{
"weight": 400,
"style": "normal",
"file": "misans-regular.woff2",
"path": "vendor/fonts/misans-regular.woff2",
"bytes": 910956,
"size": "890 KB",
"sha256": "ca7c73c33dde913833012f16334e59d8f6a3cdea856e537a52ff27b95ab01493"
},
{
"weight": 700,
"style": "normal",
"file": "misans-bold.woff2",
"path": "vendor/fonts/misans-bold.woff2",
"bytes": 930932,
"size": "909 KB",
"sha256": "18119cd03ed6931c339d8f7cfc4bcf2c7258ee6a9e2191b43d75dcc26ef64f41"
}
]
},
{
"id": "source-han-sans",
"displayName": "思源黑体 Source Han Sans CN",
"fontFamily": "Source Han Sans CN",
"cssVar": "--font-source-han-sans",
"category": "sans-serif",
"license": "OFL-1.1",
"source": "Adobe Source Han Sans CN (思源黑体)",
"totalBytes": 2856460,
"totalSize": "2.72 MB",
"weights": [
{
"weight": 400,
"style": "normal",
"file": "source-han-sans-regular.woff2",
"path": "vendor/fonts/source-han-sans-regular.woff2",
"bytes": 1399540,
"size": "1.33 MB",
"sha256": "f85482af4eb07d66df271e475f00484f764326c1cb4fb9b65c90589bc4588875"
},
{
"weight": 700,
"style": "normal",
"file": "source-han-sans-bold.woff2",
"path": "vendor/fonts/source-han-sans-bold.woff2",
"bytes": 1456920,
"size": "1.39 MB",
"sha256": "3e2e3f54161b8a96c59001d40855835539ef97f6c80c58cdb93fcc4f2baae4dd"
}
]
}
]
}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
# 字体清单(subset-fonts.sh 与 gen-manifest.py 的唯一数据源)
#
# 字段(| 分隔):
# id | 显示名 | font-family | 分类 | 授权 | 来源说明 | Regular 源文件 | Bold 源文件
#
# 源文件相对 $FONT_SRC(默认 ~/Fonts)。
# 新增字体:在下面加一行,然后重跑
# PYTHON=<带 fonttools+brotli 的 python> vendor/fonts/tools/subset-fonts.sh
noto-sans|Noto Sans SC|Noto Sans SC|sans-serif|OFL-1.1|Google Noto Sans SC (noto-sans/sc)|noto-sans/sc/NotoSansSC-Regular.otf|noto-sans/sc/NotoSansSC-Bold.otf
noto-serif|Noto Serif SC|Noto Serif SC|serif|OFL-1.1|Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Regular.otf|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Bold.otf
misans|MiSans|MiSans|sans-serif|MiSans Font License|小米 MiSans (MiSans/ttf)|MiSans/ttf/MiSans-Regular.ttf|MiSans/ttf/MiSans-Bold.ttf
source-han-sans|思源黑体 Source Han Sans CN|Source Han Sans CN|sans-serif|OFL-1.1|Adobe Source Han Sans CN (思源黑体)|思源黑体/SourceHanSansCN-Regular.otf|思源黑体/SourceHanSansCN-Bold.otf
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""生成 vendor/fonts/manifest.json。
数据源:vendor/fonts/tools/fonts.conf(字体元数据)+ vendor/fonts/*.woff2(实际产物)。
每次跑完 subset-fonts.sh 会自动调用;也可单独运行:
python3 vendor/fonts/tools/gen-manifest.py
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from datetime import date
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT_DIR = HERE.parent
ROOT = OUT_DIR.parent.parent # 仓库根,用于生成 vendor/fonts/... 形式的相对路径
CONF = HERE / "fonts.conf"
WEIGHTS = (("regular", 400), ("bold", 700))
SUBSET_DESC = "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号"
def charset_count() -> int:
"""复用 subset-fonts.sh 的字符集生成逻辑,保证清单与产物一致。"""
code = (
"chars=set()\n"
"for c in range(0x20,0x7F): chars.add(chr(c))\n"
"for hi in range(0xA1,0xFF):\n"
" for lo in range(0xA1,0xFF):\n"
" try: chars.add(bytes((hi,lo)).decode('gb2312'))\n"
" except UnicodeDecodeError: pass\n"
"chars.update(' ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇')\n"
"chars.update('「」『』【】〔〕〖〗《》〈〉')\n"
"chars.update('0123456789')\n"
"chars.update('!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~')\n"
"print(len(chars))\n"
)
return int(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout)
def human(n: int) -> str:
return f"{n / 1024 / 1024:.2f} MB" if n >= 1024 * 1024 else f"{n / 1024:.0f} KB"
def read_conf() -> list[dict[str, str]]:
rows = []
for line in CONF.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
fid, name, family, category, license_, source, reg, bold = (p.strip() for p in line.split("|"))
rows.append(
{
"id": fid,
"displayName": name,
"fontFamily": family,
"category": category,
"license": license_,
"source": source,
"regular": reg,
"bold": bold,
}
)
return rows
def main() -> int:
fonts = []
grand_total = 0
for row in read_conf():
weights = []
font_total = 0
for weight_name, weight_class in WEIGHTS:
path = OUT_DIR / f"{row['id']}-{weight_name}.woff2"
if not path.is_file():
print(f"缺少产物: {path},请先运行 subset-fonts.sh", file=sys.stderr)
return 1
data = path.read_bytes()
size = len(data)
font_total += size
weights.append(
{
"weight": weight_class,
"style": "normal",
"file": path.name,
"path": str(path.relative_to(ROOT)),
"bytes": size,
"size": human(size),
"sha256": hashlib.sha256(data).hexdigest(),
}
)
grand_total += font_total
fonts.append(
{
"id": row["id"],
"displayName": row["displayName"],
"fontFamily": row["fontFamily"],
"cssVar": f"--font-{row['id']}",
"category": row["category"],
"license": row["license"],
"source": row["source"],
"totalBytes": font_total,
"totalSize": human(font_total),
"weights": weights,
}
)
manifest = {
"version": 1,
"generatedAt": date.today().isoformat(),
"generator": "vendor/fonts/tools/subset-fonts.sh",
"subset": {
"charset": SUBSET_DESC,
"charCount": charset_count(),
"format": "woff2",
"layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk",
},
"totalBytes": grand_total,
"totalSize": human(grand_total),
"fonts": fonts,
}
out = OUT_DIR / "manifest.json"
out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"manifest.json: {len(fonts)} 套字体,{len(fonts) * 2} 个文件,合计 {human(grand_total)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""把子集字体内部的 name 表 / OS2 字重统一成 CSS 期望的规范值。
为什么需要:思源黑体的老版本 OTF 把每个字重写成独立族名
(`Source Han Sans CN Regular` / `Source Han Sans CN Bold`),
MiSans 的 usWeightClass 是 330/630 这类非标准值。
@font-face 的 `font-family` 描述符虽然能屏蔽内部族名,但保持一致更省心,
也避免字体被单独安装/引用时族名对不上。
用法(由 subset-fonts.sh 调用):
python3 vendor/fonts/tools/normalize-names.py
"""
from __future__ import annotations
import sys
from pathlib import Path
from fontTools.ttLib import TTFont
HERE = Path(__file__).resolve().parent
OUT_DIR = HERE.parent
CONF = HERE / "fonts.conf"
WEIGHTS = (("regular", "Regular", 400), ("bold", "Bold", 700))
STALE_NAME_IDS = (1, 2, 3, 4, 6, 16, 17, 21, 22)
PLATFORMS = ((3, 1, 0x409), (1, 0, 0)) # Windows / Mac,英文
def set_name(name, name_id: int, value: str) -> None:
for platform_id, plat_enc_id, lang_id in PLATFORMS:
name.setName(value, name_id, platform_id, plat_enc_id, lang_id)
def normalize(path: Path, family: str, style: str, weight: int) -> None:
font = TTFont(path)
name = font["name"]
for record in list(name.names):
if record.nameID in STALE_NAME_IDS:
name.removeNames(record.nameID, record.platformID, record.platEncID, record.langID)
full_name = family if style == "Regular" else f"{family} {style}"
set_name(name, 1, family) # family
set_name(name, 2, style) # subfamily
set_name(name, 3, f"{full_name}:subset") # unique id
set_name(name, 4, full_name) # full name
set_name(name, 6, full_name.replace(" ", "")) # postscript name
set_name(name, 16, family) # typographic family
set_name(name, 17, style) # typographic subfamily
os2 = font["OS/2"]
os2.usWeightClass = weight
if weight >= 700:
os2.fsSelection = (os2.fsSelection & ~0x40) | 0x20 # BOLD
font["head"].macStyle |= 0x01
else:
os2.fsSelection = (os2.fsSelection & ~0x20) | 0x40 # REGULAR
font["head"].macStyle &= ~0x01
font.flavor = "woff2"
font.save(path)
font.close()
def main() -> int:
for line in CONF.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
fid, _, family = [p.strip() for p in line.split("|")][:3]
for weight_name, style, weight_class in WEIGHTS:
path = OUT_DIR / f"{fid}-{weight_name}.woff2"
if not path.is_file():
print(f"缺少产物: {path}", file=sys.stderr)
return 1
normalize(path, family, style, weight_class)
print(f" {path.name:32s} -> {family} / {style} ({weight_class})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# 字体子集化脚本 —— 把 ~/Fonts 下的中文字体裁剪成 woff2 并写入 vendor/fonts/
#
# 用途:离线部署,字体必须随仓库分发,禁止 CDN。
# 产物:vendor/fonts/<id>-<weight>.woff2 + vendor/fonts/manifest.json
#
# 依赖:python3 + fonttools + brotli
# pip install fonttools brotli
# # 系统 Python 受 PEP 668 保护时用虚拟环境:
# # python3 -m venv --system-site-packages ~/.tmp/fontenv
# # ~/.tmp/fontenv/bin/pip install brotli
# # PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh
#
# 用法:
# vendor/fonts/tools/subset-fonts.sh
# FONT_SRC=~/Fonts PYTHON=python3 vendor/fonts/tools/subset-fonts.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FONT_SRC="${FONT_SRC:-$HOME/Fonts}"
PYTHON="${PYTHON:-python3}"
FONTS_CONF="$SCRIPT_DIR/fonts.conf"
# 子集字符集:ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字/全角符号
CHARSET="$("$PYTHON" - <<'PY'
import sys
chars = set()
# 1) ASCII 可打印字符
for c in range(0x20, 0x7F):
chars.add(chr(c))
# 2) GB2312 全部合法双字节码位(一级 3755 + 二级 3008 汉字,及全角标点/希腊/俄文等)
for hi in range(0xA1, 0xFF):
for lo in range(0xA1, 0xFF):
try:
chars.add(bytes((hi, lo)).decode("gb2312"))
except UnicodeDecodeError:
pass
# 3) 常用中文标点 / 数字符号补充
chars.update(
" ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇"
"「」『』【】〔〕〖〗《》〈〉"
"0123456789"
"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"
)
sys.stdout.write("".join(sorted(chars)))
PY
)"
CHAR_COUNT="$(printf '%s' "$CHARSET" | "$PYTHON" -c 'import sys; print(len(sys.stdin.read()))')"
# 子集化参数:
# --flavor=woff2 输出 woff2(需 brotli)
# --layout-features 保留字距/连字等,丢弃 CJK 竖排等无用特性以压体积
# --name-IDs='*' 保留完整 name 表(含本地化族名)
# --recommended-glyphs 保留 .notdef/.null 等推荐字形
# --drop-tables+=DSIG 丢弃无用的数字签名表
SUBSET_OPTS=(
--flavor=woff2
--layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk'
--name-IDs='*'
--name-languages='*'
--recommended-glyphs
--drop-tables+=DSIG
)
mkdir -p "$OUT_DIR"
echo "charset: ${CHAR_COUNT} 个字符(ASCII + GB2312 一级/二级 + 中文标点 + 数字符号)"
while IFS='|' read -r id name family category license source_note reg_src bold_src; do
case "$id" in ''|'#'*) continue ;; esac
for pair in "regular:400:$reg_src" "bold:700:$bold_src"; do
IFS=':' read -r weight_name weight_class src_rel <<<"$pair"
src="$FONT_SRC/$src_rel"
out="$OUT_DIR/$id-$weight_name.woff2"
[ -f "$src" ] || { echo "缺少源字体: $src" >&2; exit 1; }
printf ' %-26s %s -> %s\n' "$id-$weight_name" "$(basename "$src")" "$(basename "$out")"
"$PYTHON" -m fontTools.subset "$src" "${SUBSET_OPTS[@]}" \
--text="$CHARSET" --output-file="$out"
done
done < "$FONTS_CONF"
"$PYTHON" "$SCRIPT_DIR/normalize-names.py"
"$PYTHON" "$SCRIPT_DIR/gen-manifest.py"
echo "完成。产物目录:$OUT_DIR"
ls -la "$OUT_DIR"/*.woff2
+81
View File
@@ -0,0 +1,81 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Accordion / Collapse Component
═══════════════════════════════════════════ */
.mx-accordion{
display:flex;flex-direction:column;
border:1px solid var(--mx-divider);
border-radius:var(--mx-r);
overflow:hidden;
}
/* Item */
.mx-accordion-item{
border-bottom:1px solid var(--mx-divider);
}
.mx-accordion-item:last-child{border-bottom:none}
/* Header / Trigger */
.mx-accordion-header{
display:flex;align-items:center;gap:10px;
padding:14px 16px;
cursor:pointer;
font-size:14px;font-weight:600;
color:var(--mx-text);
transition:all var(--mx-d1) var(--mx-ease);
-webkit-tap-highlight-color:transparent;
}
.mx-accordion-header:hover{background:var(--mx-s-hover)}
.mx-accordion-header:active{background:var(--mx-s-active)}
/* Icon slot */
.mx-accordion-header svg{flex-shrink:0;color:var(--mx-t2)}
.mx-accordion-header .mx-accordion-title{flex:1}
/* Arrow */
.mx-accordion-arrow{
color:var(--mx-t3);flex-shrink:0;
transition:transform var(--mx-d2) var(--mx-ease);
}
.mx-accordion-item.open .mx-accordion-arrow{transform:rotate(180deg)}
/* Content */
.mx-accordion-content{
max-height:0;overflow:hidden;
transition:max-height var(--mx-d3) var(--mx-ease);
}
.mx-accordion-item.open .mx-accordion-content{
max-height:500px;
}
.mx-accordion-body{
padding:0 16px 16px;
font-size:13px;line-height:1.6;
color:var(--mx-text-on-surface);
}
/* Card variant — separate cards */
.mx-accordion-card{border:none;gap:8px;background:none}
.mx-accordion-card .mx-accordion-item{
background:var(--mx-s2);
border:1px solid var(--mx-divider);
border-radius:var(--mx-r);
overflow:hidden;
}
.mx-accordion-card .mx-accordion-item:last-child{border-bottom:1px solid var(--mx-divider)}
/* Flush variant — no borders */
.mx-accordion-flush{border:none;border-radius:0}
.mx-accordion-flush .mx-accordion-item{border:none}
.mx-accordion-flush .mx-accordion-header{padding:12px 0}
/* Disabled */
.mx-accordion-header.disabled{
opacity:0.4;cursor:not-allowed;pointer-events:none;
}
/* Mobile */
@media(max-width:767px){
.mx-accordion-header{padding:14px 14px;min-height:48px}
.mx-accordion-body{font-size:14px}
.mx-accordion-card .mx-accordion-item{border-radius:var(--mx-r-sm)}
}
+53
View File
@@ -0,0 +1,53 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Activity List Component
═══════════════════════════════════════════ */
.mx-activity-item{
display:flex;align-items:flex-start;gap:12px;
padding:13px 0;
border-bottom:1px solid var(--mx-divider);
transition:all var(--mx-d1);
}
.mx-activity-item:last-child{border-bottom:none}
.mx-activity-item:active{
background:var(--mx-s-active);
margin:0 -16px;padding:13px 16px;border-radius:var(--mx-r-sm);
}
.mx-activity-dot{
width:8px;height:8px;border-radius:50%;
margin-top:6px;flex-shrink:0;
transition:transform var(--mx-d2) var(--mx-spring);
}
.mx-activity-item:hover .mx-activity-dot{transform:scale(1.5)}
.mx-activity-text{font-size:13px;line-height:1.5}
.mx-activity-text b{font-weight:600;color:var(--mx-text)}
.mx-activity-time{font-size:11px;color:var(--mx-t3);margin-top:3px}
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Chart Bar Component
═══════════════════════════════════════════ */
.mx-chart{display:flex;align-items:flex-end;gap:5px;height:160px}
.mx-chart-bar-col{
flex:1;display:flex;flex-direction:column;
align-items:center;gap:6px;min-width:0;
}
.mx-chart-bar{
width:100%;border-radius:7px 7px 3px 3px;
background:var(--mx-primary);
transform-origin:bottom;
transition:filter var(--mx-d1),transform var(--mx-d1);
}
.mx-chart-bar.alt{background:var(--mx-s4)}
.mx-chart-bar:active{filter:brightness(1.2);transform:scaleY(1.04)}
.mx-chart-label{
font-size:10px;color:var(--mx-t3);
font-variant-numeric:tabular-nums;
}
/* Responsive chart height */
@media(min-width:768px){.mx-chart{height:180px}}
@media(min-width:1024px){.mx-chart{height:200px}}
+70
View File
@@ -0,0 +1,70 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Avatar Component
═══════════════════════════════════════════ */
/* Base avatar (extend existing .mx-avatar) */
.mx-avatar-img{
width:32px;height:32px;border-radius:50%;
object-fit:cover;flex-shrink:0;
border:2px solid var(--mx-s2);
}
/* Sizes */
.mx-avatar-xs{width:24px;height:24px;font-size:10px}
.mx-avatar-sm{width:28px;height:28px;font-size:11px}
.mx-avatar-md{width:36px;height:36px;font-size:14px}
.mx-avatar-lg{width:44px;height:44px;font-size:16px}
.mx-avatar-xl{width:56px;height:56px;font-size:20px}
/* Avatar Group */
.mx-avatar-group{
display:flex;
}
.mx-avatar-group .mx-avatar,
.mx-avatar-group .mx-avatar-img{
margin-left:-8px;
border:2px solid var(--mx-s2);
transition:transform var(--mx-d2) var(--mx-spring);
}
.mx-avatar-group .mx-avatar:first-child,
.mx-avatar-group .mx-avatar-img:first-child{margin-left:0}
.mx-avatar-group .mx-avatar:hover,
.mx-avatar-group .mx-avatar-img:hover{
transform:translateY(-2px);
z-index:1;
}
/* Overflow counter */
.mx-avatar-overflow{
width:32px;height:32px;
border-radius:50%;
background:var(--mx-s3);
border:2px solid var(--mx-s2);
display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;
color:var(--mx-t2);
margin-left:-8px;
}
/* Status dot */
.mx-avatar-status{
position:relative;
}
.mx-avatar-status::after{
content:'';position:absolute;
bottom:0;right:0;
width:10px;height:10px;
border-radius:50%;
border:2px solid var(--mx-s2);
}
.mx-avatar-status.online::after{background:var(--mx-green)}
.mx-avatar-status.busy::after{background:var(--mx-red)}
.mx-avatar-status.away::after{background:var(--mx-amber)}
.mx-avatar-status.offline::after{background:var(--mx-t3)}
/* Mobile */
@media(max-width:767px){
.mx-avatar-group .mx-avatar,
.mx-avatar-group .mx-avatar-img{margin-left:-10px}
.mx-avatar-overflow{width:28px;height:28px;font-size:10px}
}
+61
View File
@@ -0,0 +1,61 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Badge & Tag Component
Miuix capsule shape badges
═══════════════════════════════════════════ */
.mx-badge{
display:inline-flex;align-items:center;gap:5px;
padding:3px 10px;
border-radius:var(--mx-r-pill);
font-size:12px;font-weight:600;
transition:filter var(--mx-d1);
}
.mx-badge:hover{filter:brightness(1.15)}
.mx-badge::before{
content:'';width:6px;height:6px;
border-radius:50%;flex-shrink:0;
}
/* Variants */
.mx-badge-success{background:rgba(52,211,153,0.1);color:var(--mx-green)}
.mx-badge-success::before{background:var(--mx-green);animation:mx-pulse 2s infinite}
.mx-badge-warning{background:rgba(251,191,36,0.1);color:var(--mx-amber)}
.mx-badge-warning::before{background:var(--mx-amber)}
.mx-badge-error{background:rgba(248,113,113,0.1);color:var(--mx-red)}
.mx-badge-error::before{background:var(--mx-red);animation:mx-pulse 1.5s infinite}
.mx-badge-info{background:rgba(96,165,250,0.1);color:var(--mx-blue)}
.mx-badge-info::before{background:var(--mx-blue)}
.mx-badge-neutral{background:rgba(139,149,165,0.1);color:var(--mx-t2)}
.mx-badge-neutral::before{background:var(--mx-t2)}
/* Dot-only badge */
.mx-dot{
width:8px;height:8px;
border-radius:50%;
flex-shrink:0;
}
/* Count badge */
.mx-count{
display:inline-flex;align-items:center;justify-content:center;
min-width:18px;height:18px;padding:0 5px;
border-radius:var(--mx-r-pill);
font-size:10px;font-weight:700;
background:var(--mx-primary);color:#fff;
}
@keyframes mx-pulse{
0%,100%{opacity:1;transform:scale(1)}
50%{opacity:0.5;transform:scale(1.3)}
}
/* Mobile */
@media(max-width:767px){
.mx-badge{padding:4px 12px;font-size:13px}
.mx-count{min-width:20px;height:20px;font-size:11px}
}
+89
View File
@@ -0,0 +1,89 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Button Component
═══════════════════════════════════════════ */
.mx-btn{
display:inline-flex;align-items:center;justify-content:center;gap:6px;
height:36px;padding:0 18px;
border-radius:var(--mx-r-sm);
font-size:13px;font-weight:600;
cursor:pointer;
transition:all var(--mx-d1) var(--mx-ease);
-webkit-tap-highlight-color:transparent;
position:relative;overflow:hidden;
white-space:nowrap;
}
.mx-btn:active{transform:scale(0.95);box-shadow:inset 0 2px 4px rgba(0,0,0,0.2)}
/* Primary */
.mx-btn-primary{
background:var(--mx-primary);color:#fff;
box-shadow:0 2px 8px rgba(76,139,245,0.2);
}
.mx-btn-primary:hover{
background:var(--mx-primary-hover);
box-shadow:0 4px 14px rgba(76,139,245,0.3);
transform:translateY(-1px);
}
.mx-btn-primary:active{transform:scale(0.95) translateY(0)}
/* Secondary */
.mx-btn-secondary{
background:var(--mx-s3);color:var(--mx-text);
border:1px solid var(--mx-border);
}
.mx-btn-secondary:hover{
background:var(--mx-s4);
border-color:var(--mx-t3);
}
/* Ghost */
.mx-btn-ghost{
background:transparent;color:var(--mx-t2);
}
.mx-btn-ghost:hover{
background:var(--mx-s-hover);color:var(--mx-text);
}
/* Danger */
.mx-btn-danger{
background:var(--mx-red);color:#fff;
}
.mx-btn-danger:hover{
filter:brightness(1.1);
box-shadow:0 4px 14px rgba(248,113,113,0.25);
}
/* Pill — Miuix style capsule button */
.mx-btn-pill{
border-radius:var(--mx-r-pill);
height:32px;padding:0 14px;
font-size:12px;
}
/* Icon button */
.mx-btn-icon{
width:36px;height:36px;padding:0;
border-radius:var(--mx-r-sm);
}
/* Sizes */
.mx-btn-sm{height:30px;padding:0 12px;font-size:12px;border-radius:var(--mx-r-xs)}
.mx-btn-lg{height:42px;padding:0 22px;font-size:14px}
/* Disabled */
.mx-btn[disabled]{
opacity:0.4;cursor:not-allowed;pointer-events:none;
}
/* Mobile: 加大触控区 */
/* ═══ DARK MODE — button pressed inner shadow ═══ */
[data-theme="dark"] .mx-btn:active{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3)}
@media(max-width:767px){
.mx-btn{height:40px;padding:0 20px;font-size:14px}
.mx-btn-sm{height:36px;padding:0 14px;font-size:13px}
.mx-btn-lg{height:46px;padding:0 24px;font-size:15px}
.mx-btn-icon{width:40px;height:40px}
.mx-btn-pill{height:34px;padding:0 16px;font-size:13px}
}
+67
View File
@@ -0,0 +1,67 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Card Component
对应 Miuix Card.kt
CornerRadius = 16.dp, surfaceContainer 背景
═══════════════════════════════════════════ */
.mx-card{
background:var(--mx-s2);
border-radius:var(--mx-r);
overflow:hidden;
box-shadow:var(--mx-sh1);
transition:box-shadow var(--mx-d3) var(--mx-ease);
}
.mx-card:hover{
box-shadow:var(--mx-sh2);
transition-duration:var(--mx-d2);
}
.mx-card-head{
display:flex;align-items:center;justify-content:space-between;
padding:12px 16px;
border-bottom:1px solid var(--mx-divider);
}
.mx-card-title{font-size:15px;font-weight:700}
.mx-card-body{padding:16px}
.mx-card-body > * + * { margin-top: 12px; }
.mx-card-body > .mx-table-wrap { margin-top: 0; }
.mx-card-body > .mx-chart { margin-top: 0; }
/* 可点击卡片 — Miuix BasicCard with press feedback */
.mx-card-clickable{
cursor:pointer;
transition:box-shadow var(--mx-d3) var(--mx-ease),
transform var(--mx-d3) var(--mx-ease);
}
.mx-card-clickable:hover{
transition-duration:var(--mx-d2);
}
.mx-card-clickable:active{transform:scale(0.98)}
/* Mobile: 卡片内边距调整 */
@media(max-width:767px){
.mx-card-body{padding:14px}
.mx-card-head{padding:12px 14px}
}
/* Miuix: 顶部 accent 线 */
.mx-card-accent{
position:relative;overflow:hidden;
}
.mx-card-accent::before{
content:'';position:absolute;top:0;left:0;right:0;height:2px;
background:var(--mx-card-accent,var(--mx-primary));
opacity:0;transition:opacity var(--mx-d2);
}
.mx-card-accent:hover::before{opacity:1}
/* Miuix: 右上角柔光晕 */
.mx-card-accent::after{
content:'';position:absolute;top:-40%;right:-20%;
width:100px;height:100px;
background:var(--mx-card-accent,var(--mx-primary));
border-radius:50%;opacity:0.04;
transition:opacity var(--mx-d2);
}
.mx-card-accent:hover::after{opacity:0.08}
+139
View File
@@ -0,0 +1,139 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Checkbox & Radio Component
═══════════════════════════════════════════ */
/* ── Checkbox ── */
.mx-checkbox{
display:inline-flex;align-items:center;gap:8px;
cursor:pointer;
-webkit-tap-highlight-color:transparent;
}
.mx-checkbox input{display:none}
.mx-checkbox-box{
width:18px;height:18px;
border:2px solid var(--mx-border);
border-radius:5px;
display:flex;align-items:center;justify-content:center;
flex-shrink:0;
transition:all var(--mx-d2) var(--mx-spring);
background:transparent;
}
.mx-checkbox-box svg{
width:12px;height:12px;
color:#fff;opacity:0;
transform:scale(0);
transition:all var(--mx-d2) var(--mx-spring);
}
/* Checked */
.mx-checkbox input:checked + .mx-checkbox-box{
background:var(--mx-primary);
border-color:var(--mx-primary);
}
.mx-checkbox input:checked + .mx-checkbox-box svg{
opacity:1;transform:scale(1);
}
/* Indeterminate */
.mx-checkbox input:indeterminate + .mx-checkbox-box{
background:var(--mx-primary);
border-color:var(--mx-primary);
}
.mx-checkbox input:indeterminate + .mx-checkbox-box svg{
opacity:1;transform:scale(1);
}
/* Hover */
.mx-checkbox:hover .mx-checkbox-box{border-color:var(--mx-primary)}
/* Disabled */
.mx-checkbox input:disabled + .mx-checkbox-box{
opacity:0.4;cursor:not-allowed;
}
.mx-checkbox input:disabled ~ .mx-checkbox-label{
opacity:0.4;cursor:not-allowed;
}
/* Focus */
.mx-checkbox input:focus-visible + .mx-checkbox-box{
box-shadow:0 0 0 3px var(--mx-primary-glow);
}
/* Label */
.mx-checkbox-label{
font-size:14px;color:var(--mx-text);
transition:color var(--mx-d1);
user-select:none;
}
/* ── Radio ── */
.mx-radio{
display:inline-flex;align-items:center;gap:8px;
cursor:pointer;
-webkit-tap-highlight-color:transparent;
}
.mx-radio input{display:none}
.mx-radio-dot{
width:18px;height:18px;
border:2px solid var(--mx-border);
border-radius:50%;
display:flex;align-items:center;justify-content:center;
flex-shrink:0;
transition:all var(--mx-d2) var(--mx-spring);
}
.mx-radio-dot::after{
content:'';width:8px;height:8px;
border-radius:50%;background:#fff;
transform:scale(0);
transition:transform var(--mx-d2) var(--mx-spring);
}
/* Checked */
.mx-radio input:checked + .mx-radio-dot{
background:var(--mx-primary);
border-color:var(--mx-primary);
}
.mx-radio input:checked + .mx-radio-dot::after{
transform:scale(1);
}
/* Hover */
.mx-radio:hover .mx-radio-dot{border-color:var(--mx-primary)}
/* Disabled */
.mx-radio input:disabled + .mx-radio-dot{
opacity:0.4;cursor:not-allowed;
}
.mx-radio input:disabled ~ .mx-radio-label{
opacity:0.4;cursor:not-allowed;
}
/* Focus */
.mx-radio input:focus-visible + .mx-radio-dot{
box-shadow:0 0 0 3px var(--mx-primary-glow);
}
/* Label */
.mx-radio-label{
font-size:14px;color:var(--mx-text);
transition:color var(--mx-d1);
user-select:none;
}
/* ── Checkbox Group / Radio Group layout ── */
.mx-checkbox-group,.mx-radio-group{
display:flex;flex-direction:column;gap:10px;
}
.mx-checkbox-group.horizontal,.mx-radio-group.horizontal{
flex-direction:row;flex-wrap:wrap;gap:16px;
}
/* Mobile: 加大触控区 */
@media(max-width:767px){
.mx-checkbox,.mx-radio{min-height:44px;padding:4px 0}
.mx-checkbox-box,.mx-radio-dot{width:22px;height:22px}
.mx-checkbox-box svg{width:14px;height:14px}
.mx-radio-dot::after{width:10px;height:10px}
}
+93
View File
@@ -0,0 +1,93 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Drawer / Side Panel Component
═══════════════════════════════════════════ */
/* Overlay */
.mx-drawer-overlay{
position:fixed;inset:0;z-index:900;
background:var(--mx-window-dim);
opacity:0;visibility:hidden;
transition:opacity var(--mx-d2) var(--mx-ease),
visibility var(--mx-d2);
}
.mx-drawer-overlay.open{opacity:1;visibility:visible}
/* Drawer */
.mx-drawer{
position:fixed;top:0;z-index:950;
width:320px;height:100dvh;
background:var(--mx-s1);
box-shadow:var(--mx-sh3);
display:flex;flex-direction:column;
transition:transform var(--mx-d3) var(--mx-spring);
}
/* Position: right (default) */
.mx-drawer-right{
right:0;
transform:translateX(100%);
}
.mx-drawer-right.open{transform:translateX(0)}
/* Position: left */
.mx-drawer-left{
left:0;
transform:translateX(-100%);
}
.mx-drawer-left.open{transform:translateX(0)}
/* Size variants */
.mx-drawer-sm{width:280px}
.mx-drawer-lg{width:420px}
@media(min-width:1024px){
.mx-drawer-xl{width:560px}
}
/* Header */
.mx-drawer-head{
display:flex;align-items:center;justify-content:space-between;
padding:16px 20px;
border-bottom:1px solid var(--mx-divider);
flex-shrink:0;
}
.mx-drawer-title{font-size:15px;font-weight:700}
.mx-drawer-close{
width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
border-radius:var(--mx-r-xs);
cursor:pointer;color:var(--mx-t3);
transition:all var(--mx-d1);
}
.mx-drawer-close:hover{background:var(--mx-s-hover);color:var(--mx-text)}
/* Body */
.mx-drawer-body{
flex:1;overflow-y:auto;padding:20px;
font-size:14px;line-height:1.6;
}
/* Footer */
.mx-drawer-foot{
display:flex;align-items:center;justify-content:flex-end;gap:8px;
padding:12px 20px;
border-top:1px solid var(--mx-divider);
flex-shrink:0;
}
/* Full width on mobile */
@media(max-width:767px){
.mx-drawer{width:100%}
.mx-drawer-sm,.mx-drawer-lg,.mx-drawer-xl{width:100%}
}
/* Tablet */
@media(min-width:768px) and (max-width:1023px){
.mx-drawer{width:360px}
.mx-drawer-sm{width:300px}
.mx-drawer-lg{width:420px}
}
/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */
[data-theme="dark"] .mx-drawer{
box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04);
}
+46
View File
@@ -0,0 +1,46 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Empty State Component
═══════════════════════════════════════════ */
.mx-empty{
display:flex;flex-direction:column;
align-items:center;justify-content:center;
padding:48px 24px;text-align:center;
}
.mx-empty-icon{
width:64px;height:64px;
border-radius:var(--mx-r);
background:var(--mx-s3);
display:flex;align-items:center;justify-content:center;
margin-bottom:16px;
color:var(--mx-t3);
}
.mx-empty-icon svg{width:28px;height:28px}
.mx-empty-title{
font-size:15px;font-weight:700;
color:var(--mx-text);margin-bottom:6px;
}
.mx-empty-desc{
font-size:13px;color:var(--mx-t3);
line-height:1.5;max-width:320px;margin-bottom:20px;
}
/* Inline variant — smaller */
.mx-empty-inline{
padding:24px 16px;
}
.mx-empty-inline .mx-empty-icon{
width:48px;height:48px;
}
.mx-empty-inline .mx-empty-icon svg{width:22px;height:22px}
/* Mobile */
@media(max-width:767px){
.mx-empty{padding:32px 16px}
.mx-empty-icon{width:52px;height:52px}
.mx-empty-icon svg{width:24px;height:24px}
.mx-empty-title{font-size:14px}
.mx-empty-desc{font-size:12px}
}
+77
View File
@@ -0,0 +1,77 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Input Component
═══════════════════════════════════════════ */
.mx-input-wrap{
display:flex;align-items:center;gap:10px;
background:var(--mx-s3);
border:1px solid var(--mx-border);
border-radius:var(--mx-r-sm);
padding:0 16px;height:40px;
transition:all var(--mx-d2) var(--mx-ease);
}
.mx-input-wrap:focus-within{
border-color:var(--mx-primary);
box-shadow:0 0 0 3px var(--mx-primary-glow);
background:var(--mx-s2);
}
.mx-input-wrap .mx-input-icon{
color:var(--mx-t3);flex-shrink:0;
display:flex;align-items:center;
}
.mx-input{
width:100%;height:100%;
background:none;
color:var(--mx-text);font-size:14px;
font-family:var(--mx-font);
}
.mx-input::placeholder{color:var(--mx-t3)}
/* Textarea */
.mx-textarea-wrap{
padding:12px 14px;height:auto;min-height:80px;
align-items:flex-start;
}
.mx-textarea{
width:100%;resize:vertical;min-height:60px;
background:none;border:none;outline:none;
color:var(--mx-text);font-size:14px;
font-family:var(--mx-font);line-height:1.6;
}
.mx-textarea::placeholder{color:var(--mx-t3)}
/* Sizes */
.mx-input-sm{height:34px;padding:0 12px;font-size:13px;border-radius:var(--mx-r-xs)}
.mx-input-lg{height:46px;padding:0 16px;font-size:15px}
/* Error */
.mx-input-error{border-color:var(--mx-red)}
.mx-input-error:focus-within{box-shadow:0 0 0 3px rgba(248,113,113,0.15)}
/* Disabled */
.mx-input-wrap.mx-disabled{
opacity:0.4;cursor:not-allowed;
}
.mx-input-wrap.mx-disabled input{pointer-events:none}
/* Label */
.mx-label{
display:block;font-size:12px;font-weight:600;
color:var(--mx-t2);margin-bottom:6px;
text-transform:uppercase;letter-spacing:0.04em;
}
/* Helper text */
.mx-helper{font-size:12px;color:var(--mx-t3);margin-top:4px}
.mx-helper-error{color:var(--mx-red)}
/* Mobile: 防止 iOS 自动缩放 + 加大触控区 */
@media(max-width:767px){
.mx-input-wrap{height:44px;padding:0 16px}
.mx-input{font-size:16px}
.mx-input-sm{height:38px;font-size:15px}
.mx-input-lg{height:50px;font-size:16px}
.mx-textarea-wrap{min-height:88px}
.mx-textarea{font-size:16px;min-height:72px}
.mx-label{font-size:13px;margin-bottom:8px}
}
+327
View File
@@ -0,0 +1,327 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Layout System
全设备适配:手机 / 平板 / 桌面 / 大屏
═══════════════════════════════════════════ */
/* ── App Shell ── */
.mx-app{display:flex;flex-direction:column;height:100dvh}
/* ── Scrollable Body ── */
.mx-body{
flex:1;overflow-y:auto;overflow-x:hidden;
-webkit-overflow-scrolling:touch;
}
/* ── 自动为手机端主内容区添加底部内边距,防止 fixed 底栏遮挡 ── */
@media(max-width:1023px){
.mx-app > .mx-body,
.mx-app > .mx-desktop-main.mx-body{
padding-bottom:calc(72px + var(--mx-safe-b));
}
}
@media(min-width:1024px){
.mx-app > .mx-body,
.mx-app > .mx-desktop-main.mx-body{
padding-bottom:0;
}
}
/* ── Padding utilities ── */
.mx-pad{padding:12px}
.mx-pad-b{padding-bottom:calc(72px + var(--mx-safe-b))}
@media(min-width:768px){.mx-pad{padding:20px}.mx-pad-b{padding-bottom:20px}}
@media(min-width:1024px){.mx-pad{padding:28px}}
/* ── Page Header ── */
.mx-page-head{margin-bottom:16px}
@media(min-width:768px){.mx-page-head{margin-bottom:20px}}
.mx-page-title{
font-size:20px;font-weight:800;letter-spacing:-0.02em;
background:linear-gradient(135deg,var(--mx-text),var(--mx-t2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
@media(min-width:768px){.mx-page-title{font-size:24px}}
.mx-page-sub{
font-size:12px;color:var(--mx-t3);margin-top:4px;
display:flex;align-items:center;gap:6px;
}
/* ── Section ── */
.mx-section{margin-bottom:12px}
@media(min-width:768px){.mx-section{margin-bottom:16px}}
/* ══════════════════════════════════════════
MOBILE (< 768px) — 触控优先
══════════════════════════════════════════ */
@media(max-width:767px){
/* 触控友好的最小高度 */
.mx-btn{height:40px;padding:0 20px;font-size:14px}
.mx-btn-sm{height:36px;padding:0 14px;font-size:13px}
.mx-btn-lg{height:46px;padding:0 24px;font-size:15px}
.mx-btn-icon{width:40px;height:40px}
/* 输入框加大 */
.mx-input-wrap{height:44px;padding:0 16px}
.mx-input{font-size:16px} /* 防止 iOS 自动缩放 */
.mx-input-sm{height:38px;font-size:15px}
.mx-input-lg{height:50px;font-size:16px}
/* Select 加大 */
.mx-select-trigger{height:44px;font-size:16px}
/* Switch 加大 */
.mx-switch-track{width:48px;height:28px}
.mx-switch-thumb{width:22px;height:22px;top:3px;left:3px}
.mx-switch input:checked + .mx-switch-track .mx-switch-thumb{transform:translateX(20px)}
/* Checkbox/Radio 加大触控区 */
.mx-checkbox-box,.mx-radio-dot{width:20px;height:20px}
.mx-checkbox-box svg{width:14px;height:14px}
.mx-radio-dot::after{width:9px;height:9px}
/* Slider 触控加大 */
.mx-slider input[type="range"]::-webkit-slider-thumb{width:22px;height:22px}
.mx-slider input[type="range"]::-moz-range-thumb{width:22px;height:22px}
/* Navigation 触控加大 */
.mx-nav-item{padding:11px 10px}
.mx-btm-item{padding:8px 16px}
.mx-btm-item span{font-size:11px}
/* TopBar */
.mx-topbar{height:48px;padding:0 12px}
/* BottomNav */
.mx-bottom-nav{height:auto}
/* Modal 全屏化 */
.mx-modal-overlay{padding:0;align-items:flex-end}
.mx-modal{
max-width:100%;max-height:90dvh;
border-radius:var(--mx-r) var(--mx-r) 0 0;
transform:translateY(100%);
opacity:1;
}
.mx-modal-overlay.open .mx-modal{transform:translateY(0)}
.mx-modal-sm,.mx-modal-lg,.mx-modal-xl{max-width:100%}
/* Drawer 全宽 */
.mx-drawer{width:100%}
.mx-drawer-sm,.mx-drawer-lg,.mx-drawer-xl{width:100%}
/* Grid 单列降级 */
.mx-grid-2,.mx-grid-3{grid-template-columns:1fr}
.mx-grid-sidebar{grid-template-columns:1fr}
/* Stat 单列(窄屏手机) */
.mx-stat-grid{grid-template-columns:1fr 1fr}
/* Table 卡片式响应 */
.mx-table{border:0}
.mx-table thead{display:none}
.mx-table tbody tr{
display:block;margin-bottom:12px;
border:1px solid var(--mx-divider);
border-radius:var(--mx-r-sm);
padding:12px;
background:var(--mx-s2);
}
.mx-table tbody td{
display:flex;justify-content:space-between;align-items:center;
padding:6px 0;border-bottom:1px solid var(--mx-divider);
white-space:normal;
}
.mx-table tbody td:last-child{border-bottom:none}
.mx-table tbody td::before{
content:attr(data-label);
font-size:11px;font-weight:700;color:var(--mx-t3);
text-transform:uppercase;letter-spacing:0.04em;
flex-shrink:0;margin-right:12px;
}
.mx-table tbody tr:hover td{padding-left:0}
/* Tabs 横向滚动 */
.mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
.mx-tab{padding:10px 14px}
/* Pagination 精简 */
.mx-page-prev span,.mx-page-next span{display:none}
.mx-page-info{display:none}
.mx-pagination{gap:2px}
.mx-page-btn{width:32px;height:32px;font-size:12px}
/* Popover 全宽 */
.mx-popover{
position:fixed;left:8px!important;right:8px!important;
bottom:8px!important;top:auto!important;
width:auto!important;max-width:none;
transform:translateY(100%)!important;
transform-origin:bottom center;
border-radius:var(--mx-r);
}
.mx-popover.open{transform:translateY(0)!important}
/* Select dropdown 全宽 */
.mx-select-dropdown{
position:fixed;left:8px!important;right:8px!important;
bottom:8px!important;top:auto!important;
max-height:50dvh;
transform:translateY(100%)!important;
transform-origin:bottom center;
border-radius:var(--mx-r);
}
.mx-select-dropdown.open{transform:translateY(0)!important}
/* Datepicker dropdown 全宽 */
.mx-datepicker-dropdown{
position:fixed;left:8px!important;right:8px!important;
bottom:8px!important;top:auto!important;
transform:translateY(100%)!important;
transform-origin:bottom center;
border-radius:var(--mx-r);
}
.mx-datepicker-dropdown.open{transform:translateY(0)!important}
/* Accordion */
.mx-accordion-header{padding:12px 14px}
/* Upload */
.mx-upload{padding:24px 16px}
/* Avatar group */
.mx-avatar-group .mx-avatar,
.mx-avatar-group .mx-avatar-img{margin-left:-10px}
}
/* ══════════════════════════════════════════
TABLET (768px — 1023px)
══════════════════════════════════════════ */
@media(min-width:768px) and (max-width:1023px){
.mx-grid-3{grid-template-columns:1fr 1fr}
.mx-grid-sidebar{grid-template-columns:1fr}
.mx-stat-grid{grid-template-columns:repeat(4,1fr)}
/* Modal 稍宽 */
.mx-modal{max-width:480px}
/* Drawer */
.mx-drawer{width:360px}
.mx-drawer-sm{width:300px}
.mx-drawer-lg{width:420px}
/* Tabs 横向滚动 */
.mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
}
/* ══════════════════════════════════════════
DESKTOP (≥ 1024px)
══════════════════════════════════════════ */
@media(min-width:1024px){
.mx-app{flex-direction:row}
.mx-app>.mx-topbar{display:none}
.mx-app>.mx-bottom-nav{display:none!important}
.mx-desktop-shell{
display:flex;flex-direction:column;width:100%;
}
.mx-desktop-topbar{
display:flex;align-items:center;justify-content:space-between;
height:52px;padding:0 24px;
border-bottom:1px solid var(--mx-divider);
backdrop-filter:blur(24px) saturate(1.8);
-webkit-backdrop-filter:blur(24px) saturate(1.8);
flex-shrink:0;
}
[data-theme="dark"] .mx-desktop-topbar{background:rgba(0,0,0,0.6)}
[data-theme="light"] .mx-desktop-topbar{background:rgba(247,247,247,0.65)}
.mx-desktop-main{
flex:1;padding:28px;overflow-y:auto;background:var(--mx-bg);
}
.mx-sidebar{display:block}
.mx-bottom-nav{display:none!important}
/* Grid 正常 */
.mx-grid-2{grid-template-columns:1fr 1fr}
.mx-grid-3{grid-template-columns:1fr 1fr 1fr}
.mx-grid-sidebar{display:grid;grid-template-columns:1fr 320px;gap:16px}
.mx-stat-grid{grid-template-columns:repeat(4,1fr)}
}
/* Hide desktop elements on mobile */
@media(max-width:1023px){
.mx-sidebar{display:none}
.mx-desktop-shell{display:none}
}
/* ── Wide (≥1440px) ── */
@media(min-width:1440px){
.mx-sidebar{width:260px}
.mx-desktop-main{padding:28px 32px}
}
/* ══════════════════════════════════════════
TOUCH DETECTION — 精确触控增强
══════════════════════════════════════════ */
@media(hover:none) and (pointer:coarse){
/* 触控设备额外加大可交互区域 */
.mx-tab{min-height:44px}
.mx-nav-item{min-height:44px}
.mx-accordion-header{min-height:48px}
.mx-popover-item{min-height:44px}
.mx-select-option{min-height:44px}
.mx-page-btn{min-height:36px}
.mx-tag{min-height:32px}
/* 禁用 hover 效果(触控无 hover) */
.mx-card:hover{box-shadow:var(--mx-sh1)}
.mx-stat:hover::before{opacity:0}
.mx-stat:hover::after{opacity:0.04}
.mx-stat:hover .mx-stat-icon{transform:none}
.mx-table tbody tr:hover{background:transparent}
.mx-table tbody tr:hover td{padding-left:14px}
.mx-table th:hover::after{transform:scaleX(0)}
/* 用 :active 替代 */
.mx-card:active{box-shadow:var(--mx-sh2)}
.mx-stat:active{box-shadow:var(--mx-sh2)}
.mx-table tbody tr:active{background:var(--mx-s-hover)}
/* Tooltip 改为长按触发 */
.mx-tooltip::after{
transition:none;
opacity:0;visibility:hidden;
}
.mx-tooltip:active::after{
opacity:1;visibility:visible;
}
}
/* ══════════════════════════════════════════
LANDSCAPE PHONE — 横屏手机
══════════════════════════════════════════ */
@media(max-height:500px) and (orientation:landscape){
.mx-topbar{height:40px}
.mx-bottom-nav{display:none!important}
.mx-modal{max-height:95dvh;border-radius:var(--mx-r)}
.mx-modal-overlay{align-items:center;padding:8px}
.mx-drawer{height:100dvh}
}
/* ══════════════════════════════════════════
FLEX utilities
══════════════════════════════════════════ */
.mx-flex{display:flex}
.mx-flex-center{display:flex;align-items:center;justify-content:center}
.mx-flex-between{display:flex;align-items:center;justify-content:space-between}
.mx-gap-4{gap:4px}.mx-gap-8{gap:8px}.mx-gap-12{gap:12px}.mx-gap-16{gap:16px}
/* ══════════════════════════════════════════
SAFE AREA — 全面屏设备
══════════════════════════════════════════ */
@supports(padding:max(0px)){
.mx-pad{padding:max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right))}
@media(min-width:768px){.mx-pad{padding:max(20px, env(safe-area-inset-left)) max(20px, env(safe-area-inset-right))}}
.mx-bottom-nav{padding-bottom:max(4px, env(safe-area-inset-bottom))}
}
+117
View File
@@ -0,0 +1,117 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Modal / Dialog Component
对应 Miuix Dialog — 多层 Surface + 弹簧入场
═══════════════════════════════════════════ */
/* Overlay */
.mx-modal-overlay{
position:fixed;inset:0;z-index:1000;
display:flex;align-items:center;justify-content:center;
background:var(--mx-window-dim);
opacity:0;visibility:hidden;
transition:opacity var(--mx-d2) var(--mx-ease),
visibility var(--mx-d2);
padding:16px;
}
.mx-modal-overlay.open{
opacity:1;visibility:visible;
}
/* Dialog */
.mx-modal{
background:var(--mx-s2);
border-radius:var(--mx-r);
box-shadow:var(--mx-sh3);
width:100%;max-width:420px;
max-height:calc(100dvh - 48px);
overflow:hidden;
display:flex;flex-direction:column;
transform:scale(0.92) translateY(16px);
opacity:0;
transition:transform var(--mx-d3) var(--mx-spring),
opacity var(--mx-d2) var(--mx-ease);
}
.mx-modal-overlay.open .mx-modal{
transform:scale(1) translateY(0);
opacity:1;
}
/* Size variants */
.mx-modal-sm{max-width:340px}
.mx-modal-lg{max-width:560px}
.mx-modal-xl{max-width:720px}
.mx-modal-full{max-width:calc(100dvw - 32px);max-height:calc(100dvh - 32px)}
/* Header */
.mx-modal-head{
display:flex;align-items:center;justify-content:space-between;
padding:16px 20px;
border-bottom:1px solid var(--mx-divider);
flex-shrink:0;
}
.mx-modal-title{
font-size:15px;font-weight:700;
}
.mx-modal-close{
width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
border-radius:var(--mx-r-xs);
cursor:pointer;color:var(--mx-t3);
transition:all var(--mx-d1) var(--mx-ease);
}
.mx-modal-close:hover{background:var(--mx-s-hover);color:var(--mx-text)}
.mx-modal-close:active{transform:scale(0.9)}
/* Body */
.mx-modal-body{
padding:20px;
overflow-y:auto;flex:1;
font-size:14px;line-height:1.6;color:var(--mx-text-on-surface);
}
/* Footer */
.mx-modal-foot{
display:flex;align-items:center;justify-content:flex-end;gap:8px;
padding:12px 20px;
border-top:1px solid var(--mx-divider);
flex-shrink:0;
}
/* Confirmation variant — icon + centered text */
.mx-modal-confirm-icon{
width:48px;height:48px;
border-radius:50%;
display:flex;align-items:center;justify-content:center;
margin:0 auto 16px;
}
.mx-modal-confirm-icon.info{background:rgba(96,165,250,0.12);color:var(--mx-blue)}
.mx-modal-confirm-icon.success{background:rgba(52,211,153,0.12);color:var(--mx-green)}
.mx-modal-confirm-icon.warning{background:rgba(251,191,36,0.12);color:var(--mx-amber)}
.mx-modal-confirm-icon.danger{background:rgba(248,113,113,0.12);color:var(--mx-red)}
.mx-modal-confirm-text{
text-align:center;font-size:14px;color:var(--mx-text-on-surface);
line-height:1.6;margin-bottom:4px;
}
.mx-modal-confirm-title{
text-align:center;font-size:16px;font-weight:700;
margin-bottom:8px;
}
.mx-modal-confirm-foot{justify-content:center}
/* Mobile: 按钮全宽堆叠 */
@media(max-width:767px){
.mx-modal-foot{flex-direction:column-reverse}
.mx-modal-foot .mx-btn{width:100%}
}
/* Tablet: 居中弹窗 */
@media(min-width:768px) and (max-width:1023px){
.mx-modal-overlay{align-items:center;padding:16px}
.mx-modal{max-width:480px;transform:scale(0.92) translateY(16px)}
.mx-modal-overlay.open .mx-modal{transform:scale(1) translateY(0)}
}
/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */
[data-theme="dark"] .mx-modal{
box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04);
}
+212
View File
@@ -0,0 +1,212 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Navigation Components
TopBar + Sidebar + BottomNav
═══════════════════════════════════════════ */
/* ── TopBar — Miuix frosted glass ── */
.mx-topbar{
position:sticky;top:0;z-index:100;
display:flex;align-items:center;justify-content:space-between;
height:52px;padding:0 16px;
padding-top:var(--mx-safe-t);
border-bottom:1px solid var(--mx-divider);
backdrop-filter:blur(24px) saturate(1.8);
-webkit-backdrop-filter:blur(24px) saturate(1.8);
flex-shrink:0;
}
[data-theme="dark"] .mx-topbar{background:rgba(0,0,0,0.6)}
[data-theme="light"] .mx-topbar{background:rgba(247,247,247,0.65)}
.mx-topbar-left{display:flex;align-items:center;gap:10px}
.mx-topbar-right{display:flex;align-items:center;gap:4px}
/* Logo */
.mx-logo{
display:flex;align-items:center;gap:10px;
}
.mx-logo-icon{
width:32px;height:32px;
background:linear-gradient(135deg,var(--mx-primary),var(--mx-accent));
border-radius:10px;
display:flex;align-items:center;justify-content:center;
transition:transform var(--mx-d2) var(--mx-ease);
box-shadow:0 2px 8px rgba(76,139,245,0.2);
flex-shrink:0;
}
.mx-logo-icon:active{transform:scale(0.92)}
.mx-logo-icon svg{color:#fff}
.mx-logo-text{font-size:16px;font-weight:700}
/* ── Sidebar ── */
.mx-sidebar{
width:240px;flex-shrink:0;
background:var(--mx-s1);
border-right:1px solid var(--mx-divider);
padding:12px;
overflow-y:auto;scrollbar-width:none;
}
.mx-sidebar::-webkit-scrollbar{display:none}
.mx-sidebar-section{
font-size:11px;font-weight:700;color:var(--mx-t3);
text-transform:uppercase;letter-spacing:0.06em;
padding:0 10px;margin:16px 0 4px;
}
.mx-sidebar-section:first-child{margin-top:4px}
.mx-nav-item{
display:flex;align-items:center;gap:10px;
padding:8px 10px;border-radius:var(--mx-r-sm);
cursor:pointer;color:var(--mx-t2);
font-size:13px;font-weight:500;
transition:all var(--mx-d1) var(--mx-ease);
position:relative;
-webkit-tap-highlight-color:transparent;
}
.mx-nav-item::before{
content:'';position:absolute;left:0;top:50%;
width:3px;height:0;
background:var(--mx-primary);
border-radius:0 3px 3px 0;
transform:translateY(-50%);
transition:height var(--mx-d2) var(--mx-spring);
}
.mx-nav-item:hover{background:var(--mx-s-hover);color:var(--mx-text)}
.mx-nav-item.active{background:var(--mx-primary-glow);color:var(--mx-primary)}
.mx-nav-item.active::before{height:18px}
.mx-nav-item:active{transform:scale(0.97)}
.mx-nav-item svg{flex-shrink:0}
.mx-nav-label{flex:1}
.mx-sidebar-bottom{
margin-top:auto;padding-top:12px;
border-top:1px solid var(--mx-divider);
}
/* ── BottomNav — mobile only ── */
.mx-bottom-nav{
position:fixed;bottom:0;left:0;right:0;z-index:200;
border-top:1px solid var(--mx-divider);
backdrop-filter:blur(24px) saturate(1.8);
-webkit-backdrop-filter:blur(24px) saturate(1.8);
padding:4px 0;
padding-bottom:calc(4px + var(--mx-safe-b));
}
[data-theme="dark"] .mx-bottom-nav{background:rgba(0,0,0,0.6)}
[data-theme="light"] .mx-bottom-nav{background:rgba(247,247,247,0.65)}
.mx-bottom-nav-items{
display:flex;justify-content:space-around;
}
.mx-btm-item{
display:flex;flex-direction:column;align-items:center;gap:2px;
padding:6px 16px;border-radius:var(--mx-r-sm);
cursor:pointer;color:var(--mx-t3);
transition:all var(--mx-d2) var(--mx-spring);
-webkit-tap-highlight-color:transparent;
position:relative;
}
.mx-btm-item.active{color:var(--mx-primary)}
.mx-btm-item.active::before{
content:'';position:absolute;top:2px;left:50%;
width:5px;height:5px;
background:var(--mx-primary);
border-radius:50%;transform:translateX(-50%);
animation:mx-dot-in var(--mx-d2) var(--mx-spring);
}
.mx-btm-item span{font-size:10px;font-weight:600}
.mx-btm-item:active{transform:scale(0.88)}
@keyframes mx-dot-in{
from{transform:translateX(-50%) scale(0);opacity:0}
to{transform:translateX(-50%) scale(1);opacity:1}
}
/* ── Breadcrumb ── */
.mx-breadcrumb{
display:flex;align-items:center;gap:8px;
font-size:13px;color:var(--mx-t3);
}
.mx-breadcrumb span{transition:color var(--mx-d1)}
.mx-breadcrumb span:last-child{color:var(--mx-t2);font-weight:500}
.mx-breadcrumb .mx-breadcrumb-sep{color:var(--mx-t3);font-size:11px}
/* ── Pill Group — density/theme toggle ── */
.mx-pill-group{
display:inline-flex;
background:var(--mx-s3);
border-radius:var(--mx-r-pill);
padding:3px;gap:2px;
}
.mx-pill-group button{
padding:5px 14px;border-radius:var(--mx-r-pill);
font-size:12px;font-weight:600;cursor:pointer;
color:var(--mx-t3);
transition:all var(--mx-d2) var(--mx-ease);
}
.mx-pill-group button.active{
background:var(--mx-primary);color:#fff;
box-shadow:0 2px 8px rgba(76,139,245,0.25);
}
.mx-pill-group button:hover:not(.active){color:var(--mx-text)}
/* ── Avatar ── */
.mx-avatar{
width:32px;height:32px;border-radius:50%;
background:linear-gradient(135deg,var(--mx-primary),var(--mx-accent));
display:flex;align-items:center;justify-content:center;
font-size:13px;font-weight:700;color:#fff;
cursor:pointer;flex-shrink:0;
transition:all var(--mx-d2) var(--mx-ease);
box-shadow:0 0 0 0 var(--mx-primary-glow);
}
.mx-avatar:hover{box-shadow:0 0 0 4px var(--mx-primary-glow)}
.mx-avatar:active{transform:scale(0.9)}
/* ── Notification Dot ── */
.mx-notify{
position:relative;
}
.mx-notify .mx-notify-dot{
position:absolute;top:7px;right:7px;
width:7px;height:7px;
background:var(--mx-red);
border-radius:50%;
border:2px solid var(--mx-s1);
animation:mx-pulse 2s infinite;
}
/* ═══ MOBILE ADAPTATIONS ═══ */
@media(max-width:767px){
/* TopBar 更紧凑 */
.mx-topbar{height:48px;padding:0 12px}
.mx-logo-icon{width:28px;height:28px;border-radius:8px}
.mx-logo-text{font-size:14px}
.mx-avatar{width:28px;height:28px;font-size:12px}
/* BottomNav 加大触控区 */
.mx-btm-item{padding:8px 16px;min-width:56px}
.mx-btm-item span{font-size:11px}
.mx-btm-item svg{width:24px;height:24px}
/* Sidebar section */
.mx-sidebar-section{font-size:10px}
.mx-nav-item{padding:11px 10px;min-height:44px}
/* Breadcrumb 滚动 */
.mx-breadcrumb{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
/* Pill group */
.mx-pill-group button{padding:6px 12px;font-size:11px}
/* Notification dot */
.mx-notify .mx-notify-dot{top:5px;right:5px;width:6px;height:6px}
}
/* ═══ TABLET ADAPTATIONS ═══ */
@media(min-width:768px) and (max-width:1023px){
.mx-topbar{height:50px}
.mx-bottom-nav{padding:6px 0}
.mx-btm-item span{font-size:10px}
}
+83
View File
@@ -0,0 +1,83 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Pagination Component
═══════════════════════════════════════════ */
.mx-pagination{
display:flex;align-items:center;gap:4px;
font-size:13px;
}
/* Page button */
.mx-page-btn{
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
border-radius:var(--mx-r-xs);
cursor:pointer;color:var(--mx-t2);
font-weight:500;
transition:all var(--mx-d1) var(--mx-ease);
-webkit-tap-highlight-color:transparent;
}
.mx-page-btn:hover{background:var(--mx-s-hover);color:var(--mx-text)}
.mx-page-btn:active{transform:scale(0.92)}
/* Active */
.mx-page-btn.active{
background:var(--mx-primary);color:#fff;
box-shadow:0 2px 8px rgba(76,139,245,0.25);
}
/* Disabled */
.mx-page-btn.disabled{
opacity:0.3;pointer-events:none;
}
/* Ellipsis */
.mx-page-ellipsis{
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
color:var(--mx-t3);font-size:12px;
letter-spacing:2px;
}
/* Prev / Next with text */
.mx-page-prev,.mx-page-next{
display:flex;align-items:center;gap:4px;
padding:0 10px;height:34px;
border-radius:var(--mx-r-xs);
cursor:pointer;color:var(--mx-t2);
font-size:13px;font-weight:500;
transition:all var(--mx-d1) var(--mx-ease);
}
.mx-page-prev:hover,.mx-page-next:hover{
background:var(--mx-s-hover);color:var(--mx-text);
}
.mx-page-prev:active,.mx-page-next:active{transform:scale(0.95)}
.mx-page-prev.disabled,.mx-page-next.disabled{
opacity:0.3;pointer-events:none;
}
/* Size info */
.mx-page-info{
font-size:12px;color:var(--mx-t3);
margin:0 8px;
}
/* Simple variant — just prev/next + info */
.mx-pagination-simple .mx-page-btn{display:none}
.mx-pagination-simple .mx-page-ellipsis{display:none}
/* Mobile: 精简显示 */
@media(max-width:767px){
.mx-page-prev span,.mx-page-next span{display:none}
.mx-page-info{display:none}
.mx-pagination{gap:2px}
.mx-page-btn{width:32px;height:32px;font-size:12px}
.mx-page-prev,.mx-page-next{padding:0 6px;height:32px}
/* 只显示前 5 页 */
.mx-page-btn:nth-child(n+8){display:none}
}
/* Tablet */
@media(min-width:768px) and (max-width:1023px){
.mx-page-btn{width:36px;height:36px}
}
+87
View File
@@ -0,0 +1,87 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Popover Component
═══════════════════════════════════════════ */
.mx-popover-wrap{position:relative;display:inline-flex}
.mx-popover{
position:absolute;z-index:600;
min-width:180px;
background:var(--mx-s4);
border:1px solid var(--mx-divider);
border-radius:var(--mx-r-sm);
box-shadow:var(--mx-sh3);
padding:6px;
opacity:0;visibility:hidden;
transform:scale(0.95) translateY(-4px);
transform-origin:top center;
transition:all var(--mx-d2) var(--mx-spring);
pointer-events:none;
}
.mx-popover.open{
opacity:1;visibility:visible;
transform:scale(1) translateY(0);
pointer-events:auto;
}
/* Position: bottom */
.mx-popover-bottom{
top:calc(100% + 6px);left:50%;
transform:translateX(-50%) scale(0.95) translateY(-4px);
transform-origin:top center;
}
.mx-popover-bottom.open{
transform:translateX(-50%) scale(1) translateY(0);
}
/* Position: left */
.mx-popover-left{
right:calc(100% + 6px);top:50%;
transform:translateY(-50%) scale(0.95) translateX(4px);
transform-origin:center right;
}
.mx-popover-left.open{
transform:translateY(-50%) scale(1) translateX(0);
}
/* Position: right */
.mx-popover-right{
left:calc(100% + 6px);top:50%;
transform:translateY(-50%) scale(0.95) translateX(-4px);
transform-origin:center left;
}
.mx-popover-right.open{
transform:translateY(-50%) scale(1) translateX(0);
}
/* Item */
.mx-popover-item{
display:flex;align-items:center;gap:8px;
padding:8px 10px;border-radius:var(--mx-r-xs);
font-size:13px;font-weight:500;
cursor:pointer;color:var(--mx-text);
transition:all var(--mx-d1) var(--mx-ease);
}
.mx-popover-item:hover{background:var(--mx-s-hover)}
.mx-popover-item:active{background:var(--mx-s-active);transform:scale(0.98)}
.mx-popover-item svg{flex-shrink:0;color:var(--mx-t2)}
/* Divider */
.mx-popover-divider{
height:1px;background:var(--mx-divider);
margin:4px 0;
}
/* Danger item */
.mx-popover-item.danger{color:var(--mx-red)}
.mx-popover-item.danger svg{color:var(--mx-red)}
/* Mobile: 底部弹出 + 加大触控 */
@media(max-width:767px){
.mx-popover-item{padding:12px 14px;min-height:44px;font-size:14px}
}
/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */
[data-theme="dark"] .mx-popover{
box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04);
}
+135
View File
@@ -0,0 +1,135 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Progress Component
进度条 + 加载指示器
═══════════════════════════════════════════ */
/* ── Linear Progress ── */
.mx-progress{
height:4px;background:var(--mx-s3);
border-radius:var(--mx-r-pill);
overflow:hidden;
position:relative;
}
.mx-progress-bar{
height:100%;
background:var(--mx-primary);
border-radius:var(--mx-r-pill);
transition:width var(--mx-d3) var(--mx-ease);
position:relative;
}
/* Striped */
.mx-progress-striped .mx-progress-bar{
background-image:linear-gradient(
45deg,
rgba(255,255,255,0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255,255,255,0.15) 50%,
rgba(255,255,255,0.15) 75%,
transparent 75%
);
background-size:16px 16px;
}
/* Animated striped */
.mx-progress-animated .mx-progress-bar{
animation:mx-progress-stripes 0.8s linear infinite;
}
@keyframes mx-progress-stripes{
from{background-position:16px 0}
to{background-position:0 0}
}
/* Indeterminate */
.mx-progress-indeterminate{
overflow:hidden;
}
.mx-progress-indeterminate .mx-progress-bar{
width:30%;
animation:mx-progress-slide 1.5s var(--mx-ease) infinite;
}
@keyframes mx-progress-slide{
0%{transform:translateX(-100%)}
100%{transform:translateX(400%)}
}
/* Color variants */
.mx-progress-success .mx-progress-bar{background:var(--mx-green)}
.mx-progress-warning .mx-progress-bar{background:var(--mx-amber)}
.mx-progress-error .mx-progress-bar{background:var(--mx-red)}
/* Size */
.mx-progress-sm{height:2px}
.mx-progress-lg{height:8px}
/* With label */
.mx-progress-label{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:6px;
font-size:12px;font-weight:600;color:var(--mx-t2);
}
.mx-progress-label .mx-progress-value{
color:var(--mx-primary);font-variant-numeric:tabular-nums;
}
/* ── Circular Progress (Spinner) ── */
.mx-spinner{
width:24px;height:24px;
border:2.5px solid var(--mx-s3);
border-top-color:var(--mx-primary);
border-radius:50%;
animation:mx-spin 0.7s linear infinite;
}
@keyframes mx-spin{to{transform:rotate(360deg)}}
.mx-spinner-sm{width:18px;height:18px;border-width:2px}
.mx-spinner-lg{width:36px;height:36px;border-width:3px}
/* Dots spinner */
.mx-spinner-dots{
display:flex;gap:4px;
}
.mx-spinner-dots span{
width:6px;height:6px;
border-radius:50%;
background:var(--mx-primary);
animation:mx-dot-bounce 1.2s ease-in-out infinite;
}
.mx-spinner-dots span:nth-child(2){animation-delay:0.15s}
.mx-spinner-dots span:nth-child(3){animation-delay:0.3s}
@keyframes mx-dot-bounce{
0%,80%,100%{transform:scale(0.6);opacity:0.4}
40%{transform:scale(1);opacity:1}
}
/* ── Progress Ring (circular) ── */
.mx-progress-ring{
display:inline-flex;align-items:center;justify-content:center;
position:relative;
}
.mx-progress-ring svg{
transform:rotate(-90deg);
}
.mx-progress-ring-track{
fill:none;stroke:var(--mx-s3);stroke-width:4;
}
.mx-progress-ring-bar{
fill:none;stroke:var(--mx-primary);stroke-width:4;
stroke-linecap:round;
transition:stroke-dashoffset var(--mx-d3) var(--mx-ease);
}
.mx-progress-ring-text{
position:absolute;
font-size:12px;font-weight:700;
color:var(--mx-text);
font-variant-numeric:tabular-nums;
}
/* Mobile */
@media(max-width:767px){
.mx-progress{height:6px}
.mx-progress-lg{height:10px}
.mx-spinner{width:28px;height:28px}
.mx-spinner-lg{width:40px;height:40px}
}
+130
View File
@@ -0,0 +1,130 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Select / Dropdown Component
═══════════════════════════════════════════ */
/* Trigger */
.mx-select{
position:relative;display:inline-flex;
}
.mx-select-trigger{
display:flex;align-items:center;gap:8px;
height:40px;padding:0 16px;
background:var(--mx-s3);
border:1px solid var(--mx-border);
border-radius:var(--mx-r-sm);
font-size:14px;color:var(--mx-text);
cursor:pointer;
transition:all var(--mx-d2) var(--mx-ease);
min-width:160px;
}
.mx-select-trigger:hover{border-color:var(--mx-t3)}
.mx-select-trigger.open{
border-color:var(--mx-primary);
box-shadow:0 0 0 3px var(--mx-primary-glow);
}
.mx-select-trigger .mx-select-placeholder{color:var(--mx-t3);flex:1}
.mx-select-trigger .mx-select-value{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mx-select-trigger .mx-select-arrow{
color:var(--mx-t3);flex-shrink:0;
transition:transform var(--mx-d2) var(--mx-ease);
}
.mx-select-trigger.open .mx-select-arrow{transform:rotate(180deg)}
/* Sizes */
.mx-select-sm .mx-select-trigger{height:34px;padding:0 12px;font-size:13px;border-radius:var(--mx-r-xs)}
.mx-select-lg .mx-select-trigger{height:46px;padding:0 16px;font-size:15px}
/* Dropdown */
.mx-select-dropdown{
position:absolute;top:calc(100% + 4px);left:0;z-index:700;
min-width:100%;max-height:280px;
background:var(--mx-s4);
border:1px solid var(--mx-divider);
border-radius:var(--mx-r-sm);
box-shadow:var(--mx-sh3);
padding:4px;
overflow-y:auto;
opacity:0;visibility:hidden;
transform:scale(0.96) translateY(-4px);
transform-origin:top center;
transition:all var(--mx-d2) var(--mx-spring);
}
.mx-select-dropdown.open{
opacity:1;visibility:visible;
transform:scale(1) translateY(0);
}
/* Search input inside dropdown */
.mx-select-search{
display:flex;align-items:center;gap:8px;
padding:8px 10px;margin-bottom:2px;
border-bottom:1px solid var(--mx-divider);
}
.mx-select-search input{
flex:1;background:none;border:none;outline:none;
font-size:13px;color:var(--mx-text);
}
.mx-select-search input::placeholder{color:var(--mx-t3)}
.mx-select-search svg{color:var(--mx-t3);flex-shrink:0}
/* Option */
.mx-select-option{
display:flex;align-items:center;gap:8px;
padding:8px 10px;border-radius:var(--mx-r-xs);
font-size:13px;cursor:pointer;
color:var(--mx-text);
transition:all var(--mx-d1) var(--mx-ease);
}
.mx-select-option:hover{background:var(--mx-s-hover)}
.mx-select-option:active{background:var(--mx-s-active)}
.mx-select-option.selected{
background:var(--mx-primary-glow);color:var(--mx-primary);
font-weight:600;
}
.mx-select-option .mx-select-check{
margin-left:auto;color:var(--mx-primary);
opacity:0;transition:opacity var(--mx-d1);
}
.mx-select-option.selected .mx-select-check{opacity:1}
/* Group label */
.mx-select-group-label{
font-size:11px;font-weight:700;color:var(--mx-t3);
text-transform:uppercase;letter-spacing:0.06em;
padding:8px 10px 4px;
}
/* Disabled */
.mx-select.disabled{opacity:0.4;pointer-events:none}
/* Mobile: trigger 加大 */
@media(max-width:767px){
.mx-select-trigger{height:44px;font-size:16px}
.mx-select-sm .mx-select-trigger{height:40px;font-size:15px}
.mx-select-lg .mx-select-trigger{height:50px;font-size:16px}
.mx-select-option{min-height:44px;padding:10px}
}
/* Multi-select tags */
.mx-select-tags{
display:flex;flex-wrap:wrap;gap:4px;flex:1;
}
.mx-select-tag{
display:inline-flex;align-items:center;gap:4px;
padding:2px 8px;border-radius:var(--mx-r-pill);
background:var(--mx-primary-glow);
color:var(--mx-primary);
font-size:12px;font-weight:600;
}
.mx-select-tag-remove{
width:14px;height:14px;
display:flex;align-items:center;justify-content:center;
border-radius:50%;cursor:pointer;
transition:background var(--mx-d1);
}
.mx-select-tag-remove:hover{background:rgba(52,130,255,0.2)}
/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */
[data-theme="dark"] .mx-select-dropdown{
box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04);
}
+79
View File
@@ -0,0 +1,79 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Stat Card Component
═══════════════════════════════════════════ */
.mx-stat{
background:var(--mx-s2);
border-radius:var(--mx-r);
padding:16px;
position:relative;overflow:hidden;
cursor:pointer;
box-shadow:var(--mx-sh1);
transition:all var(--mx-d3) var(--mx-ease);
}
.mx-stat:hover{transition-duration:var(--mx-d2)}
.mx-stat:active{transform:scale(0.97)}
/* 顶部 accent 线 */
.mx-stat::before{
content:'';position:absolute;top:0;left:0;right:0;height:2px;
background:var(--mx-stat-accent,var(--mx-primary));
opacity:0;transition:opacity var(--mx-d2);
}
.mx-stat:hover::before{opacity:1}
/* 右上角柔光晕 */
.mx-stat::after{
content:'';position:absolute;top:-40%;right:-20%;
width:100px;height:100px;
background:var(--mx-stat-accent,var(--mx-primary));
border-radius:50%;opacity:0.04;
transition:opacity var(--mx-d2);
}
.mx-stat:hover::after{opacity:0.08}
.mx-stat-label{
font-size:11px;font-weight:600;color:var(--mx-t3);
text-transform:uppercase;letter-spacing:0.06em;
margin-bottom:10px;display:flex;align-items:center;gap:8px;
}
.mx-stat-icon{
width:26px;height:26px;border-radius:var(--mx-r-xs);
display:flex;align-items:center;justify-content:center;
transition:transform var(--mx-d2) var(--mx-ease);
}
.mx-stat:hover .mx-stat-icon{transform:scale(1.12) rotate(-3deg)}
.mx-stat-value{
font-size:28px;font-weight:800;
letter-spacing:-0.03em;
font-variant-numeric:tabular-nums;
line-height:1;margin-bottom:8px;
}
.mx-stat-delta{
display:inline-flex;align-items:center;gap:4px;
font-size:11px;font-weight:600;
padding:2px 8px;border-radius:var(--mx-r-pill);
}
.mx-stat-delta.up{background:rgba(52,211,153,0.1);color:var(--mx-green)}
.mx-stat-delta.down{background:rgba(248,113,113,0.1);color:var(--mx-red)}
/* Grid layout */
.mx-stat-grid{
display:grid;grid-template-columns:repeat(2,1fr);gap:12px;
}
@media(min-width:768px){
.mx-stat-grid{grid-template-columns:repeat(4,1fr)}
}
/* Mobile */
@media(max-width:767px){
.mx-stat{padding:14px}
.mx-stat-value{font-size:22px}
.mx-stat-icon{width:24px;height:24px}
.mx-stat-icon svg{width:12px;height:12px}
.mx-stat-label{font-size:10px}
.mx-stat-delta{font-size:10px;padding:2px 6px}
}
+67
View File
@@ -0,0 +1,67 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Switch Component
对应 Miuix Switch.kt — miuixCapsuleShape
═══════════════════════════════════════════ */
.mx-switch{
display:inline-flex;align-items:center;gap:10px;
cursor:pointer;
-webkit-tap-highlight-color:transparent;
}
.mx-switch input{display:none}
.mx-switch-track{
width:44px;height:26px;
background:var(--mx-s4);
border-radius:var(--mx-r-pill);
position:relative;
transition:background var(--mx-d2) var(--mx-ease);
flex-shrink:0;
}
.mx-switch-thumb{
position:absolute;top:3px;left:3px;
width:20px;height:20px;
background:#fff;
border-radius:50%;
box-shadow:0 1px 4px rgba(0,0,0,0.25);
transition:transform var(--mx-d2) var(--mx-spring);
}
@keyframes mx-switch-bounce{
0%{transform:translateX(0)}
50%{transform:translateX(22px)}
100%{transform:translateX(20px)}
}
/* Checked */
.mx-switch input:checked + .mx-switch-track{
background:var(--mx-primary);
}
.mx-switch input:checked + .mx-switch-track .mx-switch-thumb{
animation:mx-switch-bounce 0.3s cubic-bezier(0.34,1.56,0.64,1) forwards;
}
/* Disabled */
.mx-switch input:disabled + .mx-switch-track{
opacity:0.4;cursor:not-allowed;
}
/* Label */
.mx-switch-label{
font-size:14px;color:var(--mx-text);
transition:color var(--mx-d1);
}
/* Mobile: 加大触控区 */
@media(max-width:767px){
.mx-switch{min-height:44px;padding:4px 0}
.mx-switch-track{width:48px;height:28px}
.mx-switch-thumb{width:22px;height:22px;top:3px;left:3px}
@keyframes mx-switch-bounce-mobile{
0%{transform:translateX(0)}
50%{transform:translateX(22px)}
100%{transform:translateX(20px)}
}
.mx-switch input:checked + .mx-switch-track .mx-switch-thumb{animation:mx-switch-bounce-mobile 0.3s cubic-bezier(0.34,1.56,0.64,1) forwards}
.mx-switch-label{font-size:15px}
}
+85
View File
@@ -0,0 +1,85 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Table Component
═══════════════════════════════════════════ */
.mx-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.mx-table{width:100%;border-collapse:collapse}
.mx-table th{
padding:12px 16px;
font-size:11px;font-weight:700;
color:var(--mx-t3);text-align:left;
text-transform:uppercase;letter-spacing:0.06em;
border-bottom:1px solid var(--mx-divider);
white-space:nowrap;cursor:pointer;
position:relative;transition:color var(--mx-d1);
-webkit-user-select:none;user-select:none;
}
.mx-table th:hover{color:var(--mx-t2)}
/* Miuix: 表头 hover 底部亮线 */
.mx-table th::after{
content:'';position:absolute;bottom:0;left:14px;right:14px;
height:2px;background:var(--mx-primary);border-radius:1px;
transform:scaleX(0);transition:transform var(--mx-d2) var(--mx-ease);
}
.mx-table th:hover::after{transform:scaleX(1)}
.mx-table td{
padding:12px 16px;
font-size:13px;
border-bottom:1px solid var(--mx-divider);
white-space:nowrap;
transition:padding var(--mx-d1);
}
.mx-table tbody tr{
transition:background var(--mx-d3);
}
.mx-table tbody tr:hover{
background:var(--mx-s-hover);
transition-duration:var(--mx-d2);
}
.mx-table tbody tr:active{background:var(--mx-s-active)}
/* Miuix: hover 时左 padding 微推 */
.mx-table tbody tr:hover td{padding-left:18px}
/* Mono cell */
.mx-table .mono{
font-family:var(--mx-mono);font-size:12px;color:var(--mx-t2);
}
/* Compact mode */
.mx-table-compact th{padding:8px 12px}
.mx-table-compact td{padding:10px 12px}
.mx-table-compact tbody tr:hover td{padding-left:14px}
/* Mobile: 表格卡片化(需 data-label 属性配合) */
@media(max-width:767px){
.mx-table-wrap{overflow-x:visible}
.mx-table{border:0}
.mx-table thead{display:none}
.mx-table tbody tr{
display:block;margin-bottom:12px;
border:1px solid var(--mx-divider);
border-radius:var(--mx-r-sm);
padding:12px;
background:var(--mx-s2);
}
.mx-table tbody td{
display:flex;justify-content:space-between;align-items:center;
padding:6px 0;border-bottom:1px solid var(--mx-divider);
white-space:normal;
}
.mx-table tbody td:last-child{border-bottom:none}
.mx-table tbody td::before{
content:attr(data-label);
font-size:11px;font-weight:700;color:var(--mx-t3);
text-transform:uppercase;letter-spacing:0.04em;
flex-shrink:0;margin-right:12px;
}
.mx-table tbody tr:hover td{padding-left:0}
.mx-table tbody tr:active{background:var(--mx-s-hover)}
.mx-table-compact tbody tr{padding:10px}
.mx-table-compact td{padding:5px 0}
}
+94
View File
@@ -0,0 +1,94 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Tabs Component
═══════════════════════════════════════════ */
/* Tab bar */
.mx-tabs{
display:flex;align-items:center;
border-bottom:1px solid var(--mx-divider);
overflow-x:auto;scrollbar-width:none;
-webkit-overflow-scrolling:touch;
}
.mx-tabs::-webkit-scrollbar{display:none}
/* Tab item */
.mx-tab{
display:flex;align-items:center;gap:6px;
padding:10px 16px;
font-size:13px;font-weight:500;
color:var(--mx-t3);
cursor:pointer;white-space:nowrap;
position:relative;
transition:all var(--mx-d1) var(--mx-ease);
-webkit-tap-highlight-color:transparent;
}
.mx-tab:hover{color:var(--mx-text)}
/* Active indicator — bottom line */
.mx-tab::after{
content:'';position:absolute;bottom:0;left:8px;right:8px;
height:2px;border-radius:1px;
background:var(--mx-primary);
transform:scaleX(0);
transition:transform var(--mx-d2) var(--mx-spring);
}
.mx-tab.active{color:var(--mx-primary);font-weight:600}
.mx-tab.active::after{transform:scaleX(1)}
/* Badge in tab */
.mx-tab .mx-count{margin-left:4px}
/* Disabled */
.mx-tab.disabled{opacity:0.4;pointer-events:none}
/* Pill variant */
.mx-tabs-pill{
border-bottom:none;
background:var(--mx-s3);
border-radius:var(--mx-r-pill);
padding:3px;gap:2px;
}
.mx-tabs-pill .mx-tab{
border-radius:var(--mx-r-pill);
padding:6px 14px;
}
.mx-tabs-pill .mx-tab::after{display:none}
.mx-tabs-pill .mx-tab.active{
background:var(--mx-primary);color:#fff;
box-shadow:0 2px 8px rgba(76,139,245,0.25);
}
/* Card variant */
.mx-tabs-card{
border-bottom:none;gap:4px;
}
.mx-tabs-card .mx-tab{
border-radius:var(--mx-r-sm);
padding:8px 14px;
}
.mx-tabs-card .mx-tab::after{display:none}
.mx-tabs-card .mx-tab.active{
background:var(--mx-primary-glow);color:var(--mx-primary);
}
/* Tab content */
.mx-tab-panel{display:none;padding:16px 0}
.mx-tab-panel.active{display:block}
/* Alignment */
.mx-tabs-center{justify-content:center}
.mx-tabs-end{justify-content:flex-end}
/* Mobile: 触控友好 + 可滚动 */
@media(max-width:767px){
.mx-tab{padding:12px 14px;min-height:44px;font-size:14px}
.mx-tabs-pill .mx-tab{padding:8px 16px}
.mx-tabs-card .mx-tab{padding:10px 14px}
/* 隐藏超出的 badge */
.mx-tab .mx-count{display:none}
}
/* Tablet: 可滚动 */
@media(min-width:768px) and (max-width:1023px){
.mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
}
+94
View File
@@ -0,0 +1,94 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Tag / Chip Component
═══════════════════════════════════════════ */
.mx-tag{
display:inline-flex;align-items:center;gap:4px;
padding:4px 10px;
border-radius:var(--mx-r-pill);
font-size:12px;font-weight:600;
background:var(--mx-s3);color:var(--mx-text);
transition:all var(--mx-d1) var(--mx-ease);
cursor:default;
-webkit-tap-highlight-color:transparent;
}
/* Clickable */
.mx-tag-clickable{cursor:pointer}
.mx-tag-clickable:hover{background:var(--mx-s4)}
.mx-tag-clickable:active{transform:scale(0.95)}
/* Color variants */
.mx-tag-primary{background:var(--mx-primary-glow);color:var(--mx-primary)}
.mx-tag-success{background:rgba(52,211,153,0.1);color:var(--mx-green)}
.mx-tag-warning{background:rgba(251,191,36,0.1);color:var(--mx-amber)}
.mx-tag-error{background:rgba(248,113,113,0.1);color:var(--mx-red)}
.mx-tag-info{background:rgba(96,165,250,0.1);color:var(--mx-blue)}
/* Outline variant */
.mx-tag-outline{
background:transparent;
border:1px solid var(--mx-border);
}
.mx-tag-outline.mx-tag-primary{border-color:var(--mx-primary);color:var(--mx-primary)}
.mx-tag-outline.mx-tag-success{border-color:var(--mx-green);color:var(--mx-green)}
.mx-tag-outline.mx-tag-warning{border-color:var(--mx-amber);color:var(--mx-amber)}
.mx-tag-outline.mx-tag-error{border-color:var(--mx-red);color:var(--mx-red)}
/* With dot */
.mx-tag-dot::before{
content:'';width:6px;height:6px;
border-radius:50%;flex-shrink:0;
}
.mx-tag-primary.mx-tag-dot::before{background:var(--mx-primary)}
.mx-tag-success.mx-tag-dot::before{background:var(--mx-green)}
.mx-tag-warning.mx-tag-dot::before{background:var(--mx-amber)}
.mx-tag-error.mx-tag-dot::before{background:var(--mx-red)}
.mx-tag-info.mx-tag-dot::before{background:var(--mx-blue)}
/* Close button */
.mx-tag-close{
width:14px;height:14px;
display:flex;align-items:center;justify-content:center;
border-radius:50%;cursor:pointer;
transition:background var(--mx-d1);
}
.mx-tag-close:hover{background:rgba(0,0,0,0.1)}
[data-theme="dark"] .mx-tag-close:hover{background:rgba(255,255,255,0.1)}
/* Sizes */
.mx-tag-sm{padding:2px 8px;font-size:11px}
.mx-tag-lg{padding:6px 14px;font-size:13px}
/* Tag group */
.mx-tag-group{
display:flex;flex-wrap:wrap;gap:6px;
}
/* Input tag — for editable tag list */
.mx-tag-input{
display:flex;flex-wrap:wrap;gap:6px;
padding:6px 10px;
background:var(--mx-s3);
border:1px solid var(--mx-border);
border-radius:var(--mx-r-sm);
min-height:40px;align-items:center;
transition:all var(--mx-d2) var(--mx-ease);
cursor:text;
}
/* Mobile */
@media(max-width:767px){
.mx-tag{min-height:32px;padding:4px 12px}
.mx-tag-lg{min-height:38px}
.mx-tag-close{width:18px;height:18px}
}
.mx-tag-input:focus-within{
border-color:var(--mx-primary);
box-shadow:0 0 0 3px var(--mx-primary-glow);
}
.mx-tag-input input{
flex:1;min-width:60px;border:none;outline:none;
background:none;font-size:13px;color:var(--mx-text);
}
.mx-tag-input input::placeholder{color:var(--mx-t3)}
+98
View File
@@ -0,0 +1,98 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Toast / Snackbar Component
Miuix 轻量反馈 — 底部弹出 + 自动消失
═══════════════════════════════════════════ */
/* Container */
.mx-toast-container{
position:fixed;bottom:16px;left:50%;
transform:translateX(-50%);
z-index:2000;
display:flex;flex-direction:column-reverse;align-items:center;gap:8px;
pointer-events:none;
padding-bottom:var(--mx-safe-b);
}
/* Toast */
.mx-toast{
display:flex;align-items:center;gap:10px;
padding:10px 16px;
background:var(--mx-s4);
border-radius:var(--mx-r-sm);
box-shadow:var(--mx-sh3);
font-size:13px;font-weight:500;
color:var(--mx-text);
pointer-events:auto;
max-width:calc(100vw - 32px);
transform:translateY(12px) scale(0.95);
opacity:0;
transition:transform var(--mx-d3) var(--mx-spring),
opacity var(--mx-d2) var(--mx-ease);
}
.mx-toast.show{
transform:translateY(0) scale(1);
opacity:1;
}
.mx-toast.hide{
transform:translateY(-8px) scale(0.95);
opacity:0;
}
/* Icon */
.mx-toast-icon{
width:20px;height:20px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
}
.mx-toast-icon svg{width:18px;height:18px}
/* Variants */
.mx-toast-success .mx-toast-icon{color:var(--mx-green)}
.mx-toast-warning .mx-toast-icon{color:var(--mx-amber)}
.mx-toast-error .mx-toast-icon{color:var(--mx-red)}
.mx-toast-info .mx-toast-icon{color:var(--mx-blue)}
/* Action button */
.mx-toast-action{
padding:4px 10px;border-radius:var(--mx-r-xs);
font-size:12px;font-weight:600;
cursor:pointer;
transition:all var(--mx-d1) var(--mx-ease);
flex-shrink:0;
}
.mx-toast-action{color:var(--mx-primary)}
.mx-toast-action:hover{background:var(--mx-primary-glow)}
/* Close button */
.mx-toast-close{
width:20px;height:20px;
display:flex;align-items:center;justify-content:center;
border-radius:var(--mx-r-xs);
cursor:pointer;color:var(--mx-t3);
transition:all var(--mx-d1);
flex-shrink:0;
}
.mx-toast-close:hover{color:var(--mx-text)}
/* Progress bar (auto dismiss) */
.mx-toast-progress{
position:absolute;bottom:0;left:0;
height:2px;border-radius:0 0 var(--mx-r-sm) var(--mx-r-sm);
background:var(--mx-primary);
opacity:0.4;
transition:width linear;
}
/* Mobile — full width + 底部安全区 */
@media(max-width:767px){
.mx-toast-container{
left:8px;right:8px;
transform:none;
align-items:stretch;
}
.mx-toast{border-radius:var(--mx-r)}
}
/* Tablet */
@media(min-width:768px) and (max-width:1023px){
.mx-toast-container{bottom:20px}
}
+54
View File
@@ -0,0 +1,54 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Tooltip Component
═══════════════════════════════════════════ */
.mx-tooltip{
position:relative;
}
.mx-tooltip::after{
content:attr(data-tooltip);
position:absolute;
bottom:calc(100% + 8px);left:50%;
transform:translateX(-50%) translateY(4px);
padding:5px 10px;
background:var(--mx-s4);
color:var(--mx-text);
font-size:12px;font-weight:500;
white-space:nowrap;
border-radius:var(--mx-r-xs);
box-shadow:var(--mx-sh2);
opacity:0;visibility:hidden;
pointer-events:none;
transition:all var(--mx-d2) var(--mx-spring);
z-index:500;
}
.mx-tooltip:hover::after{
opacity:1;visibility:visible;
transform:translateX(-50%) translateY(0);
}
/* Position variants */
.mx-tooltip-bottom::after{
bottom:auto;top:calc(100% + 8px);
transform:translateX(-50%) translateY(-4px);
}
.mx-tooltip-bottom:hover::after{
transform:translateX(-50%) translateY(0);
}
.mx-tooltip-left::after{
bottom:auto;left:auto;
right:calc(100% + 8px);top:50%;
transform:translateY(-50%) translateX(4px);
}
.mx-tooltip-left:hover::after{
transform:translateY(-50%) translateX(0);
}
.mx-tooltip-right::after{
bottom:auto;left:calc(100% + 8px);top:50%;
transform:translateY(-50%) translateX(-4px);
}
.mx-tooltip-right:hover::after{
transform:translateY(-50%) translateX(0);
}
+71
View File
@@ -0,0 +1,71 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Animations
═══════════════════════════════════════════ */
/* ── Entry animations ── */
@keyframes mx-slide-up{
from{opacity:0;transform:translateY(16px)}
to{opacity:1;transform:translateY(0)}
}
@keyframes mx-card-in{
from{opacity:0;transform:scale(0.96) translateY(10px)}
to{opacity:1;transform:scale(1) translateY(0)}
}
@keyframes mx-fade-item{
from{opacity:0;transform:translateX(-8px)}
to{opacity:1;transform:translateX(0)}
}
@keyframes mx-fade-row{
from{opacity:0;transform:translateY(4px)}
to{opacity:1;transform:translateY(0)}
}
@keyframes mx-bar-grow{
from{transform:scaleY(0);opacity:0}
to{transform:scaleY(1);opacity:1}
}
@keyframes mx-pulse{
0%,100%{opacity:1;transform:scale(1)}
50%{opacity:0.5;transform:scale(1.3)}
}
@keyframes mx-dot-in{
from{transform:translateX(-50%) scale(0);opacity:0}
to{transform:translateX(-50%) scale(1);opacity:1}
}
@keyframes mx-ripple{
to{transform:scale(4);opacity:0}
}
@keyframes mx-shimmer{
from{background-position:200% 0}
to{background-position:-200% 0}
}
/* ── Animation classes ── */
.mx-anim-slide-up{animation:mx-slide-up var(--mx-d3) var(--mx-ease)}
.mx-anim-card-in{animation:mx-card-in var(--mx-d3) var(--mx-ease) both}
.mx-anim-fade-item{animation:mx-fade-item var(--mx-d3) var(--mx-ease) both}
.mx-anim-fade-row{animation:mx-fade-row var(--mx-d3) var(--mx-ease) both}
.mx-anim-bar-grow{
transform-origin:bottom;
animation:mx-bar-grow var(--mx-d4) var(--mx-ease) both;
}
/* ── Stagger delays ── */
.mx-delay-0{animation-delay:0ms}
.mx-delay-1{animation-delay:50ms}
.mx-delay-2{animation-delay:100ms}
.mx-delay-3{animation-delay:150ms}
.mx-delay-4{animation-delay:200ms}
.mx-delay-5{animation-delay:250ms}
.mx-delay-6{animation-delay:300ms}
.mx-delay-7{animation-delay:350ms}
.mx-delay-8{animation-delay:400ms}
/* ── Skeleton loading — dual-layer overlay ── */
.mx-skeleton{
background:
linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.06) 50%, transparent 75%),
var(--mx-s3);
background-size:200% 100%, 100% 100%;
animation:mx-shimmer 1.5s ease-in-out infinite;
border-radius:var(--mx-r-sm);
}
+50
View File
@@ -0,0 +1,50 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Base Reset & Globals
═══════════════════════════════════════════ */
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html{font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
body{
font-family:var(--mx-font);
background:var(--mx-bg);
color:var(--mx-text);
overflow-x:hidden;
min-height:100dvh;
}
button,input,textarea,select{font-family:inherit;border:none;outline:none;background:none}
ul,ol{list-style:none}
a{text-decoration:none;color:inherit}
img{display:block;max-width:100%}
/* Smooth scroll */
html{scroll-behavior:smooth}
/* Selection */
::selection{background:var(--mx-primary-glow);color:var(--mx-text)}
/* Scrollbar — Miuix thin style, auto-hide */
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--mx-s4);border-radius:2px;opacity:0;transition:opacity 0.3s}
::-webkit-scrollbar-thumb:hover{background:var(--mx-t3)}
body:hover ::-webkit-scrollbar-thumb{opacity:1}
/* Firefox scrollbar auto-hide */
*{scrollbar-color:transparent transparent}
body:hover *{scrollbar-color:var(--mx-s4) transparent}
/* Prevent selection on interactive */
.no-select{-webkit-user-select:none;user-select:none}
/* ═══ DARK MODE INNER GLOW — Miuix signature depth ═══ */
[data-theme="dark"] .mx-card,
[data-theme="dark"] .mx-stat{
box-shadow:var(--mx-sh1),inset 0 1px 0 rgba(255,255,255,0.04);
}
/* ═══ prefers-reduced-motion — kill all animation ═══ */
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{
animation-duration:0.01ms !important;
transition-duration:0.01ms !important;
}
}
+230
View File
@@ -0,0 +1,230 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Liquid Glass Effect v2
液体玻璃效果系统(完整版)
通过 [data-glass="liquid"] 属性启用
═══════════════════════════════════════════ */
/* ── Glass Variables ── */
:root {
--mx-glass-blur: 12px;
--mx-glass-saturation: 180%;
--mx-glass-displacement: 25;
--mx-glass-aberration: 2;
--mx-glass-elasticity: 0.15;
--mx-glass-corner-radius: 16px;
--mx-glass-activation-zone: 200;
--mx-glass-bg-light: rgba(255, 255, 255, 0.25);
--mx-glass-bg-dark: rgba(0, 0, 0, 0.35);
--mx-glass-border-light: rgba(255, 255, 255, 0.2);
--mx-glass-border-dark: rgba(255, 255, 255, 0.08);
--mx-glass-mouse-x: 0;
--mx-glass-mouse-y: 0;
}
/* ═══════════════════════════════════════════
DARK MODE (default)
═══════════════════════════════════════════ */
/* ── TopBar ── */
[data-glass="liquid"] .mx-topbar {
background: rgba(22, 22, 22, 0.45);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border-bottom: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Sidebar ── */
[data-glass="liquid"] .mx-sidebar {
background: rgba(22, 22, 22, 0.45);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border-right: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Bottom Nav ── */
[data-glass="liquid"] .mx-bottom-nav {
background: rgba(22, 22, 22, 0.45);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border-top: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Modal ── */
[data-glass="liquid"] .mx-modal {
background: rgba(30, 30, 30, 0.5);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Drawer ── */
[data-glass="liquid"] .mx-drawer {
background: rgba(22, 22, 22, 0.5);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border-left: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Popover ── */
[data-glass="liquid"] .mx-popover {
background: rgba(30, 30, 30, 0.5);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Select Dropdown ── */
[data-glass="liquid"] .mx-select-dropdown {
background: rgba(30, 30, 30, 0.5);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ── Card (opt-in via .mx-card-glass) ── */
[data-glass="liquid"] .mx-card-glass {
background: rgba(30, 30, 30, 0.4);
backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
-webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation));
border: 1px solid var(--mx-glass-border-dark);
filter: url(#mx-glass-filter);
}
/* ═══════════════════════════════════════════
LIGHT MODE
═══════════════════════════════════════════ */
[data-theme="light"][data-glass="liquid"] .mx-topbar {
background: rgba(255, 255, 255, 0.45);
border-bottom: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-sidebar {
background: rgba(255, 255, 255, 0.45);
border-right: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-bottom-nav {
background: rgba(255, 255, 255, 0.45);
border-top: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-modal {
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-drawer {
background: rgba(255, 255, 255, 0.5);
border-left: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-popover {
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-select-dropdown {
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--mx-glass-border-light);
}
[data-theme="light"][data-glass="liquid"] .mx-card-glass {
background: rgba(255, 255, 255, 0.4);
border: 1px solid var(--mx-glass-border-light);
}
/* ═══════════════════════════════════════════
OVER-LIGHT 模式
亮色背景上使用的深色玻璃
═══════════════════════════════════════════ */
[data-glass="liquid"][data-glass-over-light="true"] .mx-topbar,
[data-glass="liquid"][data-glass-over-light="true"] .mx-sidebar,
[data-glass="liquid"][data-glass-over-light="true"] .mx-bottom-nav,
[data-glass="liquid"][data-glass-over-light="true"] .mx-modal,
[data-glass="liquid"][data-glass-over-light="true"] .mx-drawer,
[data-glass="liquid"][data-glass-over-light="true"] .mx-popover,
[data-glass="liquid"][data-glass-over-light="true"] .mx-select-dropdown,
[data-glass="liquid"][data-glass-over-light="true"] .mx-card-glass {
background: rgba(0, 0, 0, 0.45);
border-color: rgba(255, 255, 255, 0.1);
}
/* ═══════════════════════════════════════════
GLASS 可追踪元素
鼠标跟踪 + 弹性变换 + 双层边框
═══════════════════════════════════════════ */
.mx-glass-trackable {
position: relative;
transition: transform 0.2s ease-out;
will-change: transform;
}
.mx-glass-trackable .mx-glass-border-layer {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
z-index: 2;
}
/* Hover 高光层 */
.mx-glass-trackable::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-out;
background-image: radial-gradient(circle at 50% 0%, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0) 50%);
mix-blend-mode: overlay;
z-index: 3;
}
.mx-glass-trackable:hover::after {
opacity: 0.5;
}
.mx-glass-trackable:active::after {
opacity: 0.8;
background-image: radial-gradient(circle at 50% 0%, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 80%);
}
/* ═══════════════════════════════════════════
FALLBACK — browsers without SVG filter support
═══════════════════════════════════════════ */
@supports not (filter: url(#mx-glass-filter)) {
[data-glass="liquid"] .mx-topbar,
[data-glass="liquid"] .mx-sidebar,
[data-glass="liquid"] .mx-bottom-nav,
[data-glass="liquid"] .mx-modal,
[data-glass="liquid"] .mx-drawer,
[data-glass="liquid"] .mx-popover,
[data-glass="liquid"] .mx-select-dropdown,
[data-glass="liquid"] .mx-card-glass {
filter: none;
}
}
/* ═══════════════════════════════════════════
REDUCED MOTION
═══════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
.mx-glass-trackable {
transition: none !important;
transform: none !important;
}
.mx-glass-trackable::after {
transition: none !important;
}
}
+198
View File
@@ -0,0 +1,198 @@
/* ═══════════════════════════════════════════
MIUIX CONSOLE — Design Tokens
直接从 Miuix Colors.kt lightColorScheme() / darkColorScheme() 移植
═══════════════════════════════════════════ */
:root {
/* ── Primary — from Miuix lightColorScheme primary = Color(0xFF3482FF) ── */
--mx-primary: #3482FF;
--mx-primary-hover: #5D9BFF;
--mx-primary-active: #277AF7;
--mx-primary-on: #FFFFFF;
--mx-primary-glow: rgba(52,130,255,0.12);
--mx-primary-container: #5D9BFF;
--mx-primary-container-on: #FFFFFF;
--mx-primary-variant: #3482FF;
--mx-primary-variant-on: #AECDFF;
--mx-tertiary-container: #EAF2FF;
--mx-tertiary-container-on: #3482FF;
/* ── Error — from Miuix error = Color(0xFFE94634) ── */
--mx-error: #E94634;
--mx-error-on: #FFFFFF;
--mx-error-container: #FDF6F4;
--mx-error-container-on: #410002;
/* ── Semantic ── */
--mx-green: #34D399;
--mx-amber: #FBBF24;
--mx-blue: #60A5FA;
/* ── Surface — from Miuix lightColorScheme() 源码 ── */
--mx-bg: #FFFFFF; /* background = White */
--mx-surface: #F7F7F7; /* surface = 0xFFF7F7F7 */
--mx-surface-variant: #FFFFFF; /* surfaceVariant = White */
--mx-s1: #F7F7F7; /* surface */
--mx-s2: #FFFFFF; /* surfaceContainer = White */
--mx-s3: #E8E8E8; /* surfaceContainerHigh = 0xFFE8E8E8 */
--mx-s4: #E8E8E8; /* surfaceContainerHighest = 0xFFE8E8E8 */
--mx-s-hover: rgba(0,0,0,0.04);
--mx-s-active: rgba(0,0,0,0.07);
/* ── Text — from Miuix 源码 ── */
--mx-text: #000000; /* onSurface = Black */
--mx-text-on-surface: rgba(0,0,0,0.8); /* onSurfaceSecondary = 0xCC000000 */
--mx-text-variant: rgba(0,0,0,0.6); /* onSurfaceVariantSummary = 0x99000000 */
--mx-text-actions: rgba(0,0,0,0.4); /* onSurfaceVariantActions = 0x66000000 */
--mx-t2: rgba(0,0,0,0.6);
--mx-t3: rgba(0,0,0,0.4);
--mx-text-bg-variant: #8C93B0; /* onBackgroundVariant */
--mx-text-container-variant: #959595; /* onSurfaceContainerVariant */
--mx-text-container-high: #A2A2A2; /* onSurfaceContainerHigh */
--mx-text-disabled: #B2B2B2; /* disabledOnSurface */
/* ── Secondary — from Miuix 源码 ── */
--mx-secondary: #E6E6E6; /* secondary */
--mx-secondary-variant: #F0F0F0; /* secondaryVariant */
--mx-secondary-container: #F0F0F0;
--mx-secondary-container-on: #A9A9A9;
/* ── Border / Divider — from Miuix 源码 ── */
--mx-border: #D9D9D9; /* outline = 0xFFD9D9D9 */
--mx-divider: #E0E0E0; /* dividerLine = 0xFFE0E0E0 */
/* ── Disabled — from Miuix 源码 ── */
--mx-disabled-primary: #C2D9FF;
--mx-disabled-primary-on: #F3F8FF;
--mx-disabled-secondary: #F0F0F0;
--mx-disabled-secondary-on: #FCFCFC;
/* ── Window ── */
--mx-window-dim: rgba(0,0,0,0.3); /* windowDimming */
/* ── Slider — from Miuix 源码 ── */
--mx-slider-bg: rgba(0,0,0,0.06); /* 0x0F000000 */
--mx-slider-key: rgba(163,179,205,0.3);
--mx-slider-key-fg: #6EB5FF;
/* ── Radius (Miuix Smooth Corner — 16dp default) ── */
--mx-r: 16px;
--mx-r-sm: 12px;
--mx-r-xs: 8px;
--mx-r-pill: 9999px;
/* ── Shadow (Miuix multi-layer soft shadow) ── */
--mx-sh1: 0 1px 4px rgba(0,0,0,0.08);
--mx-sh2: 0 4px 16px rgba(0,0,0,0.06);
--mx-sh3: 0 8px 32px rgba(0,0,0,0.08);
--mx-glow: 0 0 0 1px rgba(52,130,255,0.06), 0 2px 12px rgba(52,130,255,0.04);
/* ── Motion (Miuix spring + ease) ── */
--mx-ease: cubic-bezier(0.16, 1, 0.3, 1);
--mx-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--mx-d1: 100ms;
--mx-d2: 200ms;
--mx-d3: 320ms;
--mx-d4: 500ms;
/* ── Typography ── */
--mx-font: "MiSans", "Mi Sans", -apple-system, "PingFang SC", "Noto Sans SC", sans-serif;
--mx-mono: "JetBrains Mono", "SF Mono", monospace;
/* Typography scale */
--mx-text-xs: 11px;
--mx-text-sm: 12px;
--mx-text-base: 13px;
--mx-text-md: 14px;
--mx-text-lg: 15px;
--mx-text-xl: 16px;
--mx-text-2xl: 20px;
--mx-text-3xl: 24px;
/* ── Safe area ── */
--mx-safe-t: env(safe-area-inset-top, 0px);
--mx-safe-b: env(safe-area-inset-bottom, 0px);
}
/* ═══════════════════════════════════════════
DARK MODE — from Miuix darkColorScheme() 源码
═══════════════════════════════════════════ */
[data-theme="dark"] {
/* Primary — darkColorScheme primary = Color(0xFF277AF7) */
--mx-primary: #277AF7;
--mx-primary-hover: #338FE4;
--mx-primary-active: #0073DD;
--mx-primary-on: #FFFFFF;
--mx-primary-glow: rgba(39,122,247,0.15);
--mx-primary-container: #338FE4;
--mx-primary-container-on: #FFFFFF;
--mx-primary-variant: #0073DD;
--mx-primary-variant-on: #99C7F1;
--mx-tertiary-container: #2B3B54;
--mx-tertiary-container-on: #4788FF;
/* Error — darkColorScheme error = Color(0xFFF12522) */
--mx-error: #F12522;
--mx-error-on: #FFFFFF;
--mx-error-container: #2E0603;
--mx-error-container-on: #FFDAD6;
/* Surface — 5-level gradient with 3-5% brightness steps */
--mx-bg: #161616; /* 页面背景 — 最底层 */
--mx-surface: #0C0C0C; /* surface = s1 */
--mx-surface-variant: #1C1C1C; /* surfaceVariant = s2 */
--mx-s1: #0C0C0C; /* 导航栏、侧边栏 — 比 bg 深 */
--mx-s2: #1C1C1C; /* 卡片 — 比 bg 浅 */
--mx-s3: #242424; /* 输入框、浮层 — 比 s2 浅 */
--mx-s4: #2C2C2C; /* 下拉菜单 — 最亮 */
--mx-s-hover: rgba(255,255,255,0.04);
--mx-s-active: rgba(255,255,255,0.07);
/* Text — from Miuix darkColorScheme() 源码 */
--mx-text: #F2F2F2; /* onSurface = 0xFFF2F2F2 */
--mx-text-on-surface: rgba(255,255,255,0.8); /* 0xCCFFFFFF */
--mx-text-variant: rgba(255,255,255,0.5); /* 0x80FFFFFF */
--mx-text-actions: rgba(255,255,255,0.4); /* 0x66FFFFFF */
--mx-t2: rgba(255,255,255,0.6);
--mx-t3: rgba(255,255,255,0.4);
--mx-text-bg-variant: #787E96;
--mx-text-container-variant: #737373;
--mx-text-container-high: #666666;
--mx-text-disabled: #666666;
/* Secondary — from Miuix darkColorScheme() 源码 */
--mx-secondary: #505050;
--mx-secondary-variant: #434343;
--mx-secondary-container: #434343;
--mx-secondary-container-on: #7C7C7C;
/* Border / Divider — 提升对比度 */
--mx-border: #484848; /* outline = 提升到可见 */
--mx-divider: #404040; /* dividerLine = 提升到可见 */
/* Disabled */
--mx-disabled-primary: #253E64;
--mx-disabled-primary-on: #677993;
--mx-disabled-secondary: #3F3F3F;
--mx-disabled-secondary-on: #797979;
/* Window */
--mx-window-dim: rgba(0,0,0,0.6);
/* Slider */
--mx-slider-bg: rgba(255,255,255,0.15);
--mx-slider-key: rgba(122,138,166,0.3);
--mx-slider-key-fg: #5DAAFF;
/* Shadow — dark mode 阴影更重 */
--mx-sh1: 0 1px 4px rgba(0,0,0,0.5);
--mx-sh2: 0 4px 16px rgba(0,0,0,0.4);
--mx-sh3: 0 8px 32px rgba(0,0,0,0.45);
--mx-glow: 0 0 0 1px rgba(39,122,247,0.08), 0 2px 12px rgba(39,122,247,0.06);
}
/* ═══ COMPACT DENSITY ═══ */
[data-density="compact"] {
--mx-r: 14px;
--mx-r-sm: 10px;
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more