feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: 单位不存在")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e)
|
||||
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, "报名项不存在")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e); 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e); 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, ¬e, &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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user