feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
+177
-43
@@ -12,7 +12,9 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -23,8 +25,33 @@ const (
|
||||
CookieSession = "scoreboard_session"
|
||||
CookieCSRF = "csrf_token"
|
||||
|
||||
// DefaultSessionTTL 是会话有效期:12h。
|
||||
DefaultSessionTTL = 12 * time.Hour
|
||||
// DefaultSessionTTL 是登录有效期的默认值:24h。
|
||||
// 原来是 12h:现场前一晚 20:00 登录,次日早上 8:00 整被踢出登录,
|
||||
// 而那正是比赛进行中的时间——这正是本项需求要修的。
|
||||
DefaultSessionTTL = 24 * time.Hour
|
||||
// DefaultSessionHours 与 DefaultSessionTTL 同值,供以「小时」为单位的配置读写共用。
|
||||
DefaultSessionHours = 24
|
||||
// MinSessionHours / MaxSessionHours 是面板可配的登录有效期范围:1 小时 ~ 720 小时(30 天)。
|
||||
MinSessionHours = 1
|
||||
MaxSessionHours = 720
|
||||
// SessionHoursKey 是系统库 settings 里存登录有效期的键(机器级配置,与赛事级 settings 不共用)。
|
||||
SessionHoursKey = "security.session_hours"
|
||||
// SessionHoursSourceKey 记录 SessionHoursKey 是谁写的:面板写过的值一定是操作员的明确选择,
|
||||
// 不能被下面的历史占位值规则改写。
|
||||
SessionHoursSourceKey = "security.session_hours_source"
|
||||
// SessionHoursSourcePanel 是面板写入时登记的来源标记。
|
||||
SessionHoursSourcePanel = "panel"
|
||||
// legacySessionHoursSeed 是 0005 迁移种下的历史占位值。
|
||||
// 在本项改动之前,没有任何代码读过 security.session_hours,所以库里还留着 12
|
||||
// 只可能是「没人配过」;它按「未配置」处理,用新的默认 24,否则新装的库会永远停在 12。
|
||||
legacySessionHoursSeed = "12"
|
||||
// renewThreshold 是滑动续期的写入节流窗口:剩余寿命不足「有效期 - 1 分钟」时才回写 expires_at。
|
||||
// 每个请求都续期会让 sessions 变成热点写表(现场几十个客户端轮询),
|
||||
// 而少顺延这 1 分钟对「会不会掉线」没有任何影响。
|
||||
renewThreshold = time.Minute
|
||||
// touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。
|
||||
// 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。
|
||||
touchInterval = time.Minute
|
||||
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
|
||||
DefaultBcryptCost = 12
|
||||
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
|
||||
@@ -73,7 +100,10 @@ type Session struct {
|
||||
|
||||
// Options 是认证服务的可调参数;零值即文档默认。
|
||||
type Options struct {
|
||||
Logger *slog.Logger
|
||||
Logger *slog.Logger
|
||||
// SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值:
|
||||
// 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准;
|
||||
// 这里只在系统库里没有配置时兜底(例如测试用的空库)。
|
||||
SessionTTL time.Duration
|
||||
BcryptCost int
|
||||
LoginPerMinute int
|
||||
@@ -88,12 +118,14 @@ type Options struct {
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
sessionTTL time.Duration
|
||||
bcryptCost int
|
||||
secureCookies bool
|
||||
now func() time.Time
|
||||
loginLimiter *Limiter
|
||||
writeLimiter *Limiter
|
||||
// ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL),
|
||||
// 所以每个请求都会读它,必须用原子量而不是普通字段。
|
||||
ttl atomic.Int64
|
||||
loginLimiter *Limiter
|
||||
writeLimiter *Limiter
|
||||
}
|
||||
|
||||
// New 构建认证服务;db 为系统库(data/system.db)。
|
||||
@@ -102,9 +134,14 @@ func New(db *sql.DB, opts Options) *Service {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
ttl := opts.SessionTTL
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultSessionTTL
|
||||
// 优先级:面板(系统库设置)> 启动参数 / 配置文件 > 内置默认。
|
||||
// 面板排在最前是为了不出现「改了面板没反应」:只要系统库里有一个有效值,
|
||||
// 它就是这个进程的有效期,别处的默认值一律让位。
|
||||
ttl, source := DefaultSessionTTL, "内置默认"
|
||||
if dbTTL, ok := sessionTTLFromSettings(db); ok {
|
||||
ttl, source = dbTTL, "系统设置"
|
||||
} else if opts.SessionTTL > 0 {
|
||||
ttl, source = opts.SessionTTL, "启动参数"
|
||||
}
|
||||
cost := opts.BcryptCost
|
||||
if cost <= 0 {
|
||||
@@ -122,20 +159,86 @@ func New(db *sql.DB, opts Options) *Service {
|
||||
if writeLimit == 0 {
|
||||
writeLimit = DefaultWritePerMinute
|
||||
}
|
||||
return &Service{
|
||||
logger.Info("登录有效期", "hours", ttl.Hours(), "source", source,
|
||||
"滑动续期", true, "节流窗口", renewThreshold.String())
|
||||
s := &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),
|
||||
}
|
||||
s.ttl.Store(int64(ttl))
|
||||
return s
|
||||
}
|
||||
|
||||
// SessionTTL 返回会话有效期。
|
||||
func (s *Service) SessionTTL() time.Duration { return s.sessionTTL }
|
||||
// SessionTTL 返回当前生效的登录有效期。
|
||||
func (s *Service) SessionTTL() time.Duration { return time.Duration(s.ttl.Load()) }
|
||||
|
||||
// SetSessionTTL 更新登录有效期。只对之后创建的会话与之后的续期生效:
|
||||
// 已存在会话的 expires_at 一律不动——改设置不该把正在录入的人踢下线。
|
||||
func (s *Service) SetSessionTTL(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
s.ttl.Store(int64(d))
|
||||
}
|
||||
|
||||
// ParseSessionHours 解析登录有效期(小时);非法或超出 1~720 一律判为无效。
|
||||
func ParseSessionHours(raw string) (int, bool) {
|
||||
hours, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || hours < MinSessionHours || hours > MaxSessionHours {
|
||||
return 0, false
|
||||
}
|
||||
return hours, true
|
||||
}
|
||||
|
||||
// EffectiveSessionHours 算出实际生效的小时数。
|
||||
// 规则:面板写过的值原样生效(含操作员明确选的 12);迁移种下的历史占位值 12
|
||||
// 因为从来没有代码读过,按「未配置」处理,回落到新默认 24;缺键或非法同样是默认值。
|
||||
func EffectiveSessionHours(values map[string]string) int {
|
||||
raw := strings.TrimSpace(values[SessionHoursKey])
|
||||
if hours, ok := ParseSessionHours(raw); ok {
|
||||
if strings.TrimSpace(values[SessionHoursSourceKey]) != "" || raw != legacySessionHoursSeed {
|
||||
return hours
|
||||
}
|
||||
}
|
||||
return DefaultSessionHours
|
||||
}
|
||||
|
||||
// sessionTTLFromSettings 从系统库 settings 读登录有效期;没有有效配置(缺键或非法)时 ok=false,
|
||||
// 由调用方回落到启动参数 / 内置默认。数据库不可用不当作 fatal:认证要能带着默认值起来。
|
||||
func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) {
|
||||
if db == nil {
|
||||
return 0, false
|
||||
}
|
||||
values := make(map[string]string, 2)
|
||||
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN (?, ?)`,
|
||||
SessionHoursKey, SessionHoursSourceKey)
|
||||
if err != nil {
|
||||
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||
return 0, false
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
slog.Warn("解析登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||
return 0, false
|
||||
}
|
||||
values[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours)
|
||||
return 0, false
|
||||
}
|
||||
if _, ok := values[SessionHoursKey]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(EffectiveSessionHours(values)) * time.Hour, true
|
||||
}
|
||||
|
||||
// AllowLogin 按 IP 限流登录。
|
||||
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
|
||||
@@ -301,20 +404,22 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
||||
hash := hashToken(token)
|
||||
|
||||
var (
|
||||
u User
|
||||
sess Session
|
||||
expiresAt string
|
||||
role string
|
||||
u User
|
||||
sess Session
|
||||
expiresAt string
|
||||
lastSeenRaw string
|
||||
role string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''),
|
||||
COALESCE(s.last_seen_at, ''),
|
||||
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,
|
||||
Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, &lastSeenRaw,
|
||||
&u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
@@ -323,6 +428,7 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
||||
return nil, nil, fmt.Errorf("查询会话: %w", err)
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
exp, err := parseDBTime(expiresAt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -330,8 +436,26 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
|
||||
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)
|
||||
// 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期,
|
||||
// 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。
|
||||
//
|
||||
// 写入节流:只有剩余寿命已不足「有效期 - renewThreshold(1 分钟)」时才回写 expires_at。
|
||||
// 否则每点一次按钮、每次轮询都要写一遍 sessions,把单连接 sqlite 变成热点写表;
|
||||
// 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。
|
||||
if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold {
|
||||
renewed := now.Add(ttl)
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
|
||||
now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID)
|
||||
sess.ExpiresAt = renewed
|
||||
return &u, &sess, nil
|
||||
}
|
||||
// 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。
|
||||
// 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。
|
||||
lastSeen, err := parseDBTime(lastSeenRaw)
|
||||
if err != nil || now.Sub(lastSeen) >= touchInterval {
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
||||
now.Format(time.DateTime), sess.ID)
|
||||
}
|
||||
return &u, &sess, nil
|
||||
}
|
||||
|
||||
@@ -362,7 +486,7 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
expiresAt := s.now().UTC().Add(s.sessionTTL)
|
||||
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 (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -377,54 +501,64 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent
|
||||
|
||||
// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。
|
||||
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
|
||||
setSessionCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
|
||||
}
|
||||
|
||||
// RefreshSessionCookies 按会话到期时间重发会话与 CSRF Cookie。
|
||||
//
|
||||
// 滑动续期之后必须重发:Cookie 的 Max-Age 是浏览器端的硬期限,
|
||||
// 不跟着续期走的话,浏览器会在「登录时刻 + 有效期」处把 Cookie 丢掉,
|
||||
// 服务端续得再久也留不住人。CSRF 沿用原值(不轮换),避免和正在飞行的写请求打架。
|
||||
// 到期时间已过时不重发——调用方不该给一个已经失效的会话续 Cookie。
|
||||
func (s *Service) RefreshSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string, expiresAt time.Time) {
|
||||
life := expiresAt.Sub(s.now())
|
||||
if life <= 0 || sessionToken == "" {
|
||||
return
|
||||
}
|
||||
seconds := int(life.Seconds())
|
||||
setSessionCookie(w, s.secureCookies, sessionToken, seconds)
|
||||
if csrfToken != "" {
|
||||
setCSRFCookie(w, s.secureCookies, csrfToken, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieSession,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(s.sessionTTL.Seconds()),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: s.secureCookies,
|
||||
Secure: secure,
|
||||
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,
|
||||
})
|
||||
setSessionCookie(w, s.secureCookies, "", -1)
|
||||
}
|
||||
|
||||
// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。
|
||||
func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) {
|
||||
setCSRFCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds()))
|
||||
}
|
||||
|
||||
func setCSRFCookie(w http.ResponseWriter, secure bool, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieCSRF,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(s.sessionTTL.Seconds()),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: false,
|
||||
Secure: s.secureCookies,
|
||||
Secure: secure,
|
||||
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,
|
||||
})
|
||||
setCSRFCookie(w, s.secureCookies, "", -1)
|
||||
}
|
||||
|
||||
// NewCSRFToken 生成新的 CSRF Token。
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// 登录有效期的解析与新默认值:迁移种下的历史占位值 12 从未被任何代码读过,
|
||||
// 按「未配置」处理,否则新装的库会永远停在 12(现场前一晚登录、次日比赛当天掉线)。
|
||||
func TestEffectiveSessionHours(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
want int
|
||||
}{
|
||||
{"历史占位值 12(无面板标记)按未配置处理", map[string]string{SessionHoursKey: "12"}, 24},
|
||||
{"面板明确选过的 12 原样生效", map[string]string{SessionHoursKey: "12", SessionHoursSourceKey: "panel"}, 12},
|
||||
{"面板写的 72 生效", map[string]string{SessionHoursKey: "72", SessionHoursSourceKey: "panel"}, 72},
|
||||
{"未配置回默认 24", map[string]string{}, 24},
|
||||
{"上界 720", map[string]string{SessionHoursKey: "720"}, 720},
|
||||
{"下界 1", map[string]string{SessionHoursKey: "1"}, 1},
|
||||
{"0 非法回默认", map[string]string{SessionHoursKey: "0"}, 24},
|
||||
{"721 非法回默认", map[string]string{SessionHoursKey: "721"}, 24},
|
||||
{"非数字非法回默认", map[string]string{SessionHoursKey: "十二"}, 24},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := EffectiveSessionHours(tc.values); got != tc.want {
|
||||
t.Fatalf("EffectiveSessionHours(%v) = %d,期望 %d", tc.values, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSessionHours(t *testing.T) {
|
||||
for raw, want := range map[string]int{"1": 1, "24": 24, "720": 720, " 48 ": 48} {
|
||||
got, ok := ParseSessionHours(raw)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("ParseSessionHours(%q) = %d,%v,期望 %d,true", raw, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "0", "-1", "721", "24.5", "abc"} {
|
||||
if _, ok := ParseSessionHours(raw); ok {
|
||||
t.Fatalf("ParseSessionHours(%q) 应判为非法", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 系统库里的登录有效期要真正被认成会话寿命;显式传入的 Options.SessionTTL 优先(测试 / 嵌入用)。
|
||||
func TestSessionTTLFromSystemSettings(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
seed map[string]string
|
||||
optsTTL time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"历史占位值 12 视为未配置 → 24h", map[string]string{SessionHoursKey: "12"}, 0, 24 * time.Hour},
|
||||
{"面板写的 1 → 1h", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 0, time.Hour},
|
||||
{"面板里的值优先于启动参数", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 90 * time.Minute, time.Hour},
|
||||
{"系统库没有配置时用启动参数兜底", nil, 90 * time.Minute, 90 * time.Minute},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newSettingsDB(t, tc.seed)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: tc.optsTTL})
|
||||
if got := svc.SessionTTL(); got != tc.want {
|
||||
t.Fatalf("SessionTTL() = %s,期望 %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动续期:剩余寿命少于「有效期 - 节流窗口」时才回写,且回写后到期时间顺延到 now+有效期。
|
||||
func TestLookupSlidesExpiryWithThrottle(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
token := insertSession(t, db, userID, now.Add(24*time.Hour))
|
||||
|
||||
// 刚创建的会话:剩余 = 有效期,不写 expires_at(首次请求不该产生额外写)。
|
||||
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
} else if !sess.ExpiresAt.Equal(now.Add(24 * time.Hour)) {
|
||||
t.Fatalf("新鲜会话的到期时间不该变,得到 %s", sess.ExpiresAt)
|
||||
}
|
||||
if got := dbExpiry(t, db); got != now.Add(24*time.Hour).Format(time.DateTime) {
|
||||
t.Fatalf("新鲜会话不应回写 expires_at,库里是 %s", got)
|
||||
}
|
||||
|
||||
// 剩余 23h58m(不足 24h-1m):下一次请求应把到期时间顺延到 now+24h。
|
||||
before := now.Add(24*time.Hour - 2*time.Minute)
|
||||
setExpiry(t, db, before)
|
||||
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||
}
|
||||
if got, want := dbExpiry(t, db), now.Add(24*time.Hour).Format(time.DateTime); got != want {
|
||||
t.Fatalf("库里到期时间应为 %s,得到 %s", want, got)
|
||||
}
|
||||
|
||||
// 节流:紧接着的多次请求既不写 expires_at,也不写 last_seen_at——
|
||||
// 现场一次页面加载会打七八个请求,逐个写库纯属放大写压力。
|
||||
renewed := dbExpiry(t, db)
|
||||
seen := dbLastSeen(t, db)
|
||||
for i := 0; i < 5; i++ {
|
||||
now = now.Add(time.Second)
|
||||
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("第 %d 次查询会话: %v", i, err)
|
||||
}
|
||||
if got := dbExpiry(t, db); got != renewed {
|
||||
t.Fatalf("节流窗口内不该回写 expires_at:%s → %s", renewed, got)
|
||||
}
|
||||
if got := dbLastSeen(t, db); got != seen {
|
||||
t.Fatalf("节流窗口内不该回写 last_seen_at:%s → %s", seen, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 越过节流窗口(1 分钟)后再次顺延:活跃会话大约每分钟才写一次库。
|
||||
now = now.Add(5 * time.Minute)
|
||||
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||
}
|
||||
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
|
||||
t.Fatalf("续期时活动时间应一起回写为 %s,得到 %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 活动时间的节流不会影响续期:会话剩余寿命超过当前有效期(例如有效期被调短)时,
|
||||
// 只回写 last_seen_at,expires_at 保持原值。
|
||||
func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
token := insertSession(t, db, userID, now.Add(20*time.Hour))
|
||||
setLastSeen(t, db, now.Add(-time.Hour))
|
||||
stored := dbExpiry(t, db)
|
||||
|
||||
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
}
|
||||
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
|
||||
t.Fatalf("活动时间应回写为 %s,得到 %s", want, got)
|
||||
}
|
||||
if got := dbExpiry(t, db); got != stored {
|
||||
t.Fatalf("已登录会话的到期时间不该被改设置的续期逻辑改写:%s → %s", stored, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
|
||||
func TestLookupRenewsAfterThrottleWindow(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute))
|
||||
|
||||
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
|
||||
t.Fatalf("查询会话: %v", err)
|
||||
} else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 到期校验不能被续期改坏:已过期的会话仍然查不到(调用方据此回 401)。
|
||||
func TestLookupRejectsExpiredSession(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
token := insertSession(t, db, userID, now.Add(-time.Minute))
|
||||
|
||||
if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound {
|
||||
t.Fatalf("过期会话应返回 ErrSessionNotFound,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 改有效期不动已存在的会话:只换进程内的 TTL,库里的 expires_at 原样保留。
|
||||
func TestSetSessionTTLKeepsExistingExpiry(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
token := insertSession(t, db, userID, now.Add(time.Hour))
|
||||
stored := dbExpiry(t, db)
|
||||
|
||||
svc.SetSessionTTL(72 * time.Hour)
|
||||
|
||||
if got := svc.SessionTTL(); got != 72*time.Hour {
|
||||
t.Fatalf("SessionTTL() = %s,期望 72h", got)
|
||||
}
|
||||
if got := dbExpiry(t, db); got != stored {
|
||||
t.Fatalf("改有效期不该动已存在会话的 expires_at:%s → %s", stored, got)
|
||||
}
|
||||
// 旧会话下一次被使用时按新的、更长的有效期顺延(是延长,不是踢下线)。
|
||||
_, sess, err := svc.Lookup(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("旧会话不该被踢下线: %v", err)
|
||||
}
|
||||
if want := now.Add(72 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("旧会话应顺延到 %s,得到 %s", want, sess.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 新会话按改后的有效期创建。
|
||||
func TestCreateSessionUsesCurrentTTL(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
|
||||
svc.SetSessionTTL(720 * time.Hour)
|
||||
if _, sess, err := svc.createSession(context.Background(), userID, "127.0.0.1", "test"); err != nil {
|
||||
t.Fatalf("创建会话: %v", err)
|
||||
} else if want := now.Add(720 * time.Hour); !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("新会话应在 %s 到期,得到 %s", want, sess.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 测试脚手架 ----------
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
const testSchema = `
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'recorder',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
last_login_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);`
|
||||
|
||||
func newSettingsDB(t *testing.T, seed map[string]string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "system.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if _, err := db.Exec(testSchema); err != nil {
|
||||
t.Fatalf("建表: %v", err)
|
||||
}
|
||||
for k, v := range seed {
|
||||
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)`, k, v); err != nil {
|
||||
t.Fatalf("写入设置 %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func insertUser(t *testing.T, db *sql.DB, username string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO users (username, role) VALUES (?, 'admin')`, username)
|
||||
if err != nil {
|
||||
t.Fatalf("建用户: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func insertSession(t *testing.T, db *sql.DB, userID int64, expiresAt time.Time) string {
|
||||
t.Helper()
|
||||
token := "token-" + expiresAt.Format(time.RFC3339Nano)
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"sid", userID, hashToken(token), expiresAt.Format(time.DateTime), expiresAt.Format(time.DateTime)); err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func setExpiry(t *testing.T, db *sql.DB, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`UPDATE sessions SET expires_at = ?`, expiresAt.Format(time.DateTime)); err != nil {
|
||||
t.Fatalf("改到期时间: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setLastSeen(t *testing.T, db *sql.DB, at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = ?`, at.Format(time.DateTime)); err != nil {
|
||||
t.Fatalf("改活动时间: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func dbExpiry(t *testing.T, db *sql.DB) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := db.QueryRow(`SELECT expires_at FROM sessions`).Scan(&v); err != nil {
|
||||
t.Fatalf("读 expires_at: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func dbLastSeen(t *testing.T, db *sql.DB) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := db.QueryRow(`SELECT last_seen_at FROM sessions`).Scan(&v); err != nil {
|
||||
t.Fatalf("读 last_seen_at: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -55,6 +55,10 @@ const (
|
||||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||||
SettingKeyLiveEvents = "display.live_events"
|
||||
SettingKeyLiveSource = "display.live_source"
|
||||
// SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。
|
||||
// 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。
|
||||
// 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。
|
||||
SettingKeyLiveOnPublish = "standings.live_on_publish"
|
||||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||||
MaxLiveEvents = 2
|
||||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||||
@@ -116,6 +120,8 @@ type EventSettingsStandings struct {
|
||||
RankingMode string `json:"rankingMode"`
|
||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||||
// LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。
|
||||
LiveOnPublish bool `json:"liveOnPublish"`
|
||||
}
|
||||
|
||||
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
|
||||
@@ -132,10 +138,27 @@ func (s EventSettings) WithoutAdminFields() EventSettings {
|
||||
RankingMode: s.Standings.RankingMode,
|
||||
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
|
||||
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
|
||||
// 结算方式不是系统机密,而是录入员必须知道的现场行为:
|
||||
// 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。
|
||||
LiveOnPublish: s.Standings.LiveOnPublish,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。
|
||||
//
|
||||
// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings
|
||||
// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。
|
||||
type SessionSettings struct {
|
||||
SessionHours int `json:"sessionHours"`
|
||||
// DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。
|
||||
DefaultHours int `json:"defaultSessionHours"`
|
||||
MinHours int `json:"minHours"`
|
||||
MaxHours int `json:"maxHours"`
|
||||
// ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
||||
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
||||
type RuleSwitches struct {
|
||||
@@ -188,6 +211,8 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
||||
out.Standings.ShowIndividualBoard = boolDefault(
|
||||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||||
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
||||
// 缺键 = 实时结算:老赛事升级后行为必须与升级前完全一致。
|
||||
out.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], true)
|
||||
|
||||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 基础数据包的四个工作表名。导入按这几个中文名认表:缺哪张就整张跳过,
|
||||
// 一张都没有才报错——「没写的表」代表不改,绝不代表删除。
|
||||
const (
|
||||
BaseDataSheetTeams = "单位"
|
||||
BaseDataSheetSports = "大项"
|
||||
BaseDataSheetEvents = "小项"
|
||||
BaseDataSheetCategories = "组别"
|
||||
)
|
||||
|
||||
// BaseDataHeader 是一列表头与字段的对应关系。
|
||||
// Header 是导出写进单元格的中文表头,Field 是导入按它取值的规范字段名。
|
||||
// 导出与导入共用这一份定义:表头文案只有一处,往返(导出→原样导入)天然对得上。
|
||||
type BaseDataHeader struct {
|
||||
Header string
|
||||
Field string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// BaseDataSheetSpec 是一张基础数据表的导入契约:表名 + 表头集合。
|
||||
type BaseDataSheetSpec struct {
|
||||
Sheet string
|
||||
Headers []BaseDataHeader
|
||||
}
|
||||
|
||||
// baseDataColumn 在表头之外带上版式(列宽 / 对齐),仅供导出用。
|
||||
type baseDataColumn struct {
|
||||
BaseDataHeader
|
||||
width float64
|
||||
kind cellKind
|
||||
}
|
||||
|
||||
type baseDataSheet struct {
|
||||
sheet string
|
||||
columns []baseDataColumn
|
||||
}
|
||||
|
||||
// baseDataSheets 是基础数据包的全部内容定义,顺序即导入顺序:
|
||||
// 单位 → 大项 → 小项 → 组别,后者依赖前者的编码(组别依赖小项,小项依赖大项)。
|
||||
var baseDataSheets = []baseDataSheet{
|
||||
{
|
||||
sheet: BaseDataSheetTeams,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"单位编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"单位名称", "name", true}, 24, kindText},
|
||||
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||
{BaseDataHeader{"颜色", "color", false}, 12, kindCenter},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetSports,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"大项编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"大项名称", "name", true}, 24, kindText},
|
||||
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetEvents,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"大项编码", "sportCode", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"小项编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"小项名称", "name", true}, 22, kindText},
|
||||
{BaseDataHeader{"类型", "eventType", false}, 14, kindCenter},
|
||||
{BaseDataHeader{"结果模式", "resultMode", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"结果类型", "resultType", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"比较", "compare", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"计分", "scored", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"计破纪录", "recordEligible", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||
{BaseDataHeader{"状态", "status", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"平均口径", "avgMode", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"场地", "venue", false}, 16, kindText},
|
||||
{BaseDataHeader{"备注", "remark", false}, 20, kindText},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetCategories,
|
||||
columns: []baseDataColumn{
|
||||
// 组别必须带所属小项编码,否则导回来时对不上是哪个体项目的组别。
|
||||
{BaseDataHeader{"小项编码", "eventCode", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"组别名", "name", true}, 16, kindText},
|
||||
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||
{BaseDataHeader{"性别", "gender", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"年龄下限", "ageMin", false}, 10, kindInt},
|
||||
{BaseDataHeader{"年龄上限", "ageMax", false}, 10, kindInt},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// BaseDataSpecs 返回基础数据包的导入契约(表名与表头),顺序即导入顺序。
|
||||
// 调用方(httpapi)拿它做表头识别与行级校验,不再自己写一份中文表头。
|
||||
func BaseDataSpecs() []BaseDataSheetSpec {
|
||||
out := make([]BaseDataSheetSpec, 0, len(baseDataSheets))
|
||||
for _, s := range baseDataSheets {
|
||||
spec := BaseDataSheetSpec{Sheet: s.sheet, Headers: make([]BaseDataHeader, 0, len(s.columns))}
|
||||
for _, c := range s.columns {
|
||||
spec.Headers = append(spec.Headers, c.BaseDataHeader)
|
||||
}
|
||||
out = append(out, spec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BaseDataRecord 是一行待写出的基础数据:字段名 → 单元格值。
|
||||
type BaseDataRecord map[string]any
|
||||
|
||||
// BaseDataWorkbook 是基础数据包的内容,一张表一份记录列表。
|
||||
type BaseDataWorkbook struct {
|
||||
Meta Meta
|
||||
Teams []BaseDataRecord
|
||||
Sports []BaseDataRecord
|
||||
Events []BaseDataRecord
|
||||
Categories []BaseDataRecord
|
||||
}
|
||||
|
||||
// records 按工作表名取记录,顺序与 baseDataSheets 一致。
|
||||
func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
|
||||
switch sheet {
|
||||
case BaseDataSheetTeams:
|
||||
return wb.Teams
|
||||
case BaseDataSheetSports:
|
||||
return wb.Sports
|
||||
case BaseDataSheetEvents:
|
||||
return wb.Events
|
||||
case BaseDataSheetCategories:
|
||||
return wb.Categories
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExportBaseDataXLSX 导出基础数据包到 dst。
|
||||
func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error {
|
||||
wb, err := LoadBaseDataWorkbook(db, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := baseDataFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
|
||||
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
|
||||
f, err := baseDataFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func baseDataFile(wb BaseDataWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook(BaseDataSheetTeams, func(f *excelize.File, st *styleSet) error {
|
||||
meta := wb.Meta.normalized()
|
||||
namer := &sheetNamer{}
|
||||
for _, s := range baseDataSheets {
|
||||
name := namer.add(s.sheet)
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
cols := make([]column, 0, len(s.columns))
|
||||
for _, c := range s.columns {
|
||||
cols = append(cols, column{c.Header, c.width, c.kind})
|
||||
}
|
||||
rows := baseDataRows(s, wb.records(s.sheet))
|
||||
if err := writeSheet(f, st, name, meta, sheetTitle(meta, s.sheet), cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// baseDataRows 按列定义把记录摊平成行;记录里缺的字段写空,不写 "0" / "false" 这类噪声。
|
||||
func baseDataRows(s baseDataSheet, records []BaseDataRecord) [][]any {
|
||||
out := make([][]any, 0, len(records))
|
||||
for _, rec := range records {
|
||||
row := make([]any, 0, len(s.columns))
|
||||
for _, c := range s.columns {
|
||||
v, ok := rec[c.Field]
|
||||
if !ok || v == nil {
|
||||
v = ""
|
||||
}
|
||||
row = append(row, v)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boolCell 把布尔值写成「是 / 否」:中文表头配中文取值,现场改表时不用查英文枚举。
|
||||
func boolCell(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
// LoadBaseDataWorkbook 读取全部基础数据(单位 / 大项 / 小项 / 组别)。
|
||||
//
|
||||
// 单位排除「彻底删除」(deleted_at 非空)的行:它们对界面不可见,也不该被重新印进导出件;
|
||||
// 停用(active=0)的单位照常导出,启用列写「否」,导入回来才不会把它误改回启用。
|
||||
func LoadBaseDataWorkbook(db *sql.DB, opts Options) (BaseDataWorkbook, error) {
|
||||
if db == nil {
|
||||
return BaseDataWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
|
||||
teams, err := loadBaseDataTeams(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
sports, err := loadBaseDataSports(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
events, err := loadBaseDataEvents(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
categories, err := loadBaseDataCategories(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
return BaseDataWorkbook{Meta: meta, Teams: teams, Sports: sports, Events: events, Categories: categories}, nil
|
||||
}
|
||||
|
||||
func loadBaseDataTeams(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT code, name, COALESCE(short_name, ''), COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE deleted_at IS NULL ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
code, name, shortName, color string
|
||||
sortOrder, active int
|
||||
)
|
||||
if err := rows.Scan(&code, &name, &shortName, &color, &sortOrder, &active); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描单位: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"code": code, "name": name, "shortName": shortName, "color": color,
|
||||
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataSports(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT code, name, COALESCE(short_name, ''), sort_order, active
|
||||
FROM sports ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取大项: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
code, name, shortName string
|
||||
sortOrder, active int
|
||||
)
|
||||
if err := rows.Scan(&code, &name, &shortName, &sortOrder, &active); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描大项: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"code": code, "name": name, "shortName": shortName,
|
||||
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataEvents(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type,
|
||||
e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode,
|
||||
e.sort_order, COALESCE(e.venue, ''), COALESCE(e.remark, '')
|
||||
FROM events e
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
ORDER BY COALESCE(s.sort_order, 0), e.sort_order, e.code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取小项: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
sportCode, code, name string
|
||||
eventType, resultMode, resultType, cmpare string
|
||||
status, avgMode, venue, remark string
|
||||
scored, recordEligible, sortOrder int
|
||||
multiplier float64
|
||||
)
|
||||
if err := rows.Scan(&sportCode, &code, &name, &eventType, &resultMode, &resultType,
|
||||
&cmpare, &scored, &recordEligible, &multiplier, &status, &avgMode,
|
||||
&sortOrder, &venue, &remark); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描小项: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"sportCode": sportCode, "code": code, "name": name,
|
||||
"eventType": eventType, "resultMode": resultMode, "resultType": resultType,
|
||||
"compare": cmpare, "scored": boolCell(scored != 0), "recordEligible": boolCell(recordEligible != 0),
|
||||
"multiplier": multiplier, "status": status, "avgMode": avgMode,
|
||||
"sortOrder": sortOrder, "venue": venue, "remark": remark,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataCategories(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(e.code, ''), c.name, c.multiplier, COALESCE(c.gender, ''),
|
||||
c.age_min, c.age_max, c.sort_order
|
||||
FROM event_categories c
|
||||
LEFT JOIN events e ON e.id = c.event_id
|
||||
ORDER BY COALESCE(e.sort_order, 0), e.code, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取组别: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
eventCode, name, gender string
|
||||
multiplier float64
|
||||
ageMin, ageMax sql.NullInt64
|
||||
sortOrder int
|
||||
)
|
||||
if err := rows.Scan(&eventCode, &name, &multiplier, &gender, &ageMin, &ageMax, &sortOrder); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描组别: %w", err)
|
||||
}
|
||||
rec := BaseDataRecord{
|
||||
"eventCode": eventCode, "name": name, "multiplier": multiplier,
|
||||
"gender": gender, "sortOrder": sortOrder,
|
||||
}
|
||||
rec["ageMin"], rec["ageMax"] = "", ""
|
||||
if ageMin.Valid {
|
||||
rec["ageMin"] = int(ageMin.Int64)
|
||||
}
|
||||
if ageMax.Valid {
|
||||
rec["ageMax"] = int(ageMax.Int64)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 导入侧:读取工作簿 ----------
|
||||
|
||||
// BaseDataRow 是基础数据表里的一行数据。
|
||||
type BaseDataRow struct {
|
||||
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
|
||||
Line int
|
||||
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
|
||||
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
|
||||
Cells map[string]string
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
// BaseDataTable 是从工作簿里解析出的一张表。
|
||||
type BaseDataTable struct {
|
||||
Sheet string
|
||||
HeaderLine int
|
||||
Rows []BaseDataRow
|
||||
}
|
||||
|
||||
// maxHeaderScanRows 是表头检测的扫描范围。
|
||||
// 本系统的导出件在表头上方有标题与副标题(表头在第 4 行),手工整理的表通常表头在第 1 行,
|
||||
// 再往上加两三行说明也有可能;扫前 20 行足够,又不会被中间某行数据误判成表头。
|
||||
const maxHeaderScanRows = 20
|
||||
|
||||
// ReadBaseDataPackage 解析基础数据包工作簿,按工作表返回表头位置与数据行。
|
||||
// 只认 baseDataSheets 里的四个中文表名,别的工作表一律忽略;一张都没找到时返回错误。
|
||||
func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 打开基础数据包: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sheets := f.GetSheetList()
|
||||
present := make(map[string]bool, len(sheets))
|
||||
for _, name := range sheets {
|
||||
present[strings.TrimSpace(name)] = true
|
||||
}
|
||||
|
||||
tables := make([]BaseDataTable, 0, len(baseDataSheets))
|
||||
for _, spec := range BaseDataSpecs() {
|
||||
if !present[spec.Sheet] {
|
||||
continue
|
||||
}
|
||||
rows, err := f.GetRows(spec.Sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
|
||||
}
|
||||
table, err := parseBaseDataSheet(spec, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("export: 基础数据包缺少可识别的工作表(应为 %s)", baseDataSheetNames())
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func baseDataSheetNames() string {
|
||||
names := make([]string, 0, len(baseDataSheets))
|
||||
for _, s := range baseDataSheets {
|
||||
names = append(names, s.sheet)
|
||||
}
|
||||
return strings.Join(names, " / ")
|
||||
}
|
||||
|
||||
// parseBaseDataSheet 在表头行定位成功后,把其后的数据行逐行摊成「字段 → 原文」。
|
||||
// 整行为空的行(用户在表尾留的空行)直接跳过,不产生「必填」误报。
|
||||
func parseBaseDataSheet(spec BaseDataSheetSpec, rows [][]string) (BaseDataTable, error) {
|
||||
headerLine, index := findBaseDataHeader(spec, rows)
|
||||
if headerLine < 0 {
|
||||
return BaseDataTable{}, fmt.Errorf("export: 工作表 %s 找不到表头行:应有一行同时包含 %s(本系统导出的包把表头放在标题行下方)",
|
||||
spec.Sheet, strings.Join(requiredHeaders(spec), "、"))
|
||||
}
|
||||
|
||||
table := BaseDataTable{Sheet: spec.Sheet, HeaderLine: headerLine + 1}
|
||||
for i := headerLine + 1; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
cells := make(map[string]string, len(index))
|
||||
has := make(map[string]bool, len(index))
|
||||
blank := true
|
||||
for _, h := range spec.Headers {
|
||||
col, ok := index[h.Field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
has[h.Field] = true
|
||||
v := ""
|
||||
if col < len(row) {
|
||||
v = strings.TrimSpace(row[col])
|
||||
}
|
||||
cells[h.Field] = v
|
||||
if v != "" {
|
||||
blank = false
|
||||
}
|
||||
}
|
||||
if blank {
|
||||
continue
|
||||
}
|
||||
table.Rows = append(table.Rows, BaseDataRow{Line: i + 1, Cells: cells, Has: has})
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// findBaseDataHeader 找出表头行:返回行下标(0 起)与「字段 → 列下标」。
|
||||
// 判据是该行必须同时出现全部必填表头,避免把标题行或数据行认成表头。
|
||||
func findBaseDataHeader(spec BaseDataSheetSpec, rows [][]string) (int, map[string]int) {
|
||||
limit := len(rows)
|
||||
if limit > maxHeaderScanRows {
|
||||
limit = maxHeaderScanRows
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
byHeader := make(map[string]int, len(rows[i]))
|
||||
for j, cell := range rows[i] {
|
||||
text := strings.TrimSpace(cell)
|
||||
if _, dup := byHeader[text]; !dup {
|
||||
byHeader[text] = j
|
||||
}
|
||||
}
|
||||
index := make(map[string]int, len(spec.Headers))
|
||||
missing := false
|
||||
for _, h := range spec.Headers {
|
||||
col, ok := byHeader[h.Header]
|
||||
if !ok {
|
||||
if h.Required {
|
||||
missing = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
index[h.Field] = col
|
||||
}
|
||||
if !missing {
|
||||
return i, index
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func requiredHeaders(spec BaseDataSheetSpec) []string {
|
||||
out := make([]string, 0, len(spec.Headers))
|
||||
for _, h := range spec.Headers {
|
||||
if h.Required {
|
||||
out = append(out, h.Header)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 导入侧用的小工具 ----------
|
||||
|
||||
// ParseBaseDataBool 解析「是 / 否」型单元格:空值取 fallback,认不出的写法返回 false。
|
||||
func ParseBaseDataBool(raw string, fallback bool) (bool, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "":
|
||||
return fallback, true
|
||||
case "是", "1", "true", "yes", "y", "启用", "on":
|
||||
return true, true
|
||||
case "否", "0", "false", "no", "n", "停用", "off":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBaseDataFloat 解析数值列;空值取 fallback。
|
||||
func ParseBaseDataFloat(raw string, fallback float64) (float64, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return fallback, true
|
||||
}
|
||||
v, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ParseBaseDataInt 解析整数列;空值返回 nil。返回的第二个值表示是否合法。
|
||||
func ParseBaseDataInt(raw string) (*int, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return nil, true
|
||||
}
|
||||
v, err := strconv.Atoi(text)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &v, true
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
|
||||
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
|
||||
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
|
||||
func seedBaseData(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
|
||||
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
|
||||
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
|
||||
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, sort_order, venue, remark)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
|
||||
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
|
||||
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||||
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
|
||||
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
|
||||
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if row.Cells[field] == value {
|
||||
return row
|
||||
}
|
||||
}
|
||||
t.Fatalf("找不到 %s = %q 的行", field, value)
|
||||
return BaseDataRow{}
|
||||
}
|
||||
|
||||
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
|
||||
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
|
||||
func TestBaseDataPackageRoundTrip(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedBaseData(t, db)
|
||||
|
||||
wb, err := LoadBaseDataWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取基础数据: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
|
||||
t.Fatalf("写出基础数据包: %v", err)
|
||||
}
|
||||
|
||||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("读回基础数据包: %v", err)
|
||||
}
|
||||
if len(tables) != 4 {
|
||||
t.Fatalf("工作表数 = %d,期望 4", len(tables))
|
||||
}
|
||||
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
|
||||
wantRows := []int{2, 2, 3, 4}
|
||||
for i, table := range tables {
|
||||
if table.Sheet != wantSheets[i] {
|
||||
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
|
||||
}
|
||||
if table.HeaderLine != rowHeader {
|
||||
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
|
||||
}
|
||||
if len(table.Rows) != wantRows[i] {
|
||||
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
|
||||
}
|
||||
// 表头必须与导出契约逐字一致,否则导入端认不出列。
|
||||
spec := BaseDataSpecs()[i]
|
||||
for _, h := range spec.Headers {
|
||||
found := false
|
||||
for _, row := range table.Rows {
|
||||
if row.Has[h.Field] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(table.Rows) > 0 {
|
||||
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teams := tables[0]
|
||||
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
|
||||
t.Errorf("单位编码 = %q,期望 T1", got)
|
||||
}
|
||||
t1 := findRecord(t, teams.Rows, "code", "T1")
|
||||
if got := t1.Cells["active"]; got != "是" {
|
||||
t.Errorf("T1 启用 = %q,期望 是", got)
|
||||
}
|
||||
if got := t1.Cells["color"]; got != "#3482FF" {
|
||||
t.Errorf("T1 颜色 = %q", got)
|
||||
}
|
||||
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
|
||||
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
|
||||
}
|
||||
|
||||
events := tables[2]
|
||||
e1 := findRecord(t, events.Rows, "code", "E1")
|
||||
if got := e1.Cells["sportCode"]; got != "SP1" {
|
||||
t.Errorf("小项大项编码 = %q,期望 SP1", got)
|
||||
}
|
||||
if got := e1.Cells["multiplier"]; got != "2" {
|
||||
t.Errorf("E1 权重 = %q,期望 2", got)
|
||||
}
|
||||
if got := e1.Cells["venue"]; got != "主体育场" {
|
||||
t.Errorf("E1 场地 = %q", got)
|
||||
}
|
||||
if got := e1.Cells["resultType"]; got != "time" {
|
||||
t.Errorf("E1 结果类型 = %q,期望 time", got)
|
||||
}
|
||||
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
|
||||
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
|
||||
}
|
||||
|
||||
cats := tables[3]
|
||||
findRecord(t, cats.Rows, "eventCode", "E1")
|
||||
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
|
||||
if got := c1.Cells["eventCode"]; got != "E1" {
|
||||
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
|
||||
}
|
||||
if got := c1.Cells["ageMin"]; got != "18" {
|
||||
t.Errorf("年龄下限 = %q,期望 18", got)
|
||||
}
|
||||
if got := c1.Cells["gender"]; got != "男" {
|
||||
t.Errorf("性别 = %q,期望 男", got)
|
||||
}
|
||||
if got := c1.Cells["multiplier"]; got != "1.5" {
|
||||
t.Errorf("组别权重 = %q,期望 1.5", got)
|
||||
}
|
||||
|
||||
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
|
||||
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("打开基础数据包: %v", err)
|
||||
}
|
||||
defer func() { _ = raw.Close() }()
|
||||
headerRow, err := raw.GetRows(BaseDataSheetCategories)
|
||||
if err != nil {
|
||||
t.Fatalf("读取组别表: %v", err)
|
||||
}
|
||||
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
|
||||
if got := cell(headerRow, rowHeader-1, i); got != h {
|
||||
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
|
||||
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||
t.Fatalf("重命名工作表: %v", err)
|
||||
}
|
||||
rows := [][]string{
|
||||
{"单位编码", "单位名称", "启用"},
|
||||
{"T9", "第九分公司", "是"},
|
||||
{"T8", "第八分公司", "否"},
|
||||
}
|
||||
for r, row := range rows {
|
||||
for c, v := range row {
|
||||
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err := f.SetCellValue("单位", name, v); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
|
||||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if len(tables) != 1 || tables[0].Sheet != "单位" {
|
||||
t.Fatalf("只应认出单位表,得到 %+v", tables)
|
||||
}
|
||||
if tables[0].HeaderLine != 1 {
|
||||
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
|
||||
}
|
||||
if len(tables[0].Rows) != 2 {
|
||||
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
|
||||
}
|
||||
if got := tables[0].Rows[0].Line; got != 2 {
|
||||
t.Errorf("首行行号 = %d,期望 2", got)
|
||||
}
|
||||
if got := tables[0].Rows[1].Line; got != 3 {
|
||||
t.Errorf("次行行号 = %d,期望 3", got)
|
||||
}
|
||||
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
|
||||
if tables[0].Rows[0].Has["shortName"] {
|
||||
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
|
||||
}
|
||||
if !tables[0].Rows[0].Has["active"] {
|
||||
t.Errorf("文件里有启用列,Has[active] 应为 true")
|
||||
}
|
||||
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
|
||||
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBaseDataPackageErrors(t *testing.T) {
|
||||
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||
t.Fatalf("重命名工作表: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
|
||||
t.Fatal("找不到表头应报错")
|
||||
} else if !strings.Contains(err.Error(), "单位编码") {
|
||||
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
|
||||
}
|
||||
|
||||
// 一张可识别的表都没有。
|
||||
f2 := excelize.NewFile()
|
||||
defer func() { _ = f2.Close() }()
|
||||
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
var buf2 bytes.Buffer
|
||||
if err := f2.Write(&buf2); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
|
||||
t.Fatal("没有任何可识别工作表应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBaseDataHelpers(t *testing.T) {
|
||||
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
|
||||
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
|
||||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
|
||||
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
|
||||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
|
||||
}
|
||||
}
|
||||
if v, ok := ParseBaseDataBool("", true); !ok || !v {
|
||||
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataBool("也许", true); ok {
|
||||
t.Error("认不出的写法应报不合法")
|
||||
}
|
||||
|
||||
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
|
||||
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
|
||||
t.Error("非数字权重应报不合法")
|
||||
}
|
||||
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
|
||||
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataInt("三"); ok {
|
||||
t.Error("非整数应报不合法")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// entriesSheet 是「报名名单」唯一那张表的表名。
|
||||
const entriesSheet = "报名名单"
|
||||
|
||||
// EntriesFilter 是报名名单的筛选条件;某一项为零值即该维度不筛选(全部)。
|
||||
// heat 的合法值从 1 起,所以 0 可以安全地表示「不按批次筛选」。
|
||||
type EntriesFilter struct {
|
||||
EventID int64
|
||||
CategoryID int64
|
||||
TeamID int64
|
||||
Heat int64
|
||||
}
|
||||
|
||||
// EntryExportRow 是报名名单的一行。
|
||||
// 批次(heat)属于报名数据,是用户点名要看到的列:未分批时留空,也要占着一列。
|
||||
type EntryExportRow struct {
|
||||
EventCode string
|
||||
EventName string
|
||||
Category string
|
||||
TeamCode string
|
||||
TeamName string
|
||||
Name string
|
||||
IDCard string
|
||||
Bib string
|
||||
Heat string
|
||||
Gender string
|
||||
Age any
|
||||
Status string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// EntriesWorkbook 是「报名名单」的内容:一张平铺表。
|
||||
type EntriesWorkbook struct {
|
||||
Meta Meta
|
||||
Rows []EntryExportRow
|
||||
}
|
||||
|
||||
// entriesColumns 的列序与用户点名要的口径一致:批次紧跟在号码布之后。
|
||||
// 号码布用 kindText:它是带前导零的字符串("007"),任何数字格式都会把它吃掉。
|
||||
var entriesColumns = []column{
|
||||
{"小项编码", 12, kindCenter},
|
||||
{"小项名", 18, kindText},
|
||||
{"组别", 14, kindText},
|
||||
{"单位编码", 12, kindCenter},
|
||||
{"单位名", 22, kindText},
|
||||
{"姓名", 16, kindText},
|
||||
{"证件号", 22, kindCenter},
|
||||
{"号码布", 10, kindText},
|
||||
{"批次", 8, kindCenter},
|
||||
{"性别", 8, kindCenter},
|
||||
{"年龄", 8, kindInt},
|
||||
{"状态", 12, kindCenter},
|
||||
{"备注", 20, kindText},
|
||||
}
|
||||
|
||||
// ExportEntriesXLSX 导出报名名单到 dst。
|
||||
func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error {
|
||||
wb, err := LoadEntriesWorkbook(db, filter, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := entriesFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteEntriesXLSX 把报名名单写成 xlsx 流,供 HTTP 直接响应下载。
|
||||
func WriteEntriesXLSX(w io.Writer, wb EntriesWorkbook) error {
|
||||
f, err := entriesFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func entriesFile(wb EntriesWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook(entriesSheet, func(f *excelize.File, st *styleSet) error {
|
||||
meta := wb.Meta.normalized()
|
||||
name := sanitizeSheetName(entriesSheet)
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSheet(f, st, name, meta, sheetTitle(meta, entriesSheet), entriesColumns, entriesDataRows(wb.Rows))
|
||||
})
|
||||
}
|
||||
|
||||
func entriesDataRows(rows []EntryExportRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.EventCode, r.EventName, r.Category, r.TeamCode, r.TeamName,
|
||||
r.Name, r.IDCard, r.Bib, r.Heat, r.Gender, r.Age, r.Status, r.Remark,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadEntriesWorkbook 读取报名名单;筛选条件为空表示导出全部。
|
||||
//
|
||||
// 读的是报名表本身(entries),不是成绩表:批次、号码布、状态都是报名侧的数据。
|
||||
// 姓名取运动员姓名,团体行(athlete_id 为空)回落到成员名单,与分项成绩表同口径。
|
||||
func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (EntriesWorkbook, error) {
|
||||
if db == nil {
|
||||
return EntriesWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(t.code, ''), COALESCE(t.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
|
||||
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
|
||||
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, '')
|
||||
FROM entries en
|
||||
JOIN events ev ON ev.id = en.event_id
|
||||
JOIN teams t ON t.id = en.team_id
|
||||
LEFT JOIN event_categories c ON c.id = en.category_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id`
|
||||
var (
|
||||
conds []string
|
||||
args []any
|
||||
)
|
||||
for _, f := range []struct {
|
||||
column string
|
||||
value int64
|
||||
}{
|
||||
{"en.event_id", filter.EventID},
|
||||
{"en.category_id", filter.CategoryID},
|
||||
{"en.team_id", filter.TeamID},
|
||||
{"en.heat", filter.Heat},
|
||||
} {
|
||||
if f.value != 0 {
|
||||
conds = append(conds, f.column+" = ?")
|
||||
args = append(args, f.value)
|
||||
}
|
||||
}
|
||||
if len(conds) > 0 {
|
||||
query += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
// 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。
|
||||
// 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。
|
||||
query += `
|
||||
ORDER BY ev.sort_order, ev.code, c.sort_order, c.id,
|
||||
en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id`
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 读取报名名单: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []EntryExportRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
row EntryExportRow
|
||||
memberJSON string
|
||||
heat sql.NullInt64
|
||||
age sql.NullInt64
|
||||
athleteName string
|
||||
)
|
||||
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
|
||||
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
|
||||
&row.Gender, &age, &row.Status, &row.Remark); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
|
||||
}
|
||||
row.Name = participantName(athleteName, memberJSON)
|
||||
if heat.Valid {
|
||||
row.Heat = strconv.FormatInt(heat.Int64, 10)
|
||||
}
|
||||
if age.Valid {
|
||||
row.Age = int(age.Int64)
|
||||
} else {
|
||||
row.Age = ""
|
||||
}
|
||||
row.Status = entryExportStatusLabel(row.Status)
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 遍历报名名单: %w", err)
|
||||
}
|
||||
return EntriesWorkbook{Meta: meta, Rows: out}, nil
|
||||
}
|
||||
|
||||
// entryExportStatusLabel 与录入页报名状态徽标同口径(registered 正常参赛 / scratched 已退赛 /
|
||||
// dq 取消资格 / dns 未出场),导出件上的字与现场在界面上看到的一致。
|
||||
func entryExportStatusLabel(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// seedEntries 造一份覆盖筛选维度的报名名单:两个单位、两个小项、两个组别,
|
||||
// 含批次(有无各一)、中文姓名、带前导零的号码布,以及一条团体报名(无运动员)。
|
||||
func seedEntries(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`,
|
||||
`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('张伟', '男', 1, '110101199001011234', 35)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('李强', '男', 2, '110101199202022345', 33)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 1)`,
|
||||
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`,
|
||||
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`,
|
||||
`INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (2, 'E3', '100米自由泳', 'pending', 3)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||
// 批次 2、号码布带前导零。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, heat, status, remark)
|
||||
VALUES (1, 1, 1, 1, '007', 2, 'registered', '卫冕冠军')`,
|
||||
// 未分批,号码布 0012。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
|
||||
VALUES (1, 1, 2, 2, '0012', 'scratched')`,
|
||||
// 团体报名:没有运动员,姓名回落成员名单。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, heat, status)
|
||||
VALUES (2, 3, 1, '["王磊","赵雷"]', 1, 'registered')`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEntriesXLSXColumnsAndRows(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
|
||||
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
|
||||
t.Fatalf("导出报名名单: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
wantSheet := []string{"报名名单"}
|
||||
if got := f.GetSheetList(); !equalStrings(got, wantSheet) {
|
||||
t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheet)
|
||||
}
|
||||
rows, err := f.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
// 4 行版式 + 3 条报名。
|
||||
if len(rows) != 7 {
|
||||
t.Fatalf("行数 = %d,期望 7(4 行版式 + 3 条报名)", len(rows))
|
||||
}
|
||||
|
||||
want := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名", "证件号",
|
||||
"号码布", "批次", "性别", "年龄", "状态", "备注"}
|
||||
for i, header := range want {
|
||||
if got := cell(rows, 3, i); got != header {
|
||||
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:未分批的排最前(heat IS NOT NULL 升序 = false < true)。
|
||||
if got := cell(rows, 4, 5); got != "李强" {
|
||||
t.Fatalf("第一行姓名 = %q,期望 李强(未分批排最前)", got)
|
||||
}
|
||||
if got := cell(rows, 4, 7); got != "0012" {
|
||||
t.Errorf("未分批行号码布 = %q,期望 0012(前导零不能丢)", got)
|
||||
}
|
||||
if got := cell(rows, 4, 8); got != "" {
|
||||
t.Errorf("未分批行批次 = %q,期望留空", got)
|
||||
}
|
||||
if got := cell(rows, 4, 11); got != "已退赛" {
|
||||
t.Errorf("scratched 状态 = %q,期望 已退赛", got)
|
||||
}
|
||||
if got := cell(rows, 4, 2); got != "男子" {
|
||||
t.Errorf("组别 = %q,期望 男子", got)
|
||||
}
|
||||
if got := cell(rows, 4, 10); got != "33" {
|
||||
t.Errorf("年龄 = %q,期望 33", got)
|
||||
}
|
||||
|
||||
if got := cell(rows, 5, 5); got != "张伟" {
|
||||
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
|
||||
}
|
||||
if got := cell(rows, 5, 7); got != "007" {
|
||||
t.Errorf("号码布 = %q,期望 007", got)
|
||||
}
|
||||
if got := cell(rows, 5, 8); got != "2" {
|
||||
t.Errorf("批次 = %q,期望 2", got)
|
||||
}
|
||||
if got := cell(rows, 5, 11); got != "正常参赛" {
|
||||
t.Errorf("registered 状态 = %q,期望 正常参赛", got)
|
||||
}
|
||||
if got := cell(rows, 5, 12); got != "卫冕冠军" {
|
||||
t.Errorf("备注 = %q,期望 卫冕冠军", got)
|
||||
}
|
||||
|
||||
// 团体行:姓名取成员名单,批次 1。
|
||||
if got := cell(rows, 6, 5); got != "王磊、赵雷" {
|
||||
t.Errorf("团体行姓名 = %q,期望 王磊、赵雷", got)
|
||||
}
|
||||
if got := cell(rows, 6, 1); got != "4×100米接力" {
|
||||
t.Errorf("团体行小项名 = %q,期望 4×100米接力", got)
|
||||
}
|
||||
|
||||
// 号码布必须是文本单元格:写成数字会把 007 变成 7。
|
||||
if typ, err := f.GetCellType("报名名单", "H5"); err != nil || typ != excelize.CellTypeSharedString {
|
||||
t.Errorf("号码布单元格类型 = %v (err=%v),期望共享字符串(保住前导零)", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEntriesXLSXFilters(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
filter EntriesFilter
|
||||
want int
|
||||
first string
|
||||
}{
|
||||
{"全部", EntriesFilter{}, 3, "李强"},
|
||||
{"按批次", EntriesFilter{Heat: 2}, 1, "张伟"},
|
||||
{"按单位", EntriesFilter{TeamID: 1}, 2, "张伟"},
|
||||
{"按小项", EntriesFilter{EventID: 1}, 2, "李强"},
|
||||
{"按组别", EntriesFilter{CategoryID: 3}, 1, "王磊、赵雷"},
|
||||
{"组合筛选", EntriesFilter{EventID: 1, TeamID: 1, Heat: 2}, 1, "张伟"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb, err := LoadEntriesWorkbook(db, tc.filter, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != tc.want {
|
||||
t.Fatalf("行数 = %d,期望 %d", len(wb.Rows), tc.want)
|
||||
}
|
||||
if got := wb.Rows[0].Name; got != tc.first {
|
||||
t.Errorf("首行姓名 = %q,期望 %q", got, tc.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 不存在的批次号不是错误,只是没有数据。
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{Heat: 99}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取空结果: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != 0 {
|
||||
t.Errorf("批次 99 应为空,得到 %d 行", len(wb.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEntriesXLSXStream(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEntriesXLSX(&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() }()
|
||||
rows, err := f.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
if len(rows) != 7 {
|
||||
t.Fatalf("行数 = %d,期望 7", len(rows))
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ type EventResultRow struct {
|
||||
Points float64
|
||||
Status string
|
||||
Remark string
|
||||
// Unpublished 表示这行有草稿成绩但还没发布:成绩值不写出来,
|
||||
// 名次与积分对未发布成绩没有意义,一并换成占位符,见 eventRows。
|
||||
Unpublished bool
|
||||
// NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。
|
||||
// 与 Unpublished 互斥——有成绩行才会有发布状态。
|
||||
NoResult bool
|
||||
}
|
||||
|
||||
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
||||
@@ -117,12 +123,36 @@ func eventSheetTitle(meta Meta, sh EventSheet) string {
|
||||
return sheetTitle(meta, base)
|
||||
}
|
||||
|
||||
// eventCellBlank 是「这一格还没有数值」的占位符。留着空格子会让整行读起来像缺数据,
|
||||
// 占位符才读得出「这是一条完整记录,只是还没发布 / 还没录」。
|
||||
const eventCellBlank = "—"
|
||||
|
||||
// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。
|
||||
const eventCellUnpublished = "分数未录入"
|
||||
|
||||
// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。
|
||||
//
|
||||
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值,
|
||||
// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。
|
||||
func eventRows(rows []EventResultRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var (
|
||||
rank any = r.Rank
|
||||
result any = displayResult(r.ResultText, r.Unit)
|
||||
points any = r.Points
|
||||
)
|
||||
switch {
|
||||
case r.NoResult:
|
||||
// 连成绩行都没有:三格都没值,状态列才是「未录入」。
|
||||
rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank
|
||||
case r.Unpublished:
|
||||
// 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。
|
||||
rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank
|
||||
}
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Bib, r.TeamName, r.Participant,
|
||||
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
|
||||
rank, r.Bib, r.TeamName, r.Participant,
|
||||
result, points, r.Status, r.Remark,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -30,7 +30,8 @@ func (m Meta) normalized() Meta {
|
||||
// Options 是导出选项;零值即「正式成绩」口径。
|
||||
type Options struct {
|
||||
Meta Meta
|
||||
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。
|
||||
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
|
||||
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
|
||||
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
|
||||
IncludeDrafts bool
|
||||
}
|
||||
@@ -441,6 +442,8 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
|
||||
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
|
||||
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
||||
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||
// 连成绩行都没有的报名项同样占位(NoResult)。
|
||||
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, ''),
|
||||
@@ -459,9 +462,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
query += ` AND en.category_id = ?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
if !includeDrafts {
|
||||
query += ` AND (r.state IS NULL OR r.state = 'published')`
|
||||
}
|
||||
// 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。
|
||||
// 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行,
|
||||
// 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。
|
||||
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...)
|
||||
@@ -489,6 +492,11 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
row.Rank = rankLabel(rank, isTie != 0)
|
||||
row.Status = statusLabel(resultStat, resultState, entryStat)
|
||||
row.Remark = joinRemark(entryRemark, resultNote)
|
||||
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
|
||||
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
|
||||
row.NoResult = !resultState.Valid
|
||||
row.Unpublished = !includeDrafts && resultState.Valid &&
|
||||
domain.ResultState(resultState.String) == domain.ResultStateDraft
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
@@ -293,3 +294,127 @@ func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedDraftAndBlank 在 seedEvent 之上补两类「还没发布」的报名:
|
||||
// 王五有一条草稿成绩(录了没发布),赵六一条成绩都没有。种子里的两条是已发布成绩。
|
||||
func seedDraftAndBlank(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
|
||||
// rank 留空:草稿还没参与排名,也让行序稳定落在已发布成绩之后。
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 3, NULL, '12.10', 12.10, 's', 'draft')`,
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('赵六', '男', 2)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 4, '104')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿行不能从表上消失,但要按「未发布」呈现:成绩列写「分数未录入」,名次与积分填「—」;
|
||||
// 连成绩行都没有的行三格一律占位;已发布的格子一个都不许变。
|
||||
func TestEventRowsShowUnpublishedDraftWithoutDroppingIt(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
// 先取一份只有已发布成绩的导出,等下用它证明已发布行没被新行挤动。
|
||||
before, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出已发布成绩: %v", err)
|
||||
}
|
||||
if len(before.Sheets) != 1 {
|
||||
t.Fatalf("组别数 = %d,期望 1", len(before.Sheets))
|
||||
}
|
||||
publishedRows := eventRows(before.Sheets[0].Rows)
|
||||
|
||||
seedDraftAndBlank(t, db)
|
||||
after, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出含草稿的成绩: %v", err)
|
||||
}
|
||||
rows := eventRows(after.Sheets[0].Rows)
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("行数 = %d,期望 4(已发布 2 + 草稿 1 + 没成绩 1): %v", len(rows), rows)
|
||||
}
|
||||
|
||||
// 已发布的两行:每个格子与「没有那两条未发布报名时」完全一致。
|
||||
for i, want := range publishedRows {
|
||||
if !equalCells(rows[i], want) {
|
||||
t.Errorf("第 %d 行被改动了:得到 %v,期望 %v", i+1, rows[i], want)
|
||||
}
|
||||
}
|
||||
// 再钉一遍字面值:这两行是现场打印件上的样子,不能因为新增占位逻辑而漂移。
|
||||
wantPublished := [][]any{
|
||||
{"1", "101", "第一分公司", "张三", "11.58 秒", 10.0, "有效", ""},
|
||||
{"2", "102", "第二分公司", "李四", "11.90 秒", 8.0, "有效", ""},
|
||||
}
|
||||
for i, want := range wantPublished {
|
||||
if !equalCells(rows[i], want) {
|
||||
t.Errorf("已发布第 %d 行 = %v,期望 %v", i+1, rows[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿行(王五):占一行,成绩列是「分数未录入」,名次与积分是「—」。
|
||||
if got := rows[2]; !equalCells(got, []any{
|
||||
"—", "103", "第一分公司", "王五", "分数未录入", "—", "草稿", "",
|
||||
}) {
|
||||
t.Errorf("草稿行 = %v,期望名次 / 积分占位、成绩写「分数未录入」", got)
|
||||
}
|
||||
|
||||
// 完全没成绩的行(赵六):三个成绩格子都不留空白也不写 0,状态仍是未录入。
|
||||
if got := rows[3]; !equalCells(got, []any{
|
||||
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||
}) {
|
||||
t.Errorf("没成绩的行 = %v,期望名次 / 成绩 / 积分都是「—」、状态未录入", got)
|
||||
}
|
||||
// 逐列再钉一次:这三格的列序是 名次(0) / 成绩(4) / 积分(5),改列序时这里会先炸。
|
||||
for _, col := range []int{0, 4, 5} {
|
||||
if got := rows[3][col]; got != "—" {
|
||||
t.Errorf("没成绩的行第 %d 列 = %v,期望「—」", col, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// includeDrafts 打开时(草稿也导出)草稿行的真实值照常写出;
|
||||
// 连成绩行都没有的行仍然三格占位——它本来就没有值可写。
|
||||
func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedDraftAndBlank(t, db)
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 1, Options{IncludeDrafts: true})
|
||||
if err != nil {
|
||||
t.Fatalf("带草稿导出: %v", err)
|
||||
}
|
||||
rows := eventRows(wb.Sheets[0].Rows)
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("行数 = %d,期望 4", len(rows))
|
||||
}
|
||||
if got := rows[2]; !equalCells(got, []any{
|
||||
"", "103", "第一分公司", "王五", "12.10 秒", 0.0, "草稿", "",
|
||||
}) {
|
||||
t.Errorf("带草稿导出时草稿行 = %v,期望写出草稿成绩值", got)
|
||||
}
|
||||
if got := rows[3]; !equalCells(got, []any{
|
||||
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||
}) {
|
||||
t.Errorf("带草稿导出时没成绩的行 = %v,期望三格仍是「—」", got)
|
||||
}
|
||||
}
|
||||
|
||||
// equalCells 逐格比较两行;float64 与 string 都按值比。
|
||||
func equalCells(got, want []any) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchData 解一次批量动作的响应体。
|
||||
type batchItemView struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type batchResultView struct {
|
||||
Action string `json:"action"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
Requested int `json:"requested"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Items []batchItemView `json:"items"`
|
||||
Purge *struct {
|
||||
DeletedEntries int `json:"deletedEntries"`
|
||||
DeletedResults int `json:"deletedResults"`
|
||||
DeletedLedger int `json:"deletedLedger"`
|
||||
DeletedBreaks int `json:"deletedBreaks"`
|
||||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||
} `json:"purge"`
|
||||
}
|
||||
|
||||
func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return decodeData[batchResultView](t, env2)
|
||||
}
|
||||
|
||||
// auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。
|
||||
func auditCount(t *testing.T, env *testEnv, action string) int {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 {
|
||||
return n
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。
|
||||
func TestBatchDisableAthletesOneRequest(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
targets := []int64{ids.athleteA, ids.athB}
|
||||
for _, name := range []string{"赵一", "钱二", "孙三"} {
|
||||
targets = append(targets, env.insert(t,
|
||||
`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA))
|
||||
}
|
||||
|
||||
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
|
||||
if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 {
|
||||
t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result)
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if !item.OK {
|
||||
t.Fatalf("每条都该成功,得到 %+v", item)
|
||||
}
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 {
|
||||
t.Fatalf("应有 5 人处于停用,得到 %d", n)
|
||||
}
|
||||
// 停用是软删除:报名与成绩一条不动。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
|
||||
t.Fatalf("停用不该动报名,得到 %d 条", n)
|
||||
}
|
||||
if n := auditCount(t, env, "athlete.batch.disable"); n != 1 {
|
||||
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。
|
||||
func TestBatchDisableReportsMissingIDs(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
result := doBatch(t, env, client, map[string]any{
|
||||
"action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999},
|
||||
})
|
||||
if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 {
|
||||
t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result)
|
||||
}
|
||||
if len(result.Items) != 2 {
|
||||
t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items)
|
||||
}
|
||||
missing := result.Items[1]
|
||||
if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" {
|
||||
t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 {
|
||||
t.Fatalf("存在的那个人应被停用,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分;
|
||||
// 缺失的 id 不影响其余两人被删掉。
|
||||
func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
|
||||
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC)
|
||||
publishResults(t, env, client, ids.event, "batch-purge",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 前置:A 第一 10 分,C 第三 6 分。
|
||||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||||
t.Fatalf("前置条件:C 应是 6 分,得到 %v", got)
|
||||
}
|
||||
|
||||
result := doBatch(t, env, client, map[string]any{
|
||||
"action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999},
|
||||
})
|
||||
if result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("应 2 成功 1 失败,得到 %+v", result)
|
||||
}
|
||||
if result.Purge == nil {
|
||||
t.Fatalf("purge 应带回删除规模,得到 %+v", result)
|
||||
}
|
||||
if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 {
|
||||
t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge)
|
||||
}
|
||||
if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event {
|
||||
t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent)
|
||||
}
|
||||
|
||||
// 被删的两人痕迹全无,第三人原样保留。
|
||||
for _, id := range []int64{ids.athleteA, ids.athB} {
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 {
|
||||
t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n)
|
||||
}
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 {
|
||||
t.Fatalf("第三个人的报名不该被删,得到 %d", n)
|
||||
}
|
||||
// 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。
|
||||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("B 单位删空后总分应归零,得到 %v", got)
|
||||
}
|
||||
// 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||
t.Fatalf("A 单位应保持 10 分,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
|
||||
t.Fatalf("重算应把旧流水作废留下来")
|
||||
}
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
if n := auditCount(t, env, "athlete.batch.purge"); n != 1 {
|
||||
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。
|
||||
func TestBatchPurgeDryRunChangesNothing(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "batch-dry",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
)
|
||||
before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
|
||||
|
||||
result := doBatch(t, env, client, map[string]any{
|
||||
"action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true,
|
||||
})
|
||||
if !result.DryRun || result.Succeeded != 1 {
|
||||
t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result)
|
||||
}
|
||||
if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 {
|
||||
t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge)
|
||||
}
|
||||
if len(result.Purge.RecomputedEvent) != 1 {
|
||||
t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent)
|
||||
}
|
||||
|
||||
after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
|
||||
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
|
||||
if before != after {
|
||||
t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 {
|
||||
t.Fatalf("预览不该写审计,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法入参:action 只认 disable / purge,ids 去重后不能为空。
|
||||
func TestBatchRejectsBadInput(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"},
|
||||
{"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"},
|
||||
{"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Message != tc.want {
|
||||
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未登录的写操作一律 401,且不改动任何数据。
|
||||
func TestBatchRequiresLogin(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
client, _ := env.client()
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
|
||||
map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 {
|
||||
t.Fatalf("被拒后不该改动数据,得到 %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rawData 发一次请求并把 data 原样作为字符串返回。
|
||||
// 逐字节比较需要的是服务端的原始字节,不是 decode 再 encode 的结果。
|
||||
func rawData(t *testing.T, env *testEnv, client *http.Client, path string) string {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodGet, path, nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, resp.Error)
|
||||
}
|
||||
return string(resp.Data)
|
||||
}
|
||||
|
||||
// tableDump 把查询结果按原始列顺序 dump 成文本。
|
||||
// 逐字节比较要的是存储层的事实,而不是经过 join 加工后的展示字段。
|
||||
func tableDump(t *testing.T, env *testEnv, query string) string {
|
||||
t.Helper()
|
||||
rows, err := env.ev.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("dump %s: %v", query, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatalf("读列名: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
t.Fatalf("扫描 %s: %v", query, err)
|
||||
}
|
||||
for i, v := range vals {
|
||||
if raw, ok := v.([]byte); ok {
|
||||
v = string(raw)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=%v ", cols[i], v)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历 %s: %v", query, err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// categorySnapshot 抓下「报名 / 成绩 / 流水 / 成绩接口 / 积分榜」五份原始数据。
|
||||
// 改个组别名不该让它们有任何变化,连一个字节都不行。
|
||||
//
|
||||
// 不含 GET /events/{id}/entries:它 join 了组别名,categoryName 跟着变是应该的,
|
||||
// 这条由下面的断言单独检查。
|
||||
func categorySnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64) string {
|
||||
t.Helper()
|
||||
return strings.Join([]string{
|
||||
tableDump(t, env, fmt.Sprintf(`SELECT * FROM entries WHERE event_id = %d ORDER BY id`, eventID)),
|
||||
tableDump(t, env, fmt.Sprintf(`SELECT * FROM results WHERE event_id = %d ORDER BY id`, eventID)),
|
||||
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`),
|
||||
rawData(t, env, client, fmt.Sprintf("/api/results?eventId=%d&size=500", eventID)),
|
||||
rawData(t, env, client, "/api/standings"),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// 有报名数据的小项必须能改组别名,且报名 / 成绩 / 榜单逐字节不变——这是本次修复的核心诉求。
|
||||
func TestUpdateCategoryRenamesWithEntriesKeepingData(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "cat-rename",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
before := categorySnapshot(t, env, client, ids.event)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||
map[string]any{
|
||||
"id": ids.category, "eventId": ids.event, "name": "男子甲组",
|
||||
"multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35, "sortOrder": 0,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 响应回的是该小项最新的组别列表,前端据此刷新。
|
||||
cats := decodeData[[]map[string]any](t, env2)
|
||||
found := false
|
||||
for _, c := range cats {
|
||||
if c["id"] == float64(ids.category) {
|
||||
found = true
|
||||
if c["name"] != "男子甲组" {
|
||||
t.Fatalf("响应里的组别名应为 男子甲组,得到 %+v", c["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("响应里应有组别 %d,得到 %+v", ids.category, cats)
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
multiplier float64
|
||||
gender string
|
||||
ageMin, ageMax int
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT name, multiplier, gender, age_min, age_max FROM event_categories WHERE id = ?`,
|
||||
ids.category).Scan(&name, &multiplier, &gender, &ageMin, &ageMax); err != nil {
|
||||
t.Fatalf("读组别: %v", err)
|
||||
}
|
||||
if name != "男子甲组" || multiplier != 1 || gender != "男" || ageMin != 18 || ageMax != 35 {
|
||||
t.Fatalf("组别字段应就地更新,得到 name=%s mult=%v gender=%s age=%d-%d", name, multiplier, gender, ageMin, ageMax)
|
||||
}
|
||||
|
||||
// 组别 id 没变(整表替换会换 id,报名就跟着断链)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE category_id = ?`, ids.category); n != 2 {
|
||||
t.Fatalf("两条报名应仍挂在原组别上,得到 %d 条", n)
|
||||
}
|
||||
|
||||
after := categorySnapshot(t, env, client, ids.event)
|
||||
if before != after {
|
||||
t.Fatalf("改名不该动报名 / 成绩 / 流水 / 榜单:\n--- before ---\n%s\n--- after ---\n%s", before, after)
|
||||
}
|
||||
// 改名不参与算分:一条流水都不该被作废或重建。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
|
||||
t.Fatalf("改名不该触发重算,得到 %d 条作废流水", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 2 {
|
||||
t.Fatalf("流水条数不该变,得到 %d 条", n)
|
||||
}
|
||||
if audit := waitAudit(t, env, "event.category.update", ids.category); !strings.Contains(audit, `"recomputedLedger":false`) {
|
||||
t.Fatalf("审计应标出没有重算,得到 %s", audit)
|
||||
}
|
||||
|
||||
// 展示层该跟着变:报名接口里的 categoryName 是新名字(这就是用户想看到的结果)。
|
||||
entries := rawData(t, env, client, fmt.Sprintf("/api/events/%d/entries", ids.event))
|
||||
if !strings.Contains(entries, `"categoryName":"男子甲组"`) || strings.Contains(entries, `"categoryName":"男子"`) {
|
||||
t.Fatalf("报名列表应显示新组别名,得到 %s", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 改权重同样是就地更新:不重算已有流水,榜单在改名前后必须逐字节一致。
|
||||
// 改权重要立刻重算该组已发布成绩:权重进算分公式,不重算就会让积分与榜单静默不一致。
|
||||
func TestUpdateCategoryWeightChangeRecomputesLedger(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "cat-mult",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 前置:A 第一 10 分、B 第二 8 分,单位总分 10 / 8。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||
t.Fatalf("前置条件:A 单位应是 10 分,得到 %v", got)
|
||||
}
|
||||
before := rawData(t, env, client, "/api/standings")
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||
map[string]any{"name": "男子", "multiplier": 2, "sortOrder": 0}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改权重应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 旧流水被作废保留痕迹,新流水按 ×2 重建:10 → 20、8 → 16。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{20}) {
|
||||
t.Fatalf("A 应重算成 20 分,得到 %v", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{16}) {
|
||||
t.Fatalf("B 应重算成 16 分,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 2 {
|
||||
t.Fatalf("两条旧流水都应被作废,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 4 {
|
||||
t.Fatalf("应是「2 条作废 + 2 条有效」,得到 %d 条", n)
|
||||
}
|
||||
// 名次不变(权重不改排序),但分数与榜单必须跟着变。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE rank = 1`); n != 1 {
|
||||
t.Fatalf("名次不该被改乱,得到 %d 个第一", n)
|
||||
}
|
||||
if after := rawData(t, env, client, "/api/standings"); before == after {
|
||||
t.Fatalf("榜单必须随新权重重算,但仍与改前一致:%s", after)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 20 {
|
||||
t.Fatalf("改权重后 A 单位应是 20 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 16 {
|
||||
t.Fatalf("改权重后 B 单位应是 16 分,得到 %v", got)
|
||||
}
|
||||
|
||||
// 审计要能看出这次动了权重并触发重算。
|
||||
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||
if !strings.Contains(after, `"recomputedLedger":true`) {
|
||||
t.Fatalf("审计应标出触发了重算,得到 %s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// 权重原样提交(浮点比较的容差)不该白重算一次全组积分。
|
||||
func TestUpdateCategorySameMultiplierDoesNotRecompute(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "cat-same",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
)
|
||||
var logID int64
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM score_ledger`).Scan(&logID); err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||
// 1 与 1+1e-12 在容差内视为同一个权重。
|
||||
map[string]any{"name": "男子甲组", "multiplier": 1.000000000001, "sortOrder": 0}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE id > ?`, logID); n != 0 {
|
||||
t.Fatalf("权重没变不该产生新流水,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
|
||||
t.Fatalf("权重没变不该作废流水,得到 %d 条", n)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("分值不该变,得到 %v", got)
|
||||
}
|
||||
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||
if !strings.Contains(after, `"recomputedLedger":false`) {
|
||||
t.Fatalf("审计应标出没有重算,得到 %s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// 同小项内重名必须 409,且文案点名是哪一个名字。
|
||||
func TestUpdateCategoryRejectsDuplicateName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, cat2),
|
||||
map[string]any{"name": "男子", "multiplier": 1, "sortOrder": 1}, true)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("重名应 409,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "组别名称重复:男子") {
|
||||
t.Fatalf("重名文案应含「组别名称重复:男子」,得到 %+v", env2.Error)
|
||||
}
|
||||
|
||||
// 原行没被改坏。
|
||||
var name string
|
||||
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, cat2).Scan(&name); err != nil {
|
||||
t.Fatalf("读组别: %v", err)
|
||||
}
|
||||
if name != "女子" {
|
||||
t.Fatalf("冲突时不该改动原组别,得到 %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 名称必填、性别枚举、年龄范围都按新接口的口径给 400,而不是让数据库约束冒成 500。
|
||||
func TestUpdateCategoryValidatesFields(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
path := fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{"名称必填", map[string]any{"name": " ", "multiplier": 1}, "组别 name 必填"},
|
||||
{"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "其他"}, "组别 gender 只能是 男 / 女"},
|
||||
{"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 130}, "组别年龄需要在 0-120 之间"},
|
||||
{"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 20}, "组别年龄下限不能大于上限"},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPut, path, tc.body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Message != tc.want {
|
||||
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组别不存在、或 categoryId 属于别的小项,都按 404「组别不存在」处理,不给跨小项改名。
|
||||
func TestUpdateCategoryMissingReturns404(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
otherEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 0)`, otherEvent)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"组别不存在", fmt.Sprintf("/api/events/%d/categories/999999", ids.event)},
|
||||
{"组别属于别的小项", fmt.Sprintf("/api/events/%d/categories/%d", ids.event, otherCat)},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPut, tc.path, map[string]any{"name": "男子", "multiplier": 1}, true)
|
||||
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "组别不存在" {
|
||||
t.Fatalf("%s 应 404 组别不存在,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
}
|
||||
// 别的小项的组别没被改动。
|
||||
var name string
|
||||
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, otherCat).Scan(&name); err != nil {
|
||||
t.Fatalf("读组别: %v", err)
|
||||
}
|
||||
if name != "女子" {
|
||||
t.Fatalf("跨小项改名必须被拒,得到 %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。
|
||||
func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories", ids.event),
|
||||
map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}, true)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("有报名数据时整表替换应 409,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || !strings.Contains(env2.Error.Message, "不能新增或删除组别") {
|
||||
t.Fatalf("文案应说清是「不能增删组别」,得到 %+v", env2.Error)
|
||||
}
|
||||
// 拒绝时不许留下半截改动。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, ids.event); n != 1 {
|
||||
t.Fatalf("拒绝后组别数不该变,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 就地更新也要推大屏、写审计。
|
||||
func TestUpdateCategoryWritesChangeLogAndAudit(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
before := changeLogCount(t, env, "events", "upsert")
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
|
||||
map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := changeLogCount(t, env, "events", "upsert"); got != before+1 {
|
||||
t.Fatalf("改名应写 1 条 events change_log,得到 %d(之前 %d)", got, before)
|
||||
}
|
||||
after := waitAudit(t, env, "event.category.update", ids.category)
|
||||
if !strings.Contains(after, `"男子甲组"`) {
|
||||
t.Fatalf("审计的 after 应含新名字,得到 %s", after)
|
||||
}
|
||||
var beforeJSON sql.NullString
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT before_json FROM audit_logs WHERE action = 'event.category.update' AND entity_id = ?`,
|
||||
ids.category).Scan(&beforeJSON); err != nil {
|
||||
t.Fatalf("读审计 before: %v", err)
|
||||
}
|
||||
if !beforeJSON.Valid || !strings.Contains(beforeJSON.String, `"男子"`) {
|
||||
t.Fatalf("审计的 before 应含旧名字,得到 %+v", beforeJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setLiveOnPublish 直接改赛事库设置,等价于管理端 PUT /api/settings。
|
||||
// 走库而不是接口是为了让用例只关心结算行为,设置写入由 TestLiveOnPublishSwitchPersists 单独覆盖。
|
||||
func setLiveOnPublish(t *testing.T, env *testEnv, value bool) {
|
||||
t.Helper()
|
||||
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.live_on_publish', ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, map[bool]string{true: "true", false: "false"}[value])
|
||||
}
|
||||
|
||||
type settlementStatus struct {
|
||||
LiveOnPublish bool `json:"liveOnPublish"`
|
||||
Groups []struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Required int `json:"required"`
|
||||
Published int `json:"published"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
func readSettlement(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) settlementStatus {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/results/settlement?eventId=%d&categoryId=%d", eventID, categoryID), nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读结算状态应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
return decodeData[settlementStatus](t, resp)
|
||||
}
|
||||
|
||||
func activeLedgerRows(t *testing.T, env *testEnv, eventID int64) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID).Scan(&n); err != nil {
|
||||
t.Fatalf("统计流水: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func standingsTotal(t *testing.T, env *testEnv, client *http.Client) float64 {
|
||||
t.Helper()
|
||||
_, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
rows := decodeData[[]map[string]any](t, resp)
|
||||
var total float64
|
||||
for _, row := range rows {
|
||||
total += row["totalPoints"].(float64)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// rankOrZero 读名次:未发布的成绩没有名次(NULL),这里折算成 0,方便分批快照比较。
|
||||
func rankOrZero(t *testing.T, env *testEnv, entryID int64) int {
|
||||
t.Helper()
|
||||
var rank sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
if !rank.Valid {
|
||||
return 0
|
||||
}
|
||||
return int(rank.Int64)
|
||||
}
|
||||
|
||||
func changeLogCountOf(t *testing.T, env *testEnv, entityType string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ?`, entityType).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 change_log: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 默认(设置键不存在)必须是历史行为:分批发一批,流水与榜当场就变。
|
||||
func TestLiveOnPublishDefaultIsRealTime(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||
}, true)
|
||||
status, resp := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
|
||||
// 4 条已发布 = 4 条流水;前四名 10+8+6+3 = 27 分上榜。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
|
||||
if got := standingsTotal(t, env, client); got != 27 {
|
||||
t.Fatalf("默认设置下发布一批后榜单总分 = %v,期望 27", got)
|
||||
}
|
||||
if got := changeLogCountOf(t, env, "standings"); got != 1 {
|
||||
t.Fatalf("默认设置下发布应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 1 {
|
||||
t.Fatalf("第一名应第 1 名,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 开关默认(键不存在)与显式 true 必须逐字节一致:
|
||||
// 这是「加开关不能悄悄改变现场既有行为」的证明。
|
||||
func TestLiveOnPublishDefaultMatchesExplicitTrue(t *testing.T) {
|
||||
// snapshot 是一次发布之后的可比状态:名次、流水、榜单原始字节、榜单推送次数。
|
||||
type snapshot struct {
|
||||
ranks []int
|
||||
ledger []string
|
||||
standings string
|
||||
pushes int
|
||||
}
|
||||
run := func(t *testing.T, value *bool) (afterFirst, afterSecond snapshot) {
|
||||
t.Helper()
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
if value != nil {
|
||||
setLiveOnPublish(t, env, *value)
|
||||
}
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
read := func() snapshot {
|
||||
t.Helper()
|
||||
var snap snapshot
|
||||
for _, id := range entries {
|
||||
snap.ranks = append(snap.ranks, rankOrZero(t, env, id))
|
||||
}
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT entry_id, team_id, points FROM score_ledger WHERE event_id = ? AND voided = 0 ORDER BY entry_id, team_id`, ids.event)
|
||||
if err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var entryID, teamID int64
|
||||
var points float64
|
||||
if err := rows.Scan(&entryID, &teamID, &points); err != nil {
|
||||
t.Fatalf("扫描流水: %v", err)
|
||||
}
|
||||
snap.ledger = append(snap.ledger, fmt.Sprintf("%d/%d/%v", entryID, teamID, points))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历流水: %v", err)
|
||||
}
|
||||
snap.standings = rawData(t, env, client, "/api/standings")
|
||||
snap.pushes = changeLogCountOf(t, env, "standings")
|
||||
return snap
|
||||
}
|
||||
|
||||
slow := make([]float64, 8)
|
||||
fast := make([]float64, 8)
|
||||
for i := range slow {
|
||||
slow[i] = 12.0 + float64(i)*0.1
|
||||
fast[i] = 10.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
|
||||
}, true)
|
||||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
afterFirst = read()
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
|
||||
}, true)
|
||||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
afterSecond = read()
|
||||
return afterFirst, afterSecond
|
||||
}
|
||||
|
||||
assertSame := func(t *testing.T, label string, absent, explicit snapshot) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(absent.ranks, explicit.ranks) {
|
||||
t.Fatalf("%s:名次不一致:默认 %v vs 显式 true %v", label, absent.ranks, explicit.ranks)
|
||||
}
|
||||
if !reflect.DeepEqual(absent.ledger, explicit.ledger) {
|
||||
t.Fatalf("%s:流水不一致:默认 %v vs 显式 true %v", label, absent.ledger, explicit.ledger)
|
||||
}
|
||||
if absent.standings != explicit.standings {
|
||||
t.Fatalf("%s:榜单字节不一致:\n默认 %s\n显式 %s", label, absent.standings, explicit.standings)
|
||||
}
|
||||
if absent.pushes != explicit.pushes {
|
||||
t.Fatalf("%s:榜单推送次数不一致:默认 %d vs 显式 true %d", label, absent.pushes, explicit.pushes)
|
||||
}
|
||||
}
|
||||
|
||||
absentFirst, absentSecond := run(t, nil)
|
||||
explicit := true
|
||||
trueFirst, trueSecond := run(t, &explicit)
|
||||
assertSame(t, "第一批", absentFirst, trueFirst)
|
||||
assertSame(t, "第二批", absentSecond, trueSecond)
|
||||
|
||||
// 反向证明这份快照确实受开关控制,否则上面的「一致」可能只是两条路径都坏了。
|
||||
// 关掉开关后第一批只发 4/8:名次照写(延迟结算只省流水与榜单),流水必须为空、榜单必须不动。
|
||||
off := false
|
||||
offFirst, _ := run(t, &off)
|
||||
if len(offFirst.ledger) != 0 {
|
||||
t.Fatalf("关闭开关后第一批不该写流水,得到 %v", offFirst.ledger)
|
||||
}
|
||||
if offFirst.pushes != 0 {
|
||||
t.Fatalf("关闭开关后第一批不该推榜单,得到 %d 次", offFirst.pushes)
|
||||
}
|
||||
if offFirst.standings == absentFirst.standings {
|
||||
t.Fatalf("关闭开关后榜单应与默认不同,得到同一份:%s", offFirst.standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟结算:第一批只落成绩,名次 / 流水 / 榜单都不动;
|
||||
// 第二批录完自动结算,整组重算并把名次与榜单补上。
|
||||
func TestDeferredSettlementWaitsForWholeGroup(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, false)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
slow := make([]float64, 8)
|
||||
fast := make([]float64, 8)
|
||||
for i := range slow {
|
||||
slow[i] = 12.0 + float64(i)*0.1
|
||||
fast[i] = 10.0 + float64(i)*0.1
|
||||
}
|
||||
// 发布之前的榜单原始字节:延迟结算下这批发布不允许改动它(含奖牌列)。
|
||||
beforeStandings := rawData(t, env, client, "/api/standings")
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
|
||||
}, true)
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("第一批发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 成绩要上屏(可以看到时间),但名次 / 流水 / 榜单都要等结算。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 4, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`, 0)
|
||||
// 名次不写:奖牌榜统计读的就是 results.rank,分批写名次会让奖牌列跟总分对不上。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event)
|
||||
if got := rawData(t, env, client, "/api/standings"); got != beforeStandings {
|
||||
t.Fatalf("延迟结算下榜单不该动:\n发布前 %s\n发布后 %s", beforeStandings, got)
|
||||
}
|
||||
|
||||
// 未结算清单要能查到这一组。
|
||||
first := readSettlement(t, env, client, ids.event, ids.category)
|
||||
if first.LiveOnPublish {
|
||||
t.Fatalf("设置已关闭,接口应回报 liveOnPublish=false: %+v", first)
|
||||
}
|
||||
if len(first.Groups) != 1 || first.Groups[0].Required != 8 || first.Groups[0].Published != 4 {
|
||||
t.Fatalf("未结算清单应含 4/8 的该组,得到 %+v", first.Groups)
|
||||
}
|
||||
|
||||
// 第二批录完 → 自动结算:整组重算,流水齐全,榜单正确。
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
|
||||
}, true)
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
|
||||
// 前六名得分 10+8+6+3+2+1 = 30,共 6 条流水。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||
if got := standingsTotal(t, env, client); got != 30 {
|
||||
t.Fatalf("结算后榜单总分 = %v,期望 30", got)
|
||||
}
|
||||
if got := changeLogCountOf(t, env, "standings"); got != 1 {
|
||||
t.Fatalf("结算应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
// 整组重算的证明:第二批更快,第一批第一名退到第 5 名。
|
||||
if got := rankOfEntry(t, env, entries[4]); got != 1 {
|
||||
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 5 {
|
||||
t.Fatalf("第一批第一名应退到第 5 名,得到 %d", got)
|
||||
}
|
||||
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
|
||||
t.Fatalf("结算后未结算清单应为空,得到 %+v", got.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// 有人弃权(scratched)时不该再等他:7 人录完 + 1 人退赛也算全组录完,自动结算。
|
||||
func TestDeferredSettlementTreatsScratchedAsFinished(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, false)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 第 8 人退赛(与前端「批量退赛」同一条接口)。
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{entries[7]},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
|
||||
}, true)
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||
if got := standingsTotal(t, env, client); got != 30 {
|
||||
t.Fatalf("退赛者不该阻塞结算,榜单总分 = %v,期望 30", got)
|
||||
}
|
||||
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
|
||||
t.Fatalf("全组(含退赛)录完后清单应为空,得到 %+v", got.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// 发布之后才标退赛:整组因此录完,退赛动作本身也要把这组结算掉,不能停在空流水上。
|
||||
func TestScratchAfterDeferredPublishSettlesGroup(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, false)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
|
||||
}, true)
|
||||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{entries[7]},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||
if got := standingsTotal(t, env, client); got != 30 {
|
||||
t.Fatalf("退赛补全整组后应结算,榜单总分 = %v,期望 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即结算是延迟结算的人工兜底:按当前已发布成绩结算,并写审计。
|
||||
func TestManualSettleUsesPublishedResults(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, false)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||
}, true)
|
||||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-1",
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 只有 4 条已发布 → 前四名 10+8+6+3 = 27 分。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
|
||||
if got := standingsTotal(t, env, client); got != 27 {
|
||||
t.Fatalf("立即结算后榜单总分 = %v,期望 27", got)
|
||||
}
|
||||
waitAudit(t, env, "result.settle", ids.event)
|
||||
|
||||
// 未登录不能结算。
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录结算应 401,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 开关键写入 / 读出:PUT /api/settings 落库后 GET 要能读回,且缺键默认 true。
|
||||
func TestLiveOnPublishSwitchPersists(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
read := func() bool {
|
||||
t.Helper()
|
||||
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
settings := decodeData[map[string]any](t, resp)
|
||||
return settings["standings"].(map[string]any)["liveOnPublish"].(bool)
|
||||
}
|
||||
if !read() {
|
||||
t.Fatalf("缺键时 liveOnPublish 应默认 true")
|
||||
}
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"standings": map[string]any{"liveOnPublish": false},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("关闭开关应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if read() {
|
||||
t.Fatalf("关闭后 liveOnPublish 应读回 false")
|
||||
}
|
||||
// 只改一个字段不能把榜单口径的其余取值抹掉。
|
||||
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
standings := decodeData[map[string]any](t, resp)["standings"].(map[string]any)
|
||||
if standings["individualToTeamWeight"].(float64) != 1 || standings["rankingMode"] != "points" {
|
||||
t.Fatalf("其余榜单口径不应被改动: %+v", standings)
|
||||
}
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"standings": map[string]any{"liveOnPublish": true},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("重新打开应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if !read() {
|
||||
t.Fatalf("重新打开后 liveOnPublish 应读回 true")
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,9 @@ type currentUserDTO struct {
|
||||
Role string `json:"role"`
|
||||
LastLoginAt *string `json:"lastLoginAt"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
// SessionExpiresAt 是当前会话的到期时间(RFC3339 UTC),供界面显示
|
||||
// 「当前会话将于 … 过期」;未登录时为 null。
|
||||
SessionExpiresAt *string `json:"sessionExpiresAt"`
|
||||
}
|
||||
|
||||
type backupResultDTO struct {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// maxEntryBatch 是单次批量操作的上限:前端一次勾选几十条是常态,
|
||||
// 500 足够覆盖「一个组别全选」,再大说明是脚本误用,要求分批提交。
|
||||
const maxEntryBatch = 500
|
||||
|
||||
// entryBatchItemDTO 是逐条回报:ids 里每一个 id 都必有一条,
|
||||
// 不存在 / 被跳过都要写清原因,不能整批失败,也不能静默略过。
|
||||
type entryBatchItemDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// entryBatchSummaryDTO 是整批汇总:ok 含跳过的行,changed 才是真正改动的行数;
|
||||
// failed 只统计 id 不存在这类逐条失败(整批 SQL 出错会直接 5xx / 409,不进这里)。
|
||||
type entryBatchSummaryDTO struct {
|
||||
OK int `json:"ok"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Changed int `json:"changed"`
|
||||
RemovedEntries int `json:"removedEntries"`
|
||||
RemovedResults int `json:"removedResults"`
|
||||
RemovedLedger int `json:"removedLedger"`
|
||||
RemovedBreaks int `json:"removedBreaks"`
|
||||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||
}
|
||||
|
||||
type entryBatchResultDTO struct {
|
||||
Items []entryBatchItemDTO `json:"items"`
|
||||
Summary entryBatchSummaryDTO `json:"summary"`
|
||||
}
|
||||
|
||||
// entryBatchTarget 是勾选行在库里的落点:换状态要看当前 status,重算要按 (小项, 组别) 分组。
|
||||
type entryBatchTarget struct {
|
||||
id int64
|
||||
eventID int64
|
||||
categoryID int64
|
||||
status string
|
||||
}
|
||||
|
||||
// batchEntriesHandler 是 POST /api/entries/batch:
|
||||
// scratch(批量退赛,可恢复)/ restore(批量恢复)/ remove(批量移出报名,物理删除)。
|
||||
func (s *server) batchEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in entryBatchPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := strings.TrimSpace(in.Action)
|
||||
switch action {
|
||||
case "scratch", "restore", "remove":
|
||||
default:
|
||||
return nil, badRequest("action 只能是 scratch / restore / remove", in.Action)
|
||||
}
|
||||
// 重复 id 去重(前端全选后二次勾选很容易重复),非法 id 直接丢掉。
|
||||
ids := dedupeIDs(in.IDs)
|
||||
if len(ids) == 0 {
|
||||
return nil, badRequest("ids 不能为空", nil)
|
||||
}
|
||||
if len(ids) > maxEntryBatch {
|
||||
return nil, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
|
||||
}
|
||||
|
||||
result, err := s.batchEntries(r.Context(), action, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entry."+action, "entry", 0, nil, map[string]any{
|
||||
"ids": ids,
|
||||
"reason": strings.TrimSpace(in.Reason),
|
||||
"summary": result.Summary,
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// entrySkipNote 解释某一行为什么没被改动:跳过必须带出现状,
|
||||
// 否则操作员只看到「没退赛」会以为是系统漏了。
|
||||
func entrySkipNote(action, status string) string {
|
||||
switch status {
|
||||
case "scratched":
|
||||
return "已是退赛状态,跳过"
|
||||
case "registered":
|
||||
return "当前正常参赛,无需恢复"
|
||||
case "dq":
|
||||
return "已被判罚取消资格(dq),跳过,不覆盖原判定"
|
||||
case "dns":
|
||||
return "已标记未出场(dns),跳过,不覆盖原判定"
|
||||
}
|
||||
return "当前状态为「" + status + "」,跳过"
|
||||
}
|
||||
|
||||
// recomputeReason 是流水作废原因,写进 score_ledger.void_reason,事后能对上「榜为什么变了」。
|
||||
func recomputeReason(action string) string {
|
||||
switch action {
|
||||
case "scratch":
|
||||
return "批量退赛后重算"
|
||||
case "restore":
|
||||
return "批量恢复后重算"
|
||||
}
|
||||
return "批量移出报名后重算"
|
||||
}
|
||||
|
||||
// loadEntryBatchTargets 读回请求里存在的报名项;不存在的由调用方逐条回报。
|
||||
func loadEntryBatchTargets(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64]entryBatchTarget, error) {
|
||||
args := idsToArgs(ids)
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, event_id, category_id, status FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64]entryBatchTarget, len(ids))
|
||||
for rows.Next() {
|
||||
var t entryBatchTarget
|
||||
if err := rows.Scan(&t.id, &t.eventID, &t.categoryID, &t.status); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[t.id] = t
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// entryRemoveStats 是移出报名的物理删除规模,随响应汇总返回。
|
||||
type entryRemoveStats struct {
|
||||
entries int
|
||||
results int
|
||||
ledger int
|
||||
breaks int
|
||||
}
|
||||
|
||||
// removeEntriesTx 物理删除报名项及其成绩痕迹。
|
||||
//
|
||||
// 顺序按外键方向来(与 repo.go 的 purgeAthlete 同一套解法):
|
||||
// score_ledger 对 entries / results 都是 ON DELETE SET NULL,直接删 entries 只会把
|
||||
// entry_id 置空——人没了分还留在榜上,所以必须按 entry_id 显式清掉(result_id 一并兜住);
|
||||
// record_breaks 是 CASCADE、results 对 entries 没有 ON DELETE,都要显式先删。
|
||||
func (s *server) removeEntriesTx(ctx context.Context, tx *sql.Tx, ids []int64) (entryRemoveStats, error) {
|
||||
stats := entryRemoveStats{}
|
||||
if len(ids) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
ph := placeholders(len(ids))
|
||||
idArgs := idsToArgs(ids)
|
||||
steps := []struct {
|
||||
query string
|
||||
args []any
|
||||
count *int
|
||||
}{
|
||||
{`DELETE FROM score_ledger
|
||||
WHERE entry_id IN (` + ph + `)
|
||||
OR result_id IN (SELECT id FROM results WHERE entry_id IN (` + ph + `))`,
|
||||
append(append([]any{}, idArgs...), idArgs...), &stats.ledger},
|
||||
{`DELETE FROM record_breaks WHERE entry_id IN (` + ph + `)`, idArgs, &stats.breaks},
|
||||
{`DELETE FROM results WHERE entry_id IN (` + ph + `)`, idArgs, &stats.results},
|
||||
{`DELETE FROM entries WHERE id IN (` + ph + `)`, idArgs, &stats.entries},
|
||||
}
|
||||
for _, step := range steps {
|
||||
res, err := tx.ExecContext(ctx, step.query, step.args...)
|
||||
if err != nil {
|
||||
return entryRemoveStats{}, s.entryBatchBlocked(ctx, tx, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return entryRemoveStats{}, internal(err)
|
||||
}
|
||||
*step.count = int(n)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// entryBatchBlocked 兜底:撞上外键约束时回滚并给可读的 409,不让它冒成 500。
|
||||
// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。
|
||||
func (s *server) entryBatchBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
|
||||
if !isForeignKeyViolation(cause) {
|
||||
return internal(cause)
|
||||
}
|
||||
s.logger.WarnContext(ctx, "批量移出报名撞上外键约束", "err", cause)
|
||||
_ = tx.Rollback()
|
||||
return conflict("报名项仍被其它数据引用,本批已回滚,报名与成绩都没有改动。请刷新后重试。")
|
||||
}
|
||||
|
||||
// batchEntries 在一个事务里完成整批状态变更或删除,并按 (小项, 组别) 分组重算积分。
|
||||
//
|
||||
// 重算口径照 repo.go 的 purgeAthlete:删完成绩(或改完报名状态)后必须重算流水、
|
||||
// 把新名次写回 results,再推小项与榜单,否则大屏还挂着旧分数、别人的名次也不会变。
|
||||
func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (entryBatchResultDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
targets, err := loadEntryBatchTargets(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
|
||||
out := entryBatchResultDTO{Items: make([]entryBatchItemDTO, 0, len(ids))}
|
||||
affected := make([]entryBatchTarget, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
target, ok := targets[id]
|
||||
if !ok {
|
||||
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: false, Error: "报名项不存在"})
|
||||
out.Summary.Failed++
|
||||
continue
|
||||
}
|
||||
// 退赛只认 registered、恢复只认 scratched;dq/dns 是别人的判定,一律不覆盖。
|
||||
want := ""
|
||||
switch action {
|
||||
case "scratch":
|
||||
want = "registered"
|
||||
case "restore":
|
||||
want = "scratched"
|
||||
}
|
||||
if want != "" && target.status != want {
|
||||
out.Items = append(out.Items, entryBatchItemDTO{
|
||||
ID: id, OK: true, Skipped: true, Note: entrySkipNote(action, target.status),
|
||||
})
|
||||
out.Summary.OK++
|
||||
out.Summary.Skipped++
|
||||
continue
|
||||
}
|
||||
affected = append(affected, target)
|
||||
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: true})
|
||||
out.Summary.OK++
|
||||
out.Summary.Changed++
|
||||
}
|
||||
|
||||
changedIDs := targetIDs(affected)
|
||||
switch action {
|
||||
case "scratch", "restore":
|
||||
if len(changedIDs) > 0 {
|
||||
status := "scratched"
|
||||
if action == "restore" {
|
||||
status = "registered"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE entries SET status = ? WHERE id IN (`+placeholders(len(changedIDs))+`)`,
|
||||
append([]any{status}, idsToArgs(changedIDs)...)...); err != nil {
|
||||
return entryBatchResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
case "remove":
|
||||
stats, err := s.removeEntriesTx(ctx, tx, changedIDs)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
out.Summary.RemovedEntries = stats.entries
|
||||
out.Summary.RemovedResults = stats.results
|
||||
out.Summary.RemovedLedger = stats.ledger
|
||||
out.Summary.RemovedBreaks = stats.breaks
|
||||
}
|
||||
|
||||
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
|
||||
actorID := middleware.User(ctx).ID
|
||||
live, err := liveOnPublishQ(ctx, tx)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
eventIDs := make([]int64, 0, 2)
|
||||
settled := false
|
||||
for _, scope := range distinctScopes(affected) {
|
||||
// 退赛 / 恢复会改变「这一组该不该等它」:标成退赛后整组可能刚好录完,
|
||||
// 延迟结算下必须补一次结算,否则榜单会永远停在没有流水的那一版。
|
||||
settle := live
|
||||
if !live {
|
||||
var err error
|
||||
settle, err = scopeSettled(ctx, tx, scope.eventID, scope.categoryID)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 退赛的成绩不再占名次:results.rank 必须一并清掉。
|
||||
// buildRanked(奖牌统计 / 运动员榜)只认 results.rank + state='published',
|
||||
// 不清就会留下「0 分却还挂着金牌」;恢复时由 applyComputedRanks 按新名次写回。
|
||||
if action == "scratch" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET rank = NULL
|
||||
WHERE state = 'published'
|
||||
AND entry_id IN (SELECT id FROM entries
|
||||
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
|
||||
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
|
||||
return entryBatchResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
score, err := computeEventScore(ctx, tx, scope.eventID, scope.categoryID)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, scope.eventID, scope.categoryID, actorID, recomputeReason(action)); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
settled = true
|
||||
}
|
||||
if !slices.Contains(eventIDs, scope.eventID) {
|
||||
eventIDs = append(eventIDs, scope.eventID)
|
||||
}
|
||||
}
|
||||
// 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。
|
||||
for _, eventID := range eventIDs {
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
}
|
||||
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
|
||||
if len(eventIDs) > 0 && settled {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
}
|
||||
out.Summary.RecomputedEvent = eventIDs
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return entryBatchResultDTO{}, s.entryBatchBlocked(ctx, tx, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func targetIDs(targets []entryBatchTarget) []int64 {
|
||||
out := make([]int64, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
out = append(out, t.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entryScope 是重算的最小单位:积分与名次都按「小项 + 组别」算。
|
||||
type entryScope struct{ eventID, categoryID int64 }
|
||||
|
||||
// distinctScopes 按出现顺序去重,保证同一批请求的重算次序稳定、可复现。
|
||||
func distinctScopes(targets []entryBatchTarget) []entryScope {
|
||||
out := make([]entryScope, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
scope := entryScope{t.eventID, t.categoryID}
|
||||
if !slices.Contains(out, scope) {
|
||||
out = append(out, scope)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func idsToArgs(ids []int64) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
|
||||
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
|
||||
type batchItem struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type batchSummary struct {
|
||||
OK int `json:"ok"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Changed int `json:"changed"`
|
||||
RemovedEntries int `json:"removedEntries"`
|
||||
RemovedResults int `json:"removedResults"`
|
||||
RemovedLedger int `json:"removedLedger"`
|
||||
RemovedBreaks int `json:"removedBreaks"`
|
||||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||||
}
|
||||
|
||||
type batchResult struct {
|
||||
Items []batchItem `json:"items"`
|
||||
Summary batchSummary `json:"summary"`
|
||||
Raw map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// entryStatus 读报名项当前状态。
|
||||
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
|
||||
t.Fatalf("读报名项状态: %v", err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
|
||||
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
|
||||
t.Helper()
|
||||
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
|
||||
code, name, name)
|
||||
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||||
athleteName, teamID)
|
||||
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
|
||||
return teamID, entryID
|
||||
}
|
||||
|
||||
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
|
||||
func TestEntryBatchScratchAndRestore(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||||
publishResults(t, env, client, ids.event, "batch",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
// 前置:10 / 8 / 6 分。
|
||||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||||
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||||
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[batchResult](t, env2)
|
||||
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
|
||||
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
|
||||
}
|
||||
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
|
||||
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
|
||||
}
|
||||
|
||||
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
|
||||
t.Fatalf("B 应已退赛,得到 %s", got)
|
||||
}
|
||||
if got := entryStatus(t, env, entryC); got != "scratched" {
|
||||
t.Fatalf("C 应已退赛,得到 %s", got)
|
||||
}
|
||||
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
|
||||
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
|
||||
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
|
||||
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
|
||||
}
|
||||
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("A 应保持 10 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, teamC); got != 0 {
|
||||
t.Fatalf("C 单位总分应归零,得到 %v", got)
|
||||
}
|
||||
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
|
||||
for _, id := range []int64{ids.entryB, entryC} {
|
||||
var rank sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||||
t.Fatalf("读退赛行的名次: %v", err)
|
||||
}
|
||||
if rank.Valid {
|
||||
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
|
||||
}
|
||||
}
|
||||
if n := countRows(t, env,
|
||||
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
|
||||
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "restore", "ids": []int64{ids.entryB, entryC},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryB); got != "registered" {
|
||||
t.Fatalf("B 应恢复为 registered,得到 %s", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||||
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||||
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
|
||||
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||||
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
|
||||
}
|
||||
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
|
||||
var restoredRank sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
|
||||
t.Fatalf("读恢复后的名次: %v", err)
|
||||
}
|
||||
if !restoredRank.Valid || restoredRank.Int64 != 2 {
|
||||
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
|
||||
}
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
}
|
||||
|
||||
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
|
||||
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[batchResult](t, env2)
|
||||
if len(result.Items) != 3 {
|
||||
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
|
||||
}
|
||||
if !result.Items[0].OK || result.Items[0].Skipped {
|
||||
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
|
||||
}
|
||||
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
|
||||
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
|
||||
}
|
||||
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
|
||||
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
|
||||
}
|
||||
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
|
||||
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||
t.Fatalf("A 应已退赛,得到 %s", got)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryB); got != "dq" {
|
||||
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
|
||||
}
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
}
|
||||
|
||||
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
|
||||
// 留下的人按新名次重算。
|
||||
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||||
publishResults(t, env, client, ids.event, "remove",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
|
||||
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
|
||||
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "remove", "ids": []int64{ids.entryB},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[batchResult](t, env2)
|
||||
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
|
||||
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
|
||||
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
|
||||
}
|
||||
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
|
||||
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||
t.Fatalf("成绩应被删除,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||||
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
|
||||
}
|
||||
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
|
||||
if n := countRows(t, env,
|
||||
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
|
||||
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
|
||||
}
|
||||
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
|
||||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
|
||||
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, teamC); got != 8 {
|
||||
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
|
||||
}
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
}
|
||||
|
||||
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
|
||||
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
event2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, event2)
|
||||
var profileID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
|
||||
t.Fatalf("读取积分模板: %v", err)
|
||||
}
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'athlete', 'result', ?, 0)`, event2, profileID)
|
||||
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
|
||||
publishResults(t, env, client, ids.event, "cross1",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
|
||||
publishResults(t, env, client, event2, "cross2",
|
||||
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA, entry2},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[batchResult](t, env2)
|
||||
if len(result.Summary.RecomputedEvent) != 2 ||
|
||||
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
|
||||
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
|
||||
}
|
||||
if got := entryStatus(t, env, entry2); got != "scratched" {
|
||||
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
|
||||
func TestEntryBatchValidation(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
|
||||
map[string]any{"action": "scratch", "ids": []int64{}}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 重复 id 去重后只回报一条。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
dup := decodeData[batchResult](t, env2)
|
||||
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
|
||||
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
|
||||
}
|
||||
|
||||
// 超过上限要在动手之前拒绝。
|
||||
big := make([]int64, 501)
|
||||
for i := range big {
|
||||
big[i] = int64(i + 1)
|
||||
}
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||||
map[string]any{"action": "remove", "ids": big}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
|
||||
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// guest(未登录)不能写。
|
||||
guest, _ := env.client()
|
||||
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
|
||||
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
|
||||
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
|
||||
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
|
||||
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
var after sql.NullString
|
||||
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
|
||||
action).Scan(&after)
|
||||
switch {
|
||||
case err == nil:
|
||||
return after.String
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
default:
|
||||
t.Fatalf("查询审计: %v", err)
|
||||
}
|
||||
}
|
||||
t.Fatalf("等不到 %s 的审计记录", action)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 审计要带 ids 与 summary,三个动作各一条。
|
||||
func TestEntryBatchAudit(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
for _, action := range []string{"scratch", "restore", "remove"} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
|
||||
}
|
||||
after := waitBatchAudit(t, env, "entry."+action)
|
||||
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
|
||||
if !strings.Contains(after, want) {
|
||||
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
|
||||
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "keep",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "remove", "ids": []int64{ids.entryA},
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var (
|
||||
rank sql.NullInt64
|
||||
state string
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
|
||||
Scan(&rank, &state); err != nil {
|
||||
t.Fatalf("读 B 的成绩: %v", err)
|
||||
}
|
||||
if !rank.Valid || rank.Int64 != 1 || state != "published" {
|
||||
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// cellAt 取单元格文本,越界返回空串(表格行可能比表头短)。
|
||||
func cellAt(rows [][]string, r, c int) string {
|
||||
if r < 0 || r >= len(rows) || c < 0 || c >= len(rows[r]) {
|
||||
return ""
|
||||
}
|
||||
return rows[r][c]
|
||||
}
|
||||
|
||||
type testSheet struct {
|
||||
name string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
// buildTestXLSX 手工拼一份上传用的工作簿(表头在第 1 行),模拟现场自己整理的表。
|
||||
func buildTestXLSX(t *testing.T, sheets []testSheet) []byte {
|
||||
t.Helper()
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
for i, sh := range sheets {
|
||||
if i == 0 {
|
||||
if err := f.SetSheetName("Sheet1", sh.name); err != nil {
|
||||
t.Fatalf("重命名工作表: %v", err)
|
||||
}
|
||||
} else if _, err := f.NewSheet(sh.name); err != nil {
|
||||
t.Fatalf("新建工作表: %v", err)
|
||||
}
|
||||
for r, row := range sh.rows {
|
||||
for c, v := range row {
|
||||
name, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err != nil {
|
||||
t.Fatalf("算单元格坐标: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sh.name, name, v); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// editWorkbookCell 在导出的工作簿里找到「A 列等于 key」的那一行,把另一列改成新值。
|
||||
// 行号不写死:导出的版式(标题 / 副标题 / 空行 / 表头)以后变了也不会让测试失效。
|
||||
func editWorkbookCell(t *testing.T, f *excelize.File, sheet, keyCol, key, targetCol, value string) {
|
||||
t.Helper()
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表 %s: %v", sheet, err)
|
||||
}
|
||||
for r := range rows {
|
||||
if cellAt(rows, r, columnIndex(keyCol)) != key {
|
||||
continue
|
||||
}
|
||||
cell, err := excelize.CoordinatesToCellName(columnIndex(targetCol)+1, r+1)
|
||||
if err != nil {
|
||||
t.Fatalf("算单元格坐标: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, value); err != nil {
|
||||
t.Fatalf("改单元格: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("工作表 %s 里找不到 %s=%s 的行", sheet, keyCol, key)
|
||||
}
|
||||
|
||||
func columnIndex(letter string) int {
|
||||
n, _ := excelize.ColumnNameToNumber(letter)
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// uploadBaseData 以 multipart 上传基础数据包,返回状态码与原始响应体。
|
||||
func (e *testEnv) uploadBaseData(t *testing.T, client *http.Client, filename string, data []byte, fields map[string]string) (int, []byte) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
if err := w.WriteField(k, v); err != nil {
|
||||
t.Fatalf("写表单字段: %v", err)
|
||||
}
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("创建上传字段: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("写工作簿: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("关闭 multipart: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/basedata/import", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("构造请求: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("上传基础数据包: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// baseDataResult 是导入响应里我们关心的部分(与后端 baseDataImportResult 同构)。
|
||||
type baseDataResult struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
Errors []string `json:"errors"`
|
||||
Changes []struct {
|
||||
Sheet string `json:"sheet"`
|
||||
Line int `json:"line"`
|
||||
Action string `json:"action"`
|
||||
Key string `json:"key"`
|
||||
Fields []struct {
|
||||
Field string `json:"field"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
} `json:"fields"`
|
||||
} `json:"changes"`
|
||||
}
|
||||
|
||||
func decodeBaseDataResult(t *testing.T, raw []byte) baseDataResult {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data baseDataResult `json:"data"`
|
||||
Error *apiErrorBody `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("解析导入响应: %v (%s)", err, raw)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("导入失败: %+v", env.Error)
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
// 往返硬要求:刚导出的基础数据包原样导入,差异必须是 0/0/0。
|
||||
func TestBaseDataPackageRoundTripIsNoop(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, header, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "basedata.xlsx", "基础数据包.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
if got := f.GetSheetList(); !sameStrings(got, []string{"单位", "大项", "小项", "组别"}) {
|
||||
t.Fatalf("工作表列表 = %v,期望 单位 / 大项 / 小项 / 组别", got)
|
||||
}
|
||||
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", body, map[string]string{"dryRun": "1"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
res := decodeBaseDataResult(t, raw)
|
||||
if !res.DryRun {
|
||||
t.Errorf("dryRun 应为 true")
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("原样导入应无差异,得到 新增 %d / 更新 %d / 错误 %d:%v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if len(res.Changes) != 0 {
|
||||
t.Errorf("无差异时不该有逐行差异,得到 %+v", res.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
// 改掉一个单位名与一个小项名再导入:差异应正好是这 2 条更新,其余 0。
|
||||
func TestBaseDataImportReportsUpdates(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
_, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
|
||||
|
||||
// 在导出的工作簿上改两处:一个单位名、一个小项名。
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
editWorkbookCell(t, f, "单位", "B", "第一分公司", "B", "第一分公司(改)")
|
||||
editWorkbookCell(t, f, "小项", "B", "M100", "C", "百米飞人大战")
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写回工作簿: %v", err)
|
||||
}
|
||||
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", buf.Bytes(), map[string]string{"dryRun": "1"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
res := decodeBaseDataResult(t, raw)
|
||||
if res.Added != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d:%v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Updated != 2 {
|
||||
t.Fatalf("更新条数 = %d,期望 2", res.Updated)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, ch := range res.Changes {
|
||||
for _, f := range ch.Fields {
|
||||
seen[ch.Sheet+"."+f.Field] = f.From + "→" + f.To
|
||||
}
|
||||
}
|
||||
if got := seen["单位.单位名称"]; got != "第一分公司→第一分公司(改)" {
|
||||
t.Errorf("单位名差异 = %q,期望 第一分公司→第一分公司(改)", got)
|
||||
}
|
||||
if got := seen["小项.小项名称"]; got != "100米跑→百米飞人大战" {
|
||||
t.Errorf("小项名差异 = %q,期望 100米跑→百米飞人大战", got)
|
||||
}
|
||||
|
||||
// dryRun 绝不落库:库里的名字还是原来的。
|
||||
var name string
|
||||
if err := env.ev.QueryRow(`SELECT name FROM teams WHERE id = ?`, ids.teamA).Scan(&name); err != nil {
|
||||
t.Fatalf("查询单位: %v", err)
|
||||
}
|
||||
if name != "第一分公司" {
|
||||
t.Fatalf("dryRun 不该写库,单位名变成了 %q", name)
|
||||
}
|
||||
var eventName string
|
||||
if err := env.ev.QueryRow(`SELECT name FROM events WHERE id = ?`, ids.event).Scan(&eventName); err != nil {
|
||||
t.Fatalf("查询小项: %v", err)
|
||||
}
|
||||
if eventName != "100米跑" {
|
||||
t.Fatalf("dryRun 不该写库,小项名变成了 %q", eventName)
|
||||
}
|
||||
}
|
||||
|
||||
// 绝不删除:只含 1 个新单位的表导进去,其余单位一条不少。
|
||||
func TestBaseDataImportNeverDeletes(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
before := tableCount(t, env, "teams")
|
||||
beforeSports := tableCount(t, env, "sports")
|
||||
beforeEvents := tableCount(t, env, "events")
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{{
|
||||
name: "单位",
|
||||
rows: [][]string{
|
||||
{"单位编码", "单位名称", "启用"},
|
||||
{"C", "第三分公司", "是"},
|
||||
},
|
||||
}})
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导入应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
res := decodeBaseDataResult(t, raw)
|
||||
if res.Added != 1 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应新增 1 条,得到 新增 %d / 更新 %d / 错误 %d %v", res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
|
||||
if got := tableCount(t, env, "teams"); got != before+1 {
|
||||
t.Fatalf("单位数 = %d,期望 %d(只多 1 条,其余一条不少)", got, before+1)
|
||||
}
|
||||
for _, code := range []string{"A", "B", "C"} {
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT count(*) FROM teams WHERE code = ?`, code).Scan(&n); err != nil {
|
||||
t.Fatalf("查询单位 %s: %v", code, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("单位 %s 的记录数 = %d,期望 1", code, n)
|
||||
}
|
||||
}
|
||||
if got := tableCount(t, env, "sports"); got != beforeSports {
|
||||
t.Errorf("大项数 = %d,期望 %d(表里没写的不动)", got, beforeSports)
|
||||
}
|
||||
if got := tableCount(t, env, "events"); got != beforeEvents {
|
||||
t.Errorf("小项数 = %d,期望 %d(表里没写的不动)", got, beforeEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// 新建的单位 / 大项不能被建成停用:缺省 = 启用。
|
||||
func TestBaseDataImportCreatesEnabledRows(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{
|
||||
{name: "单位", rows: [][]string{
|
||||
{"单位编码", "单位名称"},
|
||||
{"C", "第三分公司"},
|
||||
}},
|
||||
{name: "大项", rows: [][]string{
|
||||
{"大项编码", "大项名称"},
|
||||
{"SWIM", "游泳"},
|
||||
}},
|
||||
{name: "小项", rows: [][]string{
|
||||
{"大项编码", "小项编码", "小项名称", "类型", "结果类型"},
|
||||
{"SWIM", "S100", "100米自由泳", "individual", "time"},
|
||||
}},
|
||||
{name: "组别", rows: [][]string{
|
||||
{"小项编码", "组别名", "性别", "年龄下限", "年龄上限"},
|
||||
{"S100", "男子", "男", "18", "35"},
|
||||
}},
|
||||
})
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导入应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
res := decodeBaseDataResult(t, raw)
|
||||
if res.Added != 4 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应新增 4 条,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
|
||||
}
|
||||
|
||||
var active int
|
||||
if err := env.ev.QueryRow(`SELECT active FROM teams WHERE code = 'C'`).Scan(&active); err != nil {
|
||||
t.Fatalf("查询新单位: %v", err)
|
||||
}
|
||||
if active != 1 {
|
||||
t.Fatalf("新单位应启用(active=1),得到 %d", active)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM sports WHERE code = 'SWIM'`).Scan(&active); err != nil {
|
||||
t.Fatalf("查询新大项: %v", err)
|
||||
}
|
||||
if active != 1 {
|
||||
t.Fatalf("新大项应启用(active=1),得到 %d", active)
|
||||
}
|
||||
|
||||
// 小项挂到了新建的大项下,组别挂到了新建的小项下,枚举值也按原样落库。
|
||||
var (
|
||||
sportID int64
|
||||
evType string
|
||||
rType string
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT sport_id, event_type, result_type FROM events WHERE code = 'S100'`).
|
||||
Scan(&sportID, &evType, &rType); err != nil {
|
||||
t.Fatalf("查询新小项: %v", err)
|
||||
}
|
||||
var wantSport int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM sports WHERE code = 'SWIM'`).Scan(&wantSport); err != nil {
|
||||
t.Fatalf("查询新大项 id: %v", err)
|
||||
}
|
||||
if sportID != wantSport {
|
||||
t.Errorf("新小项的大项 id = %d,期望 %d", sportID, wantSport)
|
||||
}
|
||||
if evType != "individual" || rType != "time" {
|
||||
t.Errorf("枚举值 = %s / %s,期望 individual / time", evType, rType)
|
||||
}
|
||||
|
||||
var gender string
|
||||
var ageMin, ageMax int
|
||||
if err := env.ev.QueryRow(`SELECT gender, age_min, age_max FROM event_categories WHERE name = '男子' AND event_id = ?`,
|
||||
func() int64 {
|
||||
var id int64
|
||||
_ = env.ev.QueryRow(`SELECT id FROM events WHERE code = 'S100'`).Scan(&id)
|
||||
return id
|
||||
}()).
|
||||
Scan(&gender, &ageMin, &ageMax); err != nil {
|
||||
t.Fatalf("查询新组别: %v", err)
|
||||
}
|
||||
if gender != "男" || ageMin != 18 || ageMax != 35 {
|
||||
t.Errorf("组别 = %s %d-%d,期望 男 18-35", gender, ageMin, ageMax)
|
||||
}
|
||||
}
|
||||
|
||||
// 行级错误:中文、带行号、带工作表名,且 dryRun 下库一点没动。
|
||||
func TestBaseDataImportRowErrors(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
before := countAllTables(t, env)
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{
|
||||
{name: "小项", rows: [][]string{
|
||||
{"大项编码", "小项编码", "小项名称", "类型"},
|
||||
{"NOPE", "X1", "幽灵小项", "individual"}, // 第 2 行:大项不存在
|
||||
{"TRACK", "X2", "坏类型", "乱七八糟"}, // 第 3 行:枚举非法
|
||||
}},
|
||||
{name: "组别", rows: [][]string{
|
||||
{"小项编码", "组别名"},
|
||||
{"NOPE", "甲组"}, // 第 2 行:小项不存在
|
||||
}},
|
||||
})
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, map[string]string{"dryRun": "1"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("预览应 200,得到 %d %s", status, raw)
|
||||
}
|
||||
res := decodeBaseDataResult(t, raw)
|
||||
if res.ErrorCount != 3 {
|
||||
t.Fatalf("错误条数 = %d,期望 3:%v", res.ErrorCount, res.Errors)
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 0 {
|
||||
t.Errorf("全错时不该有变更,得到 新增 %d / 更新 %d", res.Added, res.Updated)
|
||||
}
|
||||
wantParts := [][]string{
|
||||
{"小项", "第 2 行", "大项编码 NOPE 不存在"},
|
||||
{"小项", "第 3 行", "eventType 只能是 individual / team / match / individual_team"},
|
||||
{"组别", "第 2 行", "小项编码 NOPE 不存在"},
|
||||
}
|
||||
for i, parts := range wantParts {
|
||||
if i >= len(res.Errors) {
|
||||
t.Fatalf("缺少第 %d 条错误", i+1)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if !strings.Contains(res.Errors[i], p) {
|
||||
t.Errorf("错误 %d = %q,应包含 %q", i+1, res.Errors[i], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dryRun 不写库:四张表计数一条不变。
|
||||
if got := countAllTables(t, env); !sameCounts(got, before) {
|
||||
t.Fatalf("dryRun 不该写库,导入前后计数不同:%v → %v", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入是批量写,只有 admin 能做。
|
||||
func TestBaseDataImportAdminOnly(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{{name: "单位", rows: [][]string{
|
||||
{"单位编码", "单位名称"},
|
||||
{"C", "第三分公司"},
|
||||
}}})
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 导入应 403,得到 %d %s", status, raw)
|
||||
}
|
||||
if got := tableCount(t, env, "teams"); got != 2 {
|
||||
t.Fatalf("403 不该写库,单位数 = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDataImportRejectsNonXlsx(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.csv", []byte("单位编码,单位名称\nC,第三分公司\n"), nil)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("CSV 应 400,得到 %d %s", status, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "xlsx") {
|
||||
t.Errorf("错误信息应说明必须是 xlsx,得到 %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 小工具 ----------
|
||||
|
||||
func tableCount(t *testing.T, env *testEnv, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countAllTables(t *testing.T, env *testEnv) map[string]int {
|
||||
t.Helper()
|
||||
out := make(map[string]int, 4)
|
||||
for _, table := range []string{"teams", "sports", "events", "event_categories"} {
|
||||
out[table] = tableCount(t, env, table)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameCounts(a, b map[string]int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameStrings(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,106 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 报名名单导出:guest 可用、表头含批次、前导零不丢、筛选参数生效。
|
||||
func TestExportEntriesXLSX(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
// 号码布带前导零,entryA 分到批次 2,entryB 未分批。
|
||||
env.exec(t, `UPDATE entries SET bib = '007', heat = 2, remark = '卫冕冠军' WHERE id = ?`, ids.entryA)
|
||||
env.exec(t, `UPDATE entries SET bib = '0012' WHERE id = ?`, ids.entryB)
|
||||
|
||||
client, _ := env.client()
|
||||
status, header, body := env.getRaw(t, client, "/api/export/entries.xlsx")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("导出报名名单应 200,得到 %d %s", status, body)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "entries.xlsx", "报名名单.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
rows, err := f.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
// 4 行版式 + 2 条报名。
|
||||
if len(rows) != 6 {
|
||||
t.Fatalf("行数 = %d,期望 6", len(rows))
|
||||
}
|
||||
wantHeaders := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名",
|
||||
"证件号", "号码布", "批次", "性别", "年龄", "状态", "备注"}
|
||||
for i, want := range wantHeaders {
|
||||
if got := cellAt(rows, 3, i); got != want {
|
||||
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, want)
|
||||
}
|
||||
}
|
||||
// 未分批(heat 为空)的排最前。
|
||||
if got := cellAt(rows, 4, 5); got != "李强" {
|
||||
t.Fatalf("第一行姓名 = %q,期望 李强", got)
|
||||
}
|
||||
if got := cellAt(rows, 4, 7); got != "0012" {
|
||||
t.Errorf("号码布 = %q,期望 0012(前导零不能丢)", got)
|
||||
}
|
||||
if got := cellAt(rows, 4, 8); got != "" {
|
||||
t.Errorf("未分批行批次 = %q,期望留空", got)
|
||||
}
|
||||
if got := cellAt(rows, 5, 5); got != "张伟" {
|
||||
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
|
||||
}
|
||||
if got := cellAt(rows, 5, 8); got != "2" {
|
||||
t.Errorf("批次 = %q,期望 2", got)
|
||||
}
|
||||
if got := cellAt(rows, 5, 11); got != "正常参赛" {
|
||||
t.Errorf("状态 = %q,期望 正常参赛", got)
|
||||
}
|
||||
|
||||
// 按批次筛选:只剩批次 2 的那条。
|
||||
status, _, filtered := env.getRaw(t, client, "/api/export/entries.xlsx?heat=2")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("按批次筛选应 200,得到 %d", status)
|
||||
}
|
||||
f2 := openWorkbook(t, filtered)
|
||||
defer func() { _ = f2.Close() }()
|
||||
fRows, err := f2.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取筛选结果: %v", err)
|
||||
}
|
||||
if len(fRows) != 5 {
|
||||
t.Fatalf("按批次筛选后行数 = %d,期望 5", len(fRows))
|
||||
}
|
||||
if got := cellAt(fRows, 4, 8); got != "2" {
|
||||
t.Errorf("筛选后批次 = %q,期望 2", got)
|
||||
}
|
||||
|
||||
// 按单位 + 小项 + 组别组合筛选。
|
||||
path := "/api/export/entries.xlsx?teamId=1&eventId=1&categoryId=1"
|
||||
status, _, filtered = env.getRaw(t, client, path)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("组合筛选应 200,得到 %d", status)
|
||||
}
|
||||
f3 := openWorkbook(t, filtered)
|
||||
defer func() { _ = f3.Close() }()
|
||||
cRows, _ := f3.GetRows("报名名单")
|
||||
if len(cRows) != 5 {
|
||||
t.Fatalf("组合筛选后行数 = %d,期望 5", len(cRows))
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选参数填错必须报错:静默当成「不筛」会让现场拿到全量名单还以为筛过了。
|
||||
func TestExportEntriesXLSXRejectsBadFilter(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
client, _ := env.client()
|
||||
|
||||
status, _, body := env.getRaw(t, client, "/api/export/entries.xlsx?heat=abc")
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("非法批次筛选应 400,得到 %d %s", status, body)
|
||||
}
|
||||
status, _, body = env.getRaw(t, client, "/api/export/entries.xlsx?teamId=0")
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("0 不是合法单位 id,应 400,得到 %d %s", status, body)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
@@ -59,7 +60,12 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
|
||||
After: map[string]any{"username": u.Username},
|
||||
})
|
||||
return userDTO(u), nil
|
||||
out := userDTO(u)
|
||||
// 刚建好的会话到期时间 = 此刻 + 当前有效期(与 createSession 同一请求内,误差在毫秒级);
|
||||
// 下发它,界面登录后就能立刻显示「当前会话将于 … 过期」,不用等下一次 /auth/me。
|
||||
expiresAt := time.Now().UTC().Add(s.auth.SessionTTL()).Format(time.RFC3339)
|
||||
out.SessionExpiresAt = &expiresAt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
@@ -75,7 +81,31 @@ func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
|
||||
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
u := middleware.User(r.Context())
|
||||
return userDTO(u), nil
|
||||
out := userDTO(u)
|
||||
if u.Role == auth.RoleGuest {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 顺带把会话 Cookie 的期限同步到最新的到期时间:
|
||||
// 滑动续期只改服务端 expires_at,Cookie 的 Max-Age 不跟着走的话,
|
||||
// 浏览器会在「登录时刻 + 有效期」处丢掉 Cookie,人还是被登出。
|
||||
// /auth/me 是管理端启动与定时心跳都会走的接口,放在这里就够。
|
||||
c, err := r.Cookie(auth.CookieSession)
|
||||
if err != nil || c.Value == "" {
|
||||
return out, nil
|
||||
}
|
||||
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
|
||||
if err != nil || sess == nil {
|
||||
return out, nil
|
||||
}
|
||||
expiresAt := sess.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
out.SessionExpiresAt = &expiresAt
|
||||
csrf := ""
|
||||
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
|
||||
csrf = c.Value
|
||||
}
|
||||
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type changePasswordInput struct {
|
||||
@@ -356,9 +386,19 @@ func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, e
|
||||
|
||||
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in eventDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 与建单位 / 大项同口径:缺省 = 建表默认值(migrations/event/0004_sports_events.sql 里
|
||||
// scored 与 record_eligible 都 DEFAULT 1)。Go 的 bool 零值是 false,漏传会被静默建成
|
||||
// 「只排名不计分、不能破纪录」的小项 —— 这与导入侧按建表默认值建档的口径也不一致。
|
||||
if !keys.has("scored") {
|
||||
in.Scored = true
|
||||
}
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = true
|
||||
}
|
||||
created, err := s.createEvent(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -373,13 +413,22 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
return nil, err
|
||||
}
|
||||
var in eventDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getEvent(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新仍是全量覆盖语义,但漏传这两个开关不等于「关掉它」:沿用库里的现值,
|
||||
// 与 active 在单位 / 大项上的处理一致。
|
||||
if !keys.has("scored") {
|
||||
in.Scored = before.Scored
|
||||
}
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = before.RecordEligible
|
||||
}
|
||||
updated, err := s.updateEvent(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -611,9 +660,12 @@ type entryImportResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
// SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。
|
||||
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
|
||||
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
|
||||
}
|
||||
|
||||
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
|
||||
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
@@ -886,9 +938,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
@@ -904,8 +956,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
|
||||
seenIDCards := make(map[string]bool)
|
||||
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
|
||||
seenBibs := make(map[string]bool)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
@@ -982,7 +1034,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
if athleteName != "" {
|
||||
idCard := cell("id_card")
|
||||
bib := cell("bib")
|
||||
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
|
||||
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
@@ -994,14 +1046,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
continue
|
||||
}
|
||||
if idCard != "" {
|
||||
if seenIDCards[idCard] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
}
|
||||
if bib != "" {
|
||||
if seenBibs[bib] {
|
||||
result.Skipped++
|
||||
@@ -1023,13 +1067,16 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
|
||||
@@ -231,12 +231,12 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
@@ -285,8 +285,8 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号重复出现 → 该行报错并跳过。
|
||||
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||||
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
|
||||
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
@@ -297,12 +297,14 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("首行应新建、次行应跳过: %+v", result)
|
||||
if result["skipped"] != float64(0) {
|
||||
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
|
||||
t.Fatalf("应报证件号重复,得到 %+v", errs)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不应有行级报错,得到 %+v", errs)
|
||||
}
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) {
|
||||
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
@@ -310,7 +312,118 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
|
||||
t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
|
||||
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
|
||||
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("撞号的证件号应跳过该行: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
|
||||
}
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
|
||||
}
|
||||
|
||||
// 库里的张伟一行没动,也没有多出一个人。
|
||||
var (
|
||||
name string
|
||||
bib string
|
||||
count int
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if name != "张伟" || bib != "A100" {
|
||||
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
|
||||
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同名不同号码布应新建报名: %+v", result)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
|
||||
}
|
||||
reported, _ := result["sameNameNewPersons"].([]any)
|
||||
if len(reported) != 1 {
|
||||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||||
}
|
||||
item := reported[0].(map[string]any)
|
||||
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
|
||||
}
|
||||
// 原档案的号码布保持不动,新档案拿表里的号码布。
|
||||
var oldBib string
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
|
||||
t.Fatalf("查询原人员: %v", err)
|
||||
}
|
||||
if oldBib != "1001" {
|
||||
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
|
||||
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
|
||||
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("歧义行不落库也不跳过: %+v", result)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||
}
|
||||
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
|
||||
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,9 +473,9 @@ func TestImportToleratesMissingOptionalColumns(t *testing.T) {
|
||||
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
|
||||
func TestImportResolveKeepExisting(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||
|
||||
// 未给决策时,冲突原样返回。
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
|
||||
@@ -415,9 +528,9 @@ func TestImportResolveKeepExisting(t *testing.T) {
|
||||
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
|
||||
func TestImportResolveKeepNew(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
@@ -618,44 +731,36 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
|
||||
func TestImportConflictIncludesBibDiff(t *testing.T) {
|
||||
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
|
||||
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
|
||||
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||
if result["skipped"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("撞号应跳过该行: %+v", result)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["bib"] != "A200" {
|
||||
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
|
||||
}
|
||||
existing := conflict["existing"].(map[string]any)
|
||||
if existing["bib"] != "A100" {
|
||||
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
|
||||
}
|
||||
incoming := conflict["incoming"].(map[string]any)
|
||||
if incoming["bib"] != "A200" {
|
||||
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
|
||||
}
|
||||
|
||||
var bib string
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||||
t.Fatalf("查询人员号码布: %v", err)
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if bib != "A100" {
|
||||
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
|
||||
if athletes != 2 {
|
||||
t.Fatalf("不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,3 +1371,94 @@ func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
|
||||
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
|
||||
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
|
||||
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
|
||||
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
boolField := func(t *testing.T, body envelope, field string) bool {
|
||||
t.Helper()
|
||||
got, ok := decodeData[map[string]any](t, body)[field].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// 库里也真的按接口回报的值落着,不只是响应体好看。
|
||||
assertStoredFlags := func(t *testing.T, code string, want int) {
|
||||
t.Helper()
|
||||
var scored, recordEligible int
|
||||
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
|
||||
Scan(&scored, &recordEligible); err != nil {
|
||||
t.Fatalf("查询小项 %s 的开关: %v", code, err)
|
||||
}
|
||||
if scored != want || recordEligible != want {
|
||||
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
|
||||
}
|
||||
}
|
||||
|
||||
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
|
||||
|
||||
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||||
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
|
||||
}
|
||||
assertStoredFlags(t, "E-BOOL", 1)
|
||||
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
|
||||
|
||||
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
|
||||
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
|
||||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||||
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
|
||||
}
|
||||
|
||||
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
|
||||
turnOff := map[string]any{
|
||||
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
|
||||
"scored": false, "recordEligible": false,
|
||||
}
|
||||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||||
}
|
||||
|
||||
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
|
||||
rename["name"] = "开关缺省(改名三)"
|
||||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
|
||||
}
|
||||
|
||||
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
|
||||
explicitOff := map[string]any{
|
||||
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
|
||||
"scored": false, "recordEligible": false,
|
||||
}
|
||||
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||||
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||||
}
|
||||
assertStoredFlags(t, "E-BOOL-OFF", 0)
|
||||
assertStoredFlags(t, "E-BOOL", 0)
|
||||
}
|
||||
@@ -63,6 +63,27 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
})
|
||||
}
|
||||
|
||||
// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。
|
||||
func (s *server) settleResultsHandler(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.settleResults(r.Context(), in, middlewareUserID(r))
|
||||
})
|
||||
}
|
||||
|
||||
// settlementStatusHandler 返回未结算的 (小项, 组别) 清单,供录入页提示与「立即结算」入口。
|
||||
func (s *server) settlementStatusHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
eventID, _ := int64Query(r, "eventId")
|
||||
if eventID == 0 {
|
||||
return nil, badRequest("eventId 必填", nil)
|
||||
}
|
||||
categoryID, _ := int64Query(r, "categoryId")
|
||||
return s.settlementStatus(r.Context(), eventID, categoryID)
|
||||
}
|
||||
|
||||
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,16 @@ type entryHeatPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// entryBatchPayload 是报名项批量操作的请求体:
|
||||
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
|
||||
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
|
||||
type entryBatchPayload struct {
|
||||
Action string `json:"action"`
|
||||
IDs []int64 `json:"ids"`
|
||||
Reason string `json:"reason"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type adjustmentInput struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Delta float64 `json:"delta"`
|
||||
@@ -117,12 +127,29 @@ type settingsStandingsPatch struct {
|
||||
RankingMode *string `json:"rankingMode"`
|
||||
ShowIndividualBoard *bool `json:"showIndividualBoard"`
|
||||
ShowIndividualMedals *bool `json:"showIndividualMedals"`
|
||||
// LiveOnPublish 是「分批发布时是否实时更新各单位总分与奖牌榜」;nil 表示不改动。
|
||||
LiveOnPublish *bool `json:"liveOnPublish"`
|
||||
}
|
||||
|
||||
// sessionSettingsPatch 是 PUT /api/system/session 的请求体:
|
||||
// 只改「登录有效期(小时)」这一个系统级配置。
|
||||
type sessionSettingsPatch struct {
|
||||
SessionHours *int `json:"sessionHours"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type categoriesPayload struct {
|
||||
Categories []categoryDTO `json:"categories"`
|
||||
}
|
||||
|
||||
// athletesBatchPayload 是运动员批量动作的请求体:action 决定停用还是彻底删除。
|
||||
type athletesBatchPayload struct {
|
||||
Action string `json:"action"`
|
||||
IDs []int64 `json:"ids"`
|
||||
// DryRun 只统计不改库:前端用它先看清「会删掉多少、重算哪些小项」再确认。
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
|
||||
type channelsPayload struct {
|
||||
Channels []channelDTO `json:"channels"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -645,6 +646,323 @@ func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause e
|
||||
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
|
||||
}
|
||||
|
||||
// ---------- 运动员批量动作 ----------
|
||||
|
||||
const (
|
||||
athleteBatchDisable = "disable"
|
||||
athleteBatchPurge = "purge"
|
||||
)
|
||||
|
||||
// athleteBatchItem 是批量动作里逐条的结果。
|
||||
//
|
||||
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
|
||||
// 静默跳过又会让他以为整批都成了,所以一条一条回报。
|
||||
type athleteBatchItem struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。
|
||||
// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。
|
||||
type athleteBatchResult struct {
|
||||
Action string `json:"action"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
Requested int `json:"requested"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Items []athleteBatchItem `json:"items"`
|
||||
Purge *athletePurge `json:"purge,omitempty"`
|
||||
}
|
||||
|
||||
// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。
|
||||
type purgeScope struct{ eventID, categoryID int64 }
|
||||
|
||||
func idArgs(ids []int64) []any {
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。
|
||||
func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
found := make(map[int64]bool, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
found[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out := make([]int64, 0, len(found))
|
||||
for _, id := range ids {
|
||||
if found[id] {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。
|
||||
func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult {
|
||||
ok := make(map[int64]bool, len(existing))
|
||||
for _, id := range existing {
|
||||
ok[id] = true
|
||||
}
|
||||
items := make([]athleteBatchItem, 0, len(ids))
|
||||
result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge}
|
||||
for _, id := range ids {
|
||||
if ok[id] {
|
||||
items = append(items, athleteBatchItem{ID: id, OK: true})
|
||||
result.Succeeded++
|
||||
continue
|
||||
}
|
||||
items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"})
|
||||
result.Failed++
|
||||
}
|
||||
result.Items = items
|
||||
return result
|
||||
}
|
||||
|
||||
// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。
|
||||
//
|
||||
// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流;
|
||||
// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。
|
||||
// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。
|
||||
func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) {
|
||||
switch action {
|
||||
case athleteBatchDisable, athleteBatchPurge:
|
||||
default:
|
||||
return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action)
|
||||
}
|
||||
ids := dedupeIDs(rawIDs)
|
||||
if len(ids) == 0 {
|
||||
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
|
||||
}
|
||||
if action == athleteBatchPurge {
|
||||
return s.batchPurgeAthletes(ctx, ids, dryRun)
|
||||
}
|
||||
return s.batchDisableAthletes(ctx, ids, dryRun)
|
||||
}
|
||||
|
||||
// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0,
|
||||
// 报名、成绩、流水一行不动,也不触发任何重算。
|
||||
func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
|
||||
existing, err := existingAthleteIDs(ctx, s.ev, ids)
|
||||
if err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
if !dryRun && len(existing) > 0 {
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`,
|
||||
idArgs(existing)...); err != nil {
|
||||
return athleteBatchResult{}, internal(err)
|
||||
}
|
||||
}
|
||||
return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil
|
||||
}
|
||||
|
||||
// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录,
|
||||
// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。
|
||||
//
|
||||
// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。
|
||||
// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。
|
||||
func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return athleteBatchResult{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务,
|
||||
// 而且「已被别人删掉」和「id 不存在」本来就该一起回报。
|
||||
existing, err := existingAthleteIDs(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
// 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。
|
||||
scopes, err := purgeScopesTx(ctx, tx, existing)
|
||||
if err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
counts, err := deleteAthletesTx(ctx, tx, existing)
|
||||
if err != nil {
|
||||
var api *apiError
|
||||
if errors.As(err, &api) {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
|
||||
}
|
||||
|
||||
purge := &athletePurge{
|
||||
DeletedEntries: counts.DeletedEntries,
|
||||
DeletedResults: counts.DeletedResults,
|
||||
DeletedLedger: counts.DeletedLedger,
|
||||
DeletedBreaks: counts.DeletedBreaks,
|
||||
}
|
||||
eventIDs := make([]int64, 0, len(scopes))
|
||||
for _, sc := range scopes {
|
||||
if !slices.Contains(eventIDs, sc.eventID) {
|
||||
eventIDs = append(eventIDs, sc.eventID)
|
||||
}
|
||||
}
|
||||
purge.RecomputedEvent = eventIDs
|
||||
if dryRun {
|
||||
// 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。
|
||||
return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil
|
||||
}
|
||||
|
||||
actorID := middleware.User(ctx).ID
|
||||
for _, sc := range scopes {
|
||||
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算")
|
||||
if err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
}
|
||||
for _, eventID := range eventIDs {
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
}
|
||||
if len(eventIDs) > 0 {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return athleteBatchResult{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
|
||||
}
|
||||
return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil
|
||||
}
|
||||
|
||||
// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。
|
||||
func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT DISTINCT e.event_id, e.category_id
|
||||
FROM results r JOIN entries e ON e.id = r.entry_id
|
||||
WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]purgeScope, 0, 4)
|
||||
for rows.Next() {
|
||||
var sc purgeScope
|
||||
if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。
|
||||
// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。
|
||||
func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) {
|
||||
var out athletePurge
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ph := placeholders(len(ids))
|
||||
args := idArgs(ids)
|
||||
// score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。
|
||||
ledgerArgs := append(append(append([]any{}, args...), args...), args...)
|
||||
|
||||
for _, step := range []struct {
|
||||
query string
|
||||
args []any
|
||||
count *int
|
||||
}{
|
||||
{`DELETE FROM score_ledger
|
||||
WHERE athlete_id IN (` + ph + `)
|
||||
OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))
|
||||
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
|
||||
WHERE e.athlete_id IN (` + ph + `))`,
|
||||
ledgerArgs, &out.DeletedLedger},
|
||||
{`DELETE FROM record_breaks
|
||||
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
|
||||
args, &out.DeletedBreaks},
|
||||
{`DELETE FROM results
|
||||
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
|
||||
args, &out.DeletedResults},
|
||||
{`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries},
|
||||
} {
|
||||
res, err := tx.ExecContext(ctx, step.query, step.args...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return out, internal(err)
|
||||
}
|
||||
*step.count = int(n)
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return out, internal(err)
|
||||
}
|
||||
// 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态,
|
||||
// 用 409 明说并让整个事务回滚,别把成绩删了却报成功。
|
||||
if int(n) != len(ids) {
|
||||
return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。
|
||||
func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
|
||||
if !isForeignKeyViolation(cause) {
|
||||
return internal(cause)
|
||||
}
|
||||
s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause)
|
||||
_ = tx.Rollback()
|
||||
return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。")
|
||||
}
|
||||
|
||||
// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。
|
||||
func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in athletesBatchPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。
|
||||
if result.DryRun {
|
||||
return result, nil
|
||||
}
|
||||
// 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。
|
||||
// 逐条明细与失败原因都进 after,事后能对着名单查。
|
||||
s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- sports ----------
|
||||
|
||||
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -302,6 +304,9 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
|
||||
|
||||
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
|
||||
// 否则外键会把历史报名项一并带走。
|
||||
//
|
||||
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
|
||||
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
|
||||
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
|
||||
@@ -310,7 +315,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
return nil, internal(err)
|
||||
}
|
||||
if has {
|
||||
return nil, conflict("该项目已有报名数据,不能整体替换组别")
|
||||
return nil, conflict("该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存")
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
@@ -345,6 +350,168 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
return s.listCategories(ctx, eventID)
|
||||
}
|
||||
|
||||
// getCategory 读单个组别,并校验它属于该小项。不属于(或用别的小项 id 来探)
|
||||
// 一律按「组别不存在」处理,不给跨小项改名的机会。
|
||||
func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (categoryDTO, error) {
|
||||
var (
|
||||
c categoryDTO
|
||||
gender sql.NullString
|
||||
ageMin, ageMax sql.NullInt64
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
|
||||
FROM event_categories WHERE id = ? AND event_id = ?`, categoryID, eventID).
|
||||
Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return categoryDTO{}, notFound("组别不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return categoryDTO{}, internal(err)
|
||||
}
|
||||
c.Gender = strPtr(gender)
|
||||
c.AgeMin = intPtr(ageMin)
|
||||
c.AgeMax = intPtr(ageMax)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
|
||||
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
|
||||
func validateCategoryAges(ageMin, ageMax *int) error {
|
||||
for _, v := range []*int{ageMin, ageMax} {
|
||||
if v != nil && (*v < 0 || *v > 120) {
|
||||
return badRequest("组别年龄需要在 0-120 之间", v)
|
||||
}
|
||||
}
|
||||
if ageMin != nil && ageMax != nil && *ageMin > *ageMax {
|
||||
return badRequest("组别年龄下限不能大于上限", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
|
||||
//
|
||||
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
|
||||
// 用 == 会把「原样提交」也当成改动、白重算一次全组积分。1e-9 远小于现场能填出的最小差
|
||||
// (权重最小刻度 0.1),又远大于浮点噪声。
|
||||
const multiplierRecomputeEpsilon = 1e-9
|
||||
|
||||
func multiplierChanged(before, after float64) bool {
|
||||
return math.Abs(before-after) > multiplierRecomputeEpsilon
|
||||
}
|
||||
|
||||
// updateCategory 就地更新单个组别,可改 name / multiplier / gender / ageMin / ageMax / sortOrder。
|
||||
//
|
||||
// 与 replaceCategories 的「DELETE 全表 + INSERT」不同,这里只发一条 UPDATE:组别 id 不变,
|
||||
// entries.category_id 以及它名下的成绩、流水、名次、积分全部原地保留。
|
||||
// 有报名数据的小项正是靠它改名 / 改年龄 / 改性别 / 改权重 / 改排序;增删组别仍然只能走整表替换。
|
||||
//
|
||||
// 只有 multiplier 真的变了才在同一个事务里重算该组别的流水与名次:权重进算分公式,
|
||||
// 不重算就会让已发布成绩的积分与榜单和配置静默不一致。name / gender / ageMin / ageMax /
|
||||
// sortOrder 都不参与算分,改了不重算(报名、成绩、流水一个字节都不动)。
|
||||
// 第二个返回值报告本次是否触发了重算,供审计留痕。
|
||||
func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64, in categoryDTO) (categories []categoryDTO, recomputed bool, err error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return nil, false, badRequest("组别 name 必填", nil)
|
||||
}
|
||||
if in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
|
||||
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
|
||||
}
|
||||
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// 先读旧值:要不要重算全靠这次比对,拿不到旧值就无从判断。
|
||||
before, err := s.getCategory(ctx, eventID, categoryID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
newMultiplier := multiplierOrOne(in.Multiplier)
|
||||
recompute := multiplierChanged(before.Multiplier, newMultiplier)
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// event_id 一起进 WHERE:id 存在但挂在别的小项上时影响 0 行,按「组别不存在」处理。
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE event_categories SET name = ?, multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ?
|
||||
WHERE id = ? AND event_id = ?`,
|
||||
name, newMultiplier, nullStr(in.Gender), nullInt(in.AgeMin), nullInt(in.AgeMax),
|
||||
in.SortOrder, categoryID, eventID)
|
||||
if isUniqueViolation(err) {
|
||||
return nil, false, conflict("组别名称重复:" + name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, internal(err)
|
||||
}
|
||||
if err := requireChanges(res, "组别不存在"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
actorID := middleware.User(ctx).ID
|
||||
if recompute {
|
||||
// 与新权重保持一致:作废旧流水、按新分数重建,并把名次写回 results(榜单看的就是它)。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "组别权重变更重算")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if recompute {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, internal(err)
|
||||
}
|
||||
cats, err := s.listCategories(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cats, recompute, nil
|
||||
}
|
||||
|
||||
// updateCategoryHandler 是 PUT /api/events/{id}/categories/{categoryId}:组别的就地更新。
|
||||
func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
eventID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categoryID, err := pathID(r, "categoryId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in categoryDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getCategory(r.Context(), eventID, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, recomputed, err := s.updateCategory(r.Context(), eventID, categoryID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := s.getCategory(r.Context(), eventID, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 审计要能看出这次是不是还动了权重:权重变更会连带重算已发布成绩的积分与榜单,
|
||||
// 事后查「榜为什么变了」时,光看新旧权重容易漏掉这一层。
|
||||
s.audit(r.Context(), "event.category.update", "event_category", categoryID, before,
|
||||
map[string]any{"category": after, "recomputedLedger": recomputed})
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// ---------- score channels ----------
|
||||
|
||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
@@ -35,10 +36,21 @@ func (in athleteImportRow) decisionKey() string {
|
||||
}
|
||||
|
||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
|
||||
type athleteImportMatch struct {
|
||||
AthleteID int64
|
||||
TeamID int64
|
||||
Conflict *importConflict
|
||||
AthleteID int64
|
||||
TeamID int64
|
||||
Conflict *importConflict
|
||||
SameNameNewPerson *sameNameNewPerson
|
||||
}
|
||||
|
||||
// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项:
|
||||
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
|
||||
type sameNameNewPerson struct {
|
||||
Name string `json:"name"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
NewBib string `json:"newBib"`
|
||||
ExistingBib string `json:"existingBib"`
|
||||
}
|
||||
|
||||
// existingAthlete 是库中已有人员的可比字段。
|
||||
@@ -53,35 +65,136 @@ type existingAthlete struct {
|
||||
Age *int
|
||||
}
|
||||
|
||||
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。
|
||||
// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。
|
||||
// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。
|
||||
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
existing, err := s.athleteByIdentity(ctx, in)
|
||||
switch {
|
||||
case err == nil:
|
||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return s.matchOrCreateAthleteByName(ctx, in, decisions)
|
||||
default:
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。
|
||||
func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) {
|
||||
if in.IDCard != "" {
|
||||
ex, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return ex, err
|
||||
if in.Bib != "" {
|
||||
ex, err := s.athleteByBib(ctx, in.Bib)
|
||||
switch {
|
||||
case err == nil:
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
}
|
||||
if in.Bib != "" {
|
||||
return s.athleteByBib(ctx, in.Bib)
|
||||
}
|
||||
return existingAthlete{}, sql.ErrNoRows
|
||||
return s.matchByNameTeam(ctx, in, decisions)
|
||||
}
|
||||
|
||||
// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段,
|
||||
// matchByNameTeam 处理「号码布为空、或按号码布没找到」的情况,按姓名 + 单位找候选:
|
||||
// 0 条 → 新建;1 条且对方没有号码布 → 命中并回填;1 条但对方号码布不同 → 同名的另一个人,新建并报告;
|
||||
// 多条 → 仍有歧义,进冲突面板等用户决策。
|
||||
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return s.createImportedAthlete(ctx, in)
|
||||
case 1:
|
||||
ex := candidates[0]
|
||||
// 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」:
|
||||
// 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。
|
||||
if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *ex.Bib != in.Bib {
|
||||
created, err := s.createImportedAthlete(ctx, in)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
created.SameNameNewPerson = &sameNameNewPerson{
|
||||
Name: in.Name,
|
||||
TeamCode: in.TeamCode,
|
||||
NewBib: in.Bib,
|
||||
ExistingBib: *ex.Bib,
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
default:
|
||||
return s.matchAmbiguousCandidates(ctx, in, candidates, decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
|
||||
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
|
||||
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
|
||||
func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportRow, candidates []existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||
ex := candidates[0]
|
||||
if _, decided := decisions[in.decisionKey()]; decided {
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in))
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
}
|
||||
|
||||
// createImportedAthlete 新建人员:证件号只作为可选档案信息写入,
|
||||
// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。
|
||||
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
|
||||
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
|
||||
switch {
|
||||
case isIDCardConflict(err):
|
||||
// 并发下唯一索引兜底:再查一次占用者,把「撞上谁」说清楚。
|
||||
if taken := s.idCardTakenError(ctx, in.IDCard, 0); taken != nil {
|
||||
return athleteImportMatch{}, taken
|
||||
}
|
||||
return athleteImportMatch{}, internal(err)
|
||||
case isBibConflict(err):
|
||||
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||
case err != nil:
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
||||
}
|
||||
|
||||
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
|
||||
// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁,
|
||||
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
|
||||
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
|
||||
if idCard == "" {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
name string
|
||||
bib sql.NullString
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT name, bib FROM athletes WHERE id_card = ? AND id <> ? ORDER BY id LIMIT 1`, idCard, excludeID).
|
||||
Scan(&name, &bib)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil
|
||||
case err != nil:
|
||||
return internal(err)
|
||||
}
|
||||
occupied := "未填"
|
||||
if bib.Valid && bib.String != "" {
|
||||
occupied = bib.String
|
||||
}
|
||||
return conflict(fmt.Sprintf("证件号 %s 已被另一个人占用(姓名 %s,号码布 %s)", idCard, name, occupied))
|
||||
}
|
||||
|
||||
// importRowMessage 把业务错误转成行级提示:去掉「CONFLICT:」这类 code 前缀,只留中文 message。
|
||||
func importRowMessage(err error) string {
|
||||
var api *apiError
|
||||
if errors.As(err, &api) && api.message != "" {
|
||||
return api.message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
||||
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||
changed := changedAthleteFields(ex, in)
|
||||
@@ -110,30 +223,6 @@ func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow,
|
||||
}
|
||||
}
|
||||
|
||||
// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况:
|
||||
// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。
|
||||
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
||||
}
|
||||
|
||||
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
|
||||
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
|
||||
|
||||
@@ -155,22 +244,30 @@ func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete,
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
|
||||
}
|
||||
|
||||
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
||||
}
|
||||
|
||||
// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。
|
||||
func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes
|
||||
WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
|
||||
ORDER BY id LIMIT 1`, name, teamID))
|
||||
// athletesByNameTeam 按「姓名 + 单位」列出全部候选,供调用方按候选数量决定是回填、新建还是进冲突面板。
|
||||
// 证件号不参与匹配,所以这里不再过滤证件号为空的人。
|
||||
func (s *server) athletesByNameTeam(ctx context.Context, name string, teamID int64) ([]existingAthlete, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? ORDER BY id`, name, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]existingAthlete, 0, 1)
|
||||
for rows.Next() {
|
||||
ex, err := scanExistingAthlete(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ex)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
|
||||
@@ -208,7 +305,15 @@ func identityDiffers(dbVal *string, inVal string) bool {
|
||||
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
|
||||
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
|
||||
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
|
||||
if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
|
||||
if isIDCardConflict(err) {
|
||||
if taken := s.idCardTakenError(ctx, in.IDCard, ex.ID); taken != nil {
|
||||
return taken
|
||||
}
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
@@ -261,10 +366,23 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
|
||||
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
|
||||
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
|
||||
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
|
||||
if in.IDCard != "" {
|
||||
if err := s.idCardTakenError(ctx, in.IDCard, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
|
||||
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
|
||||
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
|
||||
switch {
|
||||
case isIDCardConflict(err):
|
||||
if taken := s.idCardTakenError(ctx, in.IDCard, id); taken != nil {
|
||||
return taken
|
||||
}
|
||||
case isBibConflict(err):
|
||||
return conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
@@ -203,6 +205,11 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
if p.LiveOnPublish != nil {
|
||||
if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.DefaultProfileID != nil {
|
||||
code := ""
|
||||
@@ -263,6 +270,104 @@ func boolDefault(v string, def bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 系统级设置:登录有效期 ----------
|
||||
//
|
||||
// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的
|
||||
// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性,
|
||||
// 切换到另一个赛事项目时不该跟着变。
|
||||
|
||||
func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
return s.sessionSettings(r)
|
||||
}
|
||||
|
||||
func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in sessionSettingsPatch
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.SessionHours == nil {
|
||||
return nil, badRequest("登录有效期 sessionHours 必填", nil)
|
||||
}
|
||||
hours := *in.SessionHours
|
||||
if hours < auth.MinSessionHours || hours > auth.MaxSessionHours {
|
||||
return nil, badRequest(
|
||||
fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours)
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
if err := s.writeSessionHours(r.Context(), hours); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "settings.session_hours", "system", 0, nil,
|
||||
map[string]any{"sessionHours": hours})
|
||||
return s.sessionSettings(r)
|
||||
})
|
||||
}
|
||||
|
||||
// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。
|
||||
func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) {
|
||||
values, err := loadSettingsMap(r.Context(), s.sys)
|
||||
if err != nil {
|
||||
return dto.SessionSettings{}, err
|
||||
}
|
||||
return dto.SessionSettings{
|
||||
SessionHours: auth.EffectiveSessionHours(values),
|
||||
DefaultHours: auth.DefaultSessionHours,
|
||||
MinHours: auth.MinSessionHours,
|
||||
MaxHours: auth.MaxSessionHours,
|
||||
ExpiresAt: s.sessionExpiresAt(r),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。
|
||||
// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。
|
||||
func (s *server) sessionExpiresAt(r *http.Request) *string {
|
||||
c, err := r.Cookie(auth.CookieSession)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil
|
||||
}
|
||||
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
|
||||
if err != nil || sess == nil {
|
||||
return nil
|
||||
}
|
||||
v := sess.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
return &v
|
||||
}
|
||||
|
||||
// writeSessionHours 落库登录有效期并立刻在进程内生效。
|
||||
// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。
|
||||
func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
||||
tx, err := s.sys.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
upsert := 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
|
||||
}
|
||||
if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil {
|
||||
return err
|
||||
}
|
||||
// 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值,
|
||||
// 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。
|
||||
if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
// 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。
|
||||
s.auth.SetSessionTTL(time.Duration(hours) * time.Hour)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 大屏公告 ----------
|
||||
|
||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||||
@@ -340,6 +341,22 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id
|
||||
// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。
|
||||
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
|
||||
score, err := computeEventScore(ctx, tx, eventID, categoryID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
return score, nil
|
||||
}
|
||||
|
||||
// writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。
|
||||
//
|
||||
// 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落,
|
||||
// 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger,
|
||||
// 所以「先算后作废」与老的「先作废后算」结果完全一致。
|
||||
func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error {
|
||||
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
@@ -351,25 +368,21 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
`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)
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
score, err := computeEventScore(ctx, tx, eventID, categoryID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
ledger := score.Ledger
|
||||
if categoryID > 0 {
|
||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
return err
|
||||
}
|
||||
if hasAggregate {
|
||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
return err
|
||||
}
|
||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||
for _, l := range score.Ledger {
|
||||
@@ -390,10 +403,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
|
||||
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
|
||||
return domain.EventScore{}, internal(err)
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return score, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||
@@ -445,6 +458,106 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
|
||||
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
|
||||
|
||||
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
|
||||
// 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。
|
||||
func publishableResult(r domain.Result) bool {
|
||||
return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK
|
||||
}
|
||||
|
||||
// groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。
|
||||
//
|
||||
// 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩:
|
||||
// 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。
|
||||
func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) {
|
||||
byEntry := make(map[int64]domain.Result, len(results))
|
||||
for _, r := range results {
|
||||
byEntry[r.EntryID] = r
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Status != domain.EntryStatusRegistered {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) {
|
||||
published++
|
||||
}
|
||||
}
|
||||
return total, published
|
||||
}
|
||||
|
||||
// groupOf 从整项的行里挑出某个组别的报名与成绩。
|
||||
// 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。
|
||||
func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) {
|
||||
inGroup := make(map[int64]bool)
|
||||
groupEntries := make([]domain.Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.CategoryID != categoryID {
|
||||
continue
|
||||
}
|
||||
inGroup[e.ID] = true
|
||||
groupEntries = append(groupEntries, e)
|
||||
}
|
||||
groupResults := make([]domain.Result, 0, len(groupEntries))
|
||||
for _, r := range results {
|
||||
if inGroup[r.EntryID] {
|
||||
groupResults = append(groupResults, r)
|
||||
}
|
||||
}
|
||||
return groupEntries, groupResults
|
||||
}
|
||||
|
||||
// scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别,
|
||||
// categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。
|
||||
func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) {
|
||||
entries, err := loadEntriesQ(ctx, q, eventID, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
results, err := loadResultsQ(ctx, q, eventID, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
categories := []int64{categoryID}
|
||||
if categoryID == 0 {
|
||||
cats, err := loadCategoriesQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
categories = make([]int64, 0, len(cats))
|
||||
for _, cat := range cats {
|
||||
categories = append(categories, cat.ID)
|
||||
}
|
||||
}
|
||||
for _, id := range categories {
|
||||
groupEntries, groupResults := groupOf(entries, results, id)
|
||||
t, p := groupStats(groupEntries, groupResults)
|
||||
total += t
|
||||
published += p
|
||||
}
|
||||
return total, published, nil
|
||||
}
|
||||
|
||||
// scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。
|
||||
// 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。
|
||||
func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) {
|
||||
total, published, err := scopeStats(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return published == total, nil
|
||||
}
|
||||
|
||||
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
|
||||
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
|
||||
var raw string
|
||||
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, internal(err)
|
||||
}
|
||||
return boolDefault(raw, true), nil
|
||||
}
|
||||
|
||||
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return nil, badRequest("eventId 必填", nil)
|
||||
@@ -470,7 +583,8 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
|
||||
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
|
||||
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
|
||||
// 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。
|
||||
// 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」
|
||||
// (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。
|
||||
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE event_id = ? AND ` + publishableResultCond
|
||||
@@ -483,12 +597,33 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算")
|
||||
live, err := liveOnPublishQ(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return nil, err
|
||||
settle := live
|
||||
if !live {
|
||||
// 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。
|
||||
// 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。
|
||||
settle, err = scopeSettled(ctx, tx, in.EventID, catID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
|
||||
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
||||
score, err := computeEventScore(ctx, tx, in.EventID, catID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if catID == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
|
||||
@@ -506,8 +641,11 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return nil, err
|
||||
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
|
||||
if settle {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -563,6 +701,9 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
|
||||
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
|
||||
}
|
||||
@@ -643,12 +784,28 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
||||
if err != nil {
|
||||
// withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算;
|
||||
// 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。
|
||||
settle := true
|
||||
if live, err := liveOnPublishQ(ctx, tx); err != nil {
|
||||
return resultDTO{}, err
|
||||
} else if !live {
|
||||
settle, err = scopeSettled(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
if settle {
|
||||
score, err := computeEventScore(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
}
|
||||
|
||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||
@@ -658,8 +815,10 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
if settle {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
@@ -669,6 +828,119 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
return s.getResult(ctx, resultID)
|
||||
}
|
||||
|
||||
// ---------- 分批结算 ----------
|
||||
|
||||
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
|
||||
// Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。
|
||||
type settlementGroupDTO struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Required int `json:"required"`
|
||||
Published int `json:"published"`
|
||||
}
|
||||
|
||||
// settlementStatusDTO 是 GET /api/results/settlement 的线上结构。
|
||||
type settlementStatusDTO struct {
|
||||
LiveOnPublish bool `json:"liveOnPublish"`
|
||||
Groups []settlementGroupDTO `json:"groups"`
|
||||
}
|
||||
|
||||
// settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。
|
||||
// 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。
|
||||
func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) {
|
||||
out := settlementStatusDTO{Groups: []settlementGroupDTO{}}
|
||||
live, err := liveOnPublishQ(ctx, s.ev)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.LiveOnPublish = live
|
||||
if live {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
categories, err := loadCategoriesQ(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
entries, err := loadEntriesQ(ctx, s.ev, eventID, 0)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
results, err := loadResultsQ(ctx, s.ev, eventID, 0)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, cat := range categories {
|
||||
if catID > 0 && cat.ID != catID {
|
||||
continue
|
||||
}
|
||||
groupEntries, groupResults := groupOf(entries, results, cat.ID)
|
||||
required, published := groupStats(groupEntries, groupResults)
|
||||
// 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。
|
||||
if required == 0 || published == 0 || published == required {
|
||||
continue
|
||||
}
|
||||
out.Groups = append(out.Groups, settlementGroupDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: cat.ID,
|
||||
CategoryName: cat.Name,
|
||||
Required: required,
|
||||
Published: published,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。
|
||||
//
|
||||
// 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched),
|
||||
// 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。
|
||||
func (s *server) settleResults(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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
s.audit(ctx, "result.settle", "event", in.EventID, before, after)
|
||||
return s.resultsByEvent(ctx, in.EventID)
|
||||
}
|
||||
|
||||
// ---------- 变更日志与版本 ----------
|
||||
|
||||
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
|
||||
|
||||
@@ -197,6 +197,11 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
|
||||
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
|
||||
|
||||
// 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用;
|
||||
// 它决定所有人的会话寿命,所以读写都限 admin。
|
||||
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
|
||||
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
|
||||
|
||||
// 基础配置
|
||||
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
|
||||
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
|
||||
@@ -218,6 +223,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
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("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
|
||||
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
|
||||
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
|
||||
|
||||
@@ -231,17 +238,25 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
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.write(auth.RoleRecorder, s.deleteAthleteHandler))
|
||||
// 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。
|
||||
mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler))
|
||||
|
||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
||||
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
||||
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
|
||||
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
|
||||
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
|
||||
|
||||
// 成绩
|
||||
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))
|
||||
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
|
||||
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
|
||||
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
|
||||
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
|
||||
|
||||
// 积分与排名
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
)
|
||||
|
||||
// ---------- 登录有效期(系统级设置)的测试脚手架 ----------
|
||||
|
||||
type sessionSettingsDTO struct {
|
||||
SessionHours int `json:"sessionHours"`
|
||||
DefaultHours int `json:"defaultSessionHours"`
|
||||
MinHours int `json:"minHours"`
|
||||
MaxHours int `json:"maxHours"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
// sysSetting 读系统库 settings 里的键。
|
||||
func sysSetting(t *testing.T, env *testEnv, key string) (string, bool) {
|
||||
t.Helper()
|
||||
var v string
|
||||
err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("读系统设置 %s: %v", key, err)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// sessionExpiry 取某用户当前会话的到期时间(测试里每个用户只保留一个会话)。
|
||||
func sessionExpiry(t *testing.T, env *testEnv, username string) time.Time {
|
||||
t.Helper()
|
||||
var raw string
|
||||
if err := env.sys.QueryRow(
|
||||
`SELECT s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
|
||||
username).Scan(&raw); err != nil {
|
||||
t.Fatalf("读 %s 的会话到期时间: %v", username, err)
|
||||
}
|
||||
at, err := time.Parse(time.DateTime, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析到期时间 %q: %v", raw, err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// sessionLastSeen 取某用户当前会话的活动时间。
|
||||
func sessionLastSeen(t *testing.T, env *testEnv, username string) time.Time {
|
||||
t.Helper()
|
||||
var raw string
|
||||
if err := env.sys.QueryRow(
|
||||
`SELECT COALESCE(s.last_seen_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
|
||||
username).Scan(&raw); err != nil {
|
||||
t.Fatalf("读 %s 的会话活动时间: %v", username, err)
|
||||
}
|
||||
at, err := time.Parse(time.DateTime, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析活动时间 %q: %v", raw, err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
func setSessionExpiry(t *testing.T, env *testEnv, username string, at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := env.sys.Exec(
|
||||
`UPDATE sessions SET expires_at = ?
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = ?)`,
|
||||
at.UTC().Format(time.DateTime), username); err != nil {
|
||||
t.Fatalf("改 %s 的会话到期时间: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
func adminClient(t *testing.T, env *testEnv) *http.Client {
|
||||
t.Helper()
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
return client
|
||||
}
|
||||
|
||||
// ---------- 默认值与落库 ----------
|
||||
|
||||
// 面板默认 24 小时:迁移种下的历史占位值 12 从没被任何代码读过,按「未配置」处理;
|
||||
// 写完之后落到系统库,重启(新建 Service)也读得到。
|
||||
func TestSessionSettingsDefaultsAndWrite(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := adminClient(t, env)
|
||||
|
||||
status, body := env.do(client, http.MethodGet, "/api/system/session", nil, false)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("读取登录有效期应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
got := decodeData[sessionSettingsDTO](t, body)
|
||||
if got.SessionHours != 24 || got.DefaultHours != 24 || got.MinHours != 1 || got.MaxHours != 720 {
|
||||
t.Fatalf("默认值/范围不符:%+v", got)
|
||||
}
|
||||
if got.ExpiresAt == nil || !strings.HasSuffix(*got.ExpiresAt, "Z") {
|
||||
t.Fatalf("应回带当前会话的到期时间(RFC3339 UTC),得到 %v", got.ExpiresAt)
|
||||
}
|
||||
if v, ok := sysSetting(t, env, auth.SessionHoursKey); !ok || v != "12" {
|
||||
t.Fatalf("初始库里应是迁移种下的 12,得到 %q(存在=%v)", v, ok)
|
||||
}
|
||||
|
||||
status, body = env.do(client, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 72}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("写入应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if got := decodeData[sessionSettingsDTO](t, body); got.SessionHours != 72 {
|
||||
t.Fatalf("应回显 72,得到 %+v", got)
|
||||
}
|
||||
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "72" {
|
||||
t.Fatalf("落库应为 72,得到 %q", v)
|
||||
}
|
||||
// 来源标记:有这个键,读取时就不会把值当成迁移占位值。
|
||||
if v, _ := sysSetting(t, env, auth.SessionHoursSourceKey); v != auth.SessionHoursSourcePanel {
|
||||
t.Fatalf("应登记来源 %q,得到 %q", auth.SessionHoursSourcePanel, v)
|
||||
}
|
||||
|
||||
// 重启等价物:新起一个 Service 读同一个库,仍是 72h。
|
||||
restarted := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
|
||||
if restarted.SessionTTL() != 72*time.Hour {
|
||||
t.Fatalf("重启后应仍读 72h,得到 %s", restarted.SessionTTL())
|
||||
}
|
||||
}
|
||||
|
||||
// 非法值一律 400 + 中文提示,且不落库;读写都是 admin-only。
|
||||
func TestSessionSettingsValidationAndPermission(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := adminClient(t, env)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"缺字段", map[string]any{}},
|
||||
{"过小", map[string]any{"sessionHours": 0}},
|
||||
{"负数", map[string]any{"sessionHours": -3}},
|
||||
{"过大", map[string]any{"sessionHours": 721}},
|
||||
} {
|
||||
status, body := env.do(client, http.MethodPut, "/api/system/session", tc.body, true)
|
||||
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, body.Error)
|
||||
}
|
||||
if !strings.Contains(body.Error.Message, "登录有效期") {
|
||||
t.Fatalf("%s 的中文提示应说明是登录有效期,得到 %q", tc.name, body.Error.Message)
|
||||
}
|
||||
}
|
||||
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "12" {
|
||||
t.Fatalf("非法值不该落库,库里是 %q", v)
|
||||
}
|
||||
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
recorder, _ := env.client()
|
||||
env.login(recorder, "recorder", "recorder-pass")
|
||||
if status, _ := env.do(recorder, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 读登录有效期应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(recorder, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 1}, true); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 写登录有效期应 403,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 滑动续期 ----------
|
||||
|
||||
// 会话只要还在被使用就顺延;节流生效时不会每次请求都写 expires_at。
|
||||
func TestSlidingRenewalThrottledOverHTTP(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := adminClient(t, env)
|
||||
|
||||
// 模拟「已经消耗掉大半寿命」:剩 23h58m(不足 24h-1m 的节流窗口)。
|
||||
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(23*time.Hour+58*time.Minute))
|
||||
before := sessionExpiry(t, env, "admin")
|
||||
|
||||
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("请求应成功,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
renewed := sessionExpiry(t, env, "admin")
|
||||
if diff := renewed.Sub(before); diff < time.Minute || diff > 3*time.Minute {
|
||||
t.Fatalf("应把到期时间顺延约 2 分钟(到 now+24h),实际 %s → %s(差 %s)",
|
||||
before.Format(time.DateTime), renewed.Format(time.DateTime), diff)
|
||||
}
|
||||
|
||||
// 紧接着的多次请求都落在节流窗口内:expires_at 与 last_seen_at 都一动不动
|
||||
// (改动前是每个请求写一次 last_seen_at,现在最多每分钟一次)。
|
||||
seen := sessionLastSeen(t, env, "admin")
|
||||
for i := 0; i < 5; i++ {
|
||||
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
|
||||
t.Fatalf("第 %d 次请求应成功,得到 %d", i, status)
|
||||
}
|
||||
if got := sessionExpiry(t, env, "admin"); !got.Equal(renewed) {
|
||||
t.Fatalf("节流窗口内不该再写 expires_at:%s → %s",
|
||||
renewed.Format(time.DateTime), got.Format(time.DateTime))
|
||||
}
|
||||
if got := sessionLastSeen(t, env, "admin"); !got.Equal(seen) {
|
||||
t.Fatalf("节流窗口内不该再写 last_seen_at:%s → %s",
|
||||
seen.Format(time.DateTime), got.Format(time.DateTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 改设置不动在线的人:旧会话的 expires_at 不变、请求照常成功,下一次使用时按新有效期延长。
|
||||
func TestSessionHoursChangeKeepsOnlineSessions(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
boss := adminClient(t, env)
|
||||
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
|
||||
t.Fatalf("先设成 1 小时应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
worker, _ := env.client()
|
||||
env.login(worker, "recorder", "recorder-pass")
|
||||
before := sessionExpiry(t, env, "recorder")
|
||||
|
||||
// 管理员把有效期改成 72 小时。
|
||||
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 72}, true); status != http.StatusOK {
|
||||
t.Fatalf("改成 72 小时应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if after := sessionExpiry(t, env, "recorder"); !after.Equal(before) {
|
||||
t.Fatalf("改设置不该动已登录会话的 expires_at:%s → %s",
|
||||
before.Format(time.DateTime), after.Format(time.DateTime))
|
||||
}
|
||||
|
||||
// 旧会话仍被认,而且拿到的是新的(更长的)到期时间。
|
||||
status, body := env.do(worker, http.MethodGet, "/api/auth/me", nil, false)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("旧会话不该被踢下线,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
me := decodeData[map[string]any](t, body)
|
||||
if me["role"] != "recorder" {
|
||||
t.Fatalf("旧会话应仍是 recorder 身份,得到 %v", me["role"])
|
||||
}
|
||||
if me["sessionExpiresAt"] == nil {
|
||||
t.Fatal("me 应回带当前会话的到期时间")
|
||||
}
|
||||
after := sessionExpiry(t, env, "recorder")
|
||||
if diff := after.Sub(before); diff < 70*time.Hour {
|
||||
t.Fatalf("下一次使用应按新有效期顺延到 ~72h,实际 %s → %s",
|
||||
before.Format(time.DateTime), after.Format(time.DateTime))
|
||||
}
|
||||
}
|
||||
|
||||
// 到期校验不能被改坏:手工把 expires_at 改到 1 分钟前,写请求必须 401 AUTH_REQUIRED。
|
||||
func TestExpiredSessionReturnsAuthRequired(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := adminClient(t, env)
|
||||
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(-time.Minute))
|
||||
|
||||
status, body := env.do(client, http.MethodPut, "/api/settings",
|
||||
map[string]any{"event": map[string]any{"name": "秋季运动会"}}, true)
|
||||
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("过期会话写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 安全口径没有被放宽 ----------
|
||||
|
||||
func TestSessionSettingsKeepsSecurityPosture(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
|
||||
// 登录下发的 Cookie 仍带 HttpOnly + SameSite=Lax;CSRF Cookie 仍可被前端读取。
|
||||
resp, err := http.Post(env.server.URL+"/api/auth/login", "application/json",
|
||||
strings.NewReader(`{"username":"admin","password":"admin-pass"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("登录: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var session, csrf *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
switch c.Name {
|
||||
case auth.CookieSession:
|
||||
session = c
|
||||
case auth.CookieCSRF:
|
||||
csrf = c
|
||||
}
|
||||
}
|
||||
if session == nil || !session.HttpOnly || session.SameSite != http.SameSiteLaxMode || session.Secure {
|
||||
t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax:%+v", session)
|
||||
}
|
||||
if csrf == nil || csrf.HttpOnly {
|
||||
t.Fatalf("CSRF Cookie 应可被前端读取(非 HttpOnly):%+v", csrf)
|
||||
}
|
||||
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
// CSRF 双提交仍然拦写:不带 X-CSRF-Token 的写请求一律 403。
|
||||
if status, body := env.do(client, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 2}, false); status != http.StatusForbidden ||
|
||||
body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
||||
t.Fatalf("缺 CSRF 头的写请求应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
// must_change_password 仍然拦写操作。
|
||||
env.createUser("fresh", "fresh-pass", "admin")
|
||||
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE username = 'fresh'`); err != nil {
|
||||
t.Fatalf("置 must_change_password: %v", err)
|
||||
}
|
||||
fresh, _ := env.client()
|
||||
env.login(fresh, "fresh", "fresh-pass")
|
||||
if status, body := env.do(fresh, http.MethodPut, "/api/system/session",
|
||||
map[string]any{"sessionHours": 2}, true); status != http.StatusForbidden ||
|
||||
body.Error == nil || body.Error.Message != "请先修改初始密码" {
|
||||
t.Fatalf("待改密账号的写请求应被拦下,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
// 未登录访客读不到登录有效期。
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("访客读登录有效期应 403,得到 %d", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// 分批结算的判据是纯函数,这里逐类钉住它的口径:
|
||||
// 「所有批次都录完」= 该组所有 registered 报名项都有可发布的已发布成绩。
|
||||
func TestGroupStatsSettlementJudgement(t *testing.T) {
|
||||
value := 11.58
|
||||
cases := []struct {
|
||||
name string
|
||||
entries []domain.Entry
|
||||
results []domain.Result
|
||||
wantTotal int
|
||||
wantPublished int
|
||||
}{
|
||||
{
|
||||
name: "全部发完",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
},
|
||||
wantTotal: 2,
|
||||
wantPublished: 2,
|
||||
},
|
||||
{
|
||||
name: "有人弃权(scratched 不该再等它)",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||
{ID: 3, Status: domain.EntryStatusScratched},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
},
|
||||
wantTotal: 2,
|
||||
wantPublished: 2,
|
||||
},
|
||||
{
|
||||
name: "有人失格(dq/dns 同样已有现场结论)",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||
{ID: 3, Status: domain.EntryStatusDNS},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
},
|
||||
wantTotal: 2,
|
||||
wantPublished: 2,
|
||||
},
|
||||
{
|
||||
name: "有草稿但不该发布(还没跑 / 还没录)",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
{ID: 2, Status: domain.EntryStatusRegistered},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
|
||||
{EntryID: 2, Status: domain.ResultStatusOK, State: domain.ResultStateDraft},
|
||||
},
|
||||
wantTotal: 2,
|
||||
wantPublished: 1,
|
||||
},
|
||||
{
|
||||
name: "已发布但空且 ok 的行不算成绩",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusOK, State: domain.ResultStatePublished},
|
||||
},
|
||||
wantTotal: 1,
|
||||
wantPublished: 0,
|
||||
},
|
||||
{
|
||||
name: "DNS 这类没有成绩值的现场结论可以发布",
|
||||
entries: []domain.Entry{
|
||||
{ID: 1, Status: domain.EntryStatusRegistered},
|
||||
},
|
||||
results: []domain.Result{
|
||||
{EntryID: 1, Status: domain.ResultStatusDNS, State: domain.ResultStatePublished},
|
||||
},
|
||||
wantTotal: 1,
|
||||
wantPublished: 1,
|
||||
},
|
||||
{
|
||||
name: "整组弃权:没有要等的人",
|
||||
entries: []domain.Entry{{ID: 1, Status: domain.EntryStatusScratched}},
|
||||
results: nil,
|
||||
wantTotal: 0,
|
||||
wantPublished: 0,
|
||||
},
|
||||
{
|
||||
name: "没有报名项",
|
||||
entries: nil,
|
||||
results: nil,
|
||||
wantTotal: 0,
|
||||
wantPublished: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
total, published := groupStats(tc.entries, tc.results)
|
||||
if total != tc.wantTotal || published != tc.wantPublished {
|
||||
t.Fatalf("groupStats = (%d, %d),期望 (%d, %d)", total, published, tc.wantTotal, tc.wantPublished)
|
||||
}
|
||||
got := published == total
|
||||
want := tc.wantPublished == tc.wantTotal
|
||||
if got != want {
|
||||
t.Fatalf("结算判据 = %v,期望 %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// groupOf 必须只留下该组别的行:成绩表不带组别,靠报名 id 反查时最容易把别组的成绩算进来。
|
||||
func TestGroupOfFiltersByCategory(t *testing.T) {
|
||||
entries := []domain.Entry{
|
||||
{ID: 1, CategoryID: 7},
|
||||
{ID: 2, CategoryID: 7},
|
||||
{ID: 3, CategoryID: 8},
|
||||
}
|
||||
results := []domain.Result{
|
||||
{ID: 11, EntryID: 1},
|
||||
{ID: 12, EntryID: 2},
|
||||
{ID: 13, EntryID: 3},
|
||||
}
|
||||
|
||||
groupEntries, groupResults := groupOf(entries, results, 7)
|
||||
if len(groupEntries) != 2 || len(groupResults) != 2 {
|
||||
t.Fatalf("第 7 组应只剩 2 条报名 / 2 条成绩,得到 %d / %d", len(groupEntries), len(groupResults))
|
||||
}
|
||||
for _, r := range groupResults {
|
||||
if r.EntryID == 3 {
|
||||
t.Fatalf("别组的成绩不该被算进来: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
||||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
var rows []map[string]any
|
||||
|
||||
Reference in new issue
Block a user