fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

+159 -36
View File
@@ -52,10 +52,23 @@ const (
// touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。
// 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。
touchInterval = time.Minute
// sessionPurgeInterval 是「清理过期会话行」的最小间隔。
// sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存),需要一个清理动作;
// 单实例部署不值得为它常开一个 goroutine(还要在 main 里管生命周期),
// 于是挂在「查找会话」这条登录后必经的路径上按间隔做一次:只要有人在线,清理就不会停。
sessionPurgeInterval = time.Hour
// DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。
DefaultBcryptCost = 12
// DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。
DefaultLoginPerMinute = 5
// LoginRateKey 是系统库 settings 里存登录限流的键。0005 迁移种下 5,
// 但在本项改动之前全仓没有一行代码读它——现场按「系统设置」改这个键不起作用,
// 限流实际上只由编译期常量决定,属死配置。
LoginRateKey = "security.login_rate_per_minute"
// MinLoginPerMinute / MaxLoginPerMinute 是系统库那个键的可接受范围。
// 上限卡在 60:这是防爆破的闸门,配成几千等于把它关掉;越界或非法值按「未配置」回落默认。
MinLoginPerMinute = 1
MaxLoginPerMinute = 60
// DefaultWritePerMinute 写操作限流:120 次/分钟/用户。
//
// 原来定 30,现场一次批量导入 / 批量删除跑完就会撞上(第 7 次起全是 429),
@@ -90,6 +103,10 @@ type User struct {
}
// Session 是一次登录产生的服务端会话。
//
// IP / UserAgent 目前除本包外无人读(面板还没有「在线会话」视图),但保留写入:
// 排查「这条会话是哪个 IP / 哪台设备开的」时它们才是唯一线索,
// 而留存本身有上界——Lookup 里的过期行清理会把超过会话寿命的行连同这两列一起删掉(见 purgeExpiredSessions)。
type Session struct {
ID string
UserID int64
@@ -104,8 +121,14 @@ type Options struct {
// SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值:
// 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准;
// 这里只在系统库里没有配置时兜底(例如测试用的空库)。
SessionTTL time.Duration
BcryptCost int
//
// 注意:生产构造点(main.go)不传这个字段,也没有对应的启动参数 / 配置文件字段——
// devdocs/05 里「面板 > 启动参数 > 内置默认」的中间那一档目前只存在于本字段,
// 实际只有测试在用(P2-C13)。要把它变成真的启动参数得先在 main.go 加 flag。
SessionTTL time.Duration
BcryptCost int
// LoginPerMinute 显式指定登录限流(测试 / 嵌入用);优先级同 SessionTTL:
// 系统库 settings 里的 security.login_rate_per_minute 优先,本字段只在库里没有有效值时生效。
LoginPerMinute int
WritePerMinute int
// Secure 为 true 时 Cookie 带 Secure 标记(HTTPS 部署)。
@@ -123,7 +146,10 @@ type Service struct {
now func() time.Time
// ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL),
// 所以每个请求都会读它,必须用原子量而不是普通字段。
ttl atomic.Int64
ttl atomic.Int64
// lastPurge 是上一次清理过期会话行的时刻(Unix 纳秒,0 = 还没清理过)。
// 请求会并发走到 Lookup,节流判断与「谁来做这次清理」都必须原子地一次完成。
lastPurge atomic.Int64
loginLimiter *Limiter
writeLimiter *Limiter
}
@@ -151,9 +177,13 @@ func New(db *sql.DB, opts Options) *Service {
if now == nil {
now = time.Now
}
loginLimit := opts.LoginPerMinute
if loginLimit == 0 {
loginLimit = DefaultLoginPerMinute
loginLimit, loginSource := DefaultLoginPerMinute, "内置默认"
// 与登录有效期同一条优先级:系统库设置 > 启动参数 > 内置默认。
// 0005 迁移早就种下了 security.login_rate_per_minute,但直到现在才有人读它。
if v, ok := loginRateFromSettings(db); ok {
loginLimit, loginSource = v, "系统设置"
} else if opts.LoginPerMinute > 0 {
loginLimit, loginSource = opts.LoginPerMinute, "启动参数"
}
writeLimit := opts.WritePerMinute
if writeLimit == 0 {
@@ -161,6 +191,7 @@ func New(db *sql.DB, opts Options) *Service {
}
logger.Info("登录有效期", "hours", ttl.Hours(), "source", source,
"滑动续期", true, "节流窗口", renewThreshold.String())
logger.Info("登录限流", "perMinute", loginLimit, "source", loginSource)
s := &Service{
db: db,
log: logger,
@@ -240,6 +271,31 @@ func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) {
return time.Duration(EffectiveSessionHours(values)) * time.Hour, true
}
// loginRateFromSettings 从系统库 settings 读登录限流(次/分钟/IP)。
// 缺键、非法或越界一律 ok=false,由调用方回落到启动参数 / 内置默认——
// 这个键是防爆破的闸门,配错了宁可用默认值,也不能因为库里写了个 0 就把闸门拿掉。
func loginRateFromSettings(db *sql.DB) (int, bool) {
if db == nil {
return 0, false
}
var raw string
switch err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, LoginRateKey).Scan(&raw); {
case errors.Is(err, sql.ErrNoRows):
return 0, false
case err != nil:
slog.Warn("读取登录限流失败,本次用默认值", "err", err, "perMinute", DefaultLoginPerMinute)
return 0, false
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < MinLoginPerMinute || n > MaxLoginPerMinute {
slog.Warn("登录限流配置非法,本次用默认值",
"value", raw, "perMinute", DefaultLoginPerMinute,
"min", MinLoginPerMinute, "max", MaxLoginPerMinute)
return 0, false
}
return n, true
}
// AllowLogin 按 IP 限流登录。
func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) }
@@ -282,6 +338,8 @@ func (s *Service) EnsureDefaultAdmin(ctx context.Context) (username, password st
func RandomPassword(n int) (string, error) { return randomPassword(n) }
// SetPassword 重置用户密码并作废其全部会话;mustChange 表示下次登录后强制改密。
//
// 「改密」与「作废会话」必须同成同败,因此两条语句进同一个事务(见 withUserSessionRevoke)。
func (s *Service) SetPassword(ctx context.Context, username, password string, mustChange bool) error {
if password == "" {
return fmt.Errorf("密码不能为空")
@@ -297,43 +355,67 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu
if mustChange {
must = 1
}
res, err := s.db.ExecContext(ctx,
`UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = datetime('now') WHERE username = ?`,
string(hash), must, username)
if err != nil {
return fmt.Errorf("更新密码: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", username)
}
var id int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, username).Scan(&id); err != nil {
return fmt.Errorf("查询用户: %w", err)
}
return s.RevokeUserSessions(ctx, id)
return s.withUserSessionRevoke(ctx, username, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = datetime('now') WHERE username = ?`,
string(hash), must, username)
if err != nil {
return fmt.Errorf("更新密码: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", username)
}
return nil
})
}
// SetUsername 修改用户名(供本机管理入口免登录调用),并作废该用户全部会话。
// 与 SetPassword 同一口径:改名与撤会话要么都成、要么都不成。
func (s *Service) SetUsername(ctx context.Context, oldUsername, newUsername string) error {
if newUsername == "" {
return fmt.Errorf("新用户名不能为空")
}
res, err := s.db.ExecContext(ctx,
`UPDATE users SET username = ?, updated_at = datetime('now') WHERE username = ?`,
newUsername, oldUsername)
if err != nil {
return fmt.Errorf("更新用户名: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", oldUsername)
}
return s.withUserSessionRevoke(ctx, newUsername, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE users SET username = ?, updated_at = datetime('now') WHERE username = ?`,
newUsername, oldUsername)
if err != nil {
return fmt.Errorf("更新用户名: %w", err)
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return fmt.Errorf("用户 %s 不存在", oldUsername)
}
return nil
})
}
var id int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, newUsername).Scan(&id); err != nil {
return fmt.Errorf("查询用户: %w", err)
// withUserSessionRevoke 在一个事务里执行 fn(改用户行)并删掉该用户(改名后用 newName 定位)的全部会话行。
//
// 为什么要事务:改密 / 改名与「作废会话」是同一个安全动作的两半,中间态是危险的——
// 撤会话失败而密码已改 = 「密码改了,但旧会话(可能正是被盗用的那个)仍然全有效」,
// 用户以为已经止血、实际没有;反过来先撤会话再改密失败 = 人全被踢下线却没改成密码。
// 事务保证外部只看得到两种结果:全做了,或什么都没做(此时旧密码与旧会话都还在,语义自洽)。
//
// system.db 的连接池上限是 1(store.Open),事务在提交前独占该连接,两条语句不会被别的请求插进来。
func (s *Service) withUserSessionRevoke(ctx context.Context, username string, fn func(*sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开启事务: %w", err)
}
return s.RevokeUserSessions(ctx, id)
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
// 用子查询定位 user_id:用户名唯一,省掉一次「读回 id」的往返(读了也是同一事务内的快照)。
if _, err := tx.ExecContext(ctx,
`DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE username = ?)`, username); err != nil {
return fmt.Errorf("作废会话: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务: %w", err)
}
return nil
}
// VerifyPassword 校验指定用户的原密码,不创建会话(供「修改密码」等场景使用)。
@@ -436,6 +518,10 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
sess.ExpiresAt = exp
u.Role = ParseRole(role)
// 过期会话行顺手清一次(节流见 sessionPurgeInterval)。放在续期之前:
// 续期那条分支会提前 return,放后面就永远轮不到它。
s.purgeExpiredSessions(ctx, now)
// 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期,
// 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。
//
@@ -444,13 +530,22 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
// 少顺延这 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)
if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID); err != nil {
// 写库失败时不能只顺延内存里的到期时间:/api/auth/me 会拿 sess.ExpiresAt 重发 Cookie,
// 于是浏览器被告知「还有一整天」,库里却还是旧时刻——用户会在毫无征兆的情况下被登出,
// 排查时「服务端说没过期、库里已经过期」两边对不上。
// 这里的取舍是「放弃本次续期」:本次请求照常放行(会话此刻仍然有效),
// 库与内存保持同一个到期时间,等下一次请求再试。
s.log.Warn("会话续期写库失败,本次不续期", "err", err, "sessionId", sess.ID)
return &u, &sess, nil
}
sess.ExpiresAt = renewed
return &u, &sess, nil
}
// 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。
// 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。
// 这条写失败无所谓:last_seen_at 只用于排障,不影响「会话还活着」的判定。
lastSeen, err := parseDBTime(lastSeenRaw)
if err != nil || now.Sub(lastSeen) >= touchInterval {
_, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
@@ -459,6 +554,34 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er
return &u, &sess, nil
}
// purgeExpiredSessions 删掉已过期的会话行。
//
// sessions 只增不减:每次登录留一行,过期行连同 token 哈希永久躺在库里(含 ip / user_agent),
// 这是无界增长也是个人信息留存。迁移里早就有 idx_sessions_expires_at 索引,就是为这件事准备的。
// 节流到 sessionPurgeInterval 一次,且只在「有人带着会话访问」时触发——
// 没人登录就没有新会话行,也就不需要清理。
// 失败不吞:写 WARN,并把节流时间戳清掉,让下一次请求立刻重试。
func (s *Service) purgeExpiredSessions(ctx context.Context, now time.Time) {
last := s.lastPurge.Load()
if last != 0 && now.Sub(time.Unix(0, last)) < sessionPurgeInterval {
return
}
// CAS 只有一个请求能拿到「本次清理」的资格,其余请求直接跳过(并发下只删一次)。
if !s.lastPurge.CompareAndSwap(last, now.UnixNano()) {
return
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM sessions WHERE expires_at <= ?`, now.UTC().Format(time.DateTime))
if err != nil {
s.lastPurge.Store(0)
s.log.Warn("清理过期会话失败", "err", err)
return
}
if n, err := res.RowsAffected(); err == nil && n > 0 {
s.log.Info("清理过期会话", "rows", n)
}
}
// Logout 删除会话(服务端强制下线)。
func (s *Service) Logout(ctx context.Context, token string) error {
if strings.TrimSpace(token) == "" {
+132
View File
@@ -0,0 +1,132 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// refreshResult 是一次 RefreshSessionCookies 写出的 Cookie(按名字索引)。
func refreshResult(t *testing.T, svc *Service, sessionToken, csrfToken string, expiresAt time.Time) map[string]*http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
svc.RefreshSessionCookies(rec, sessionToken, csrfToken, expiresAt)
out := map[string]*http.Cookie{}
for _, c := range rec.Result().Cookies() {
out[c.Name] = c
}
return out
}
// 滑动续期之后必须重发 Cookie,而且 Max-Age 要跟着「会话当前还剩多久」走:
//
// Max-Age 是浏览器端的硬期限。写成完整有效期(登录时那个固定数)时,
// 真出现「会话剩余寿命与有效期不一致」的时刻(有效期被改短、会话是旧口径建的),
// 浏览器就会在服务端仍然认这条会话的时候把 Cookie 丢掉 —— 用户毫无征兆地被登出,
// 而服务端日志上看这条会话一直是活的。这条之前的 0 覆盖正是这个承诺没人守。
func TestRefreshSessionCookiesMaxAgeFollowsRemainingLife(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
db := newSettingsDB(t, nil)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
Now: func() time.Time { return now }})
// ① 续期恰好到「now + 24h」:Max-Age 是 24h 的秒数。
got := refreshResult(t, svc, "sess-token", "csrf-token", now.Add(24*time.Hour))
sess, csrf := got[CookieSession], got[CookieCSRF]
if sess == nil || csrf == nil {
t.Fatalf("续期必须同时重发会话与 CSRF Cookie,得到 %v", got)
}
if sess.Value != "sess-token" || csrf.Value != "csrf-token" {
t.Fatalf("Cookie 值必须沿用原值(CSRF 不轮换,避免和飞行中的写请求打架),得到 %q / %q",
sess.Value, csrf.Value)
}
if want := 24 * 60 * 60; sess.MaxAge != want || csrf.MaxAge != want {
t.Fatalf("Max-Age 应为剩余寿命 %d 秒,得到会话 %d / CSRF %d", want, sess.MaxAge, csrf.MaxAge)
}
// ② 关键对照:会话只剩 30 分钟时,Max-Age 必须是 1800,而不是有效期的 86400。
got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(30*time.Minute))
if got[CookieSession] == nil || got[CookieSession].MaxAge != 30*60 {
t.Fatalf("Max-Age 应跟会话的剩余寿命(1800),得到 %+v", got[CookieSession])
}
if got[CookieSession].MaxAge == 24*60*60 {
t.Fatalf("Max-Age 被写成了完整有效期:浏览器会比服务端早 23.5 小时丢掉 Cookie")
}
// ③ 到期时间已过:一个 Cookie 都不许写(不给已经失效的会话续期限)。
if got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(-time.Second)); len(got) != 0 {
t.Fatalf("已过期的会话不该重发任何 Cookie,得到 %v", got)
}
// ④ 没有会话 token 时不写;CSRF 空串时只写会话(不写一枚空值 Cookie 把票面清掉)。
if got = refreshResult(t, svc, "", "csrf-token", now.Add(time.Hour)); len(got) != 0 {
t.Fatalf("没有会话 token 时不该写任何 Cookie,得到 %v", got)
}
got = refreshResult(t, svc, "sess-token", "", now.Add(time.Hour))
if got[CookieSession] == nil || got[CookieCSRF] != nil {
t.Fatalf("CSRF token 为空时只该重发会话 Cookie,得到 %v", got)
}
// ⑤ 安全属性口径不变:会话 HttpOnly + SameSite=Lax + Path=/,CSRF 可被前端读取。
if !sess.HttpOnly || sess.SameSite != http.SameSiteLaxMode || sess.Path != "/" {
t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax + Path=/:%+v", sess)
}
if csrf.HttpOnly {
t.Fatalf("CSRF Cookie 必须可被前端读取(非 HttpOnly):%+v", csrf)
}
}
// Secure 开关(HTTPS 部署)必须作用到重发的两枚 Cookie 上,否则续期一次就退回明文传输。
func TestRefreshSessionCookiesKeepsSecureFlag(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
db := newSettingsDB(t, nil)
insecure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }})
secure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Secure: true,
Now: func() time.Time { return now }})
for _, tc := range []struct {
name string
svc *Service
secure bool
}{
{"默认(HTTP 部署)", insecure, false},
{"Secure=true(HTTPS 部署)", secure, true},
} {
t.Run(tc.name, func(t *testing.T) {
got := refreshResult(t, tc.svc, "sess-token", "csrf-token", now.Add(time.Hour))
for _, name := range []string{CookieSession, CookieCSRF} {
if got[name] == nil {
t.Fatalf("应重发 %s,得到 %v", name, got)
}
if got[name].Secure != tc.secure {
t.Fatalf("%s 的 Secure 应为 %v,得到 %+v", name, tc.secure, got[name])
}
}
})
}
}
// 停用用户是「立即失效」的第二道闸:管理端撤会话那一步万一没跑到(异常分支、手工改库),
// 会话行还在时 Lookup 也必须当它不存在——否则被停用的人只要不关页面就能继续写。
func TestLookupRefusesInactiveUserWithLiveSession(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
db := newSettingsDB(t, nil)
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))
// 会话行还在(到期时间也没动),只把用户停用。
execTest(t, db, `UPDATE users SET active = 0 WHERE id = ?`, userID)
if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound {
t.Fatalf("停用用户的会话应查不到(ErrSessionNotFound),得到 %v", err)
}
// 对照:重新启用后同一条会话又能用了,证明上面拦的确实是「停用」而不是别的什么。
execTest(t, db, `UPDATE users SET active = 1 WHERE id = ?`, userID)
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("重新启用后同一条会话应恢复可用,得到 %v", err)
}
}
@@ -0,0 +1,323 @@
package auth
import (
"context"
"database/sql"
"sync"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// ---------- 测试脚手架 ----------
// blockSessionExpiryUpdate 装一个只拦 expires_at 回写的触发器,模拟「续期那句 UPDATE 失败」
// (库被锁 / 磁盘满 / 只读挂载)。last_seen_at 的单列回写不受影响——
// 这两条路径必须能被区分开,否则测不出 A35 的分支。
func blockSessionExpiryUpdate(t *testing.T, db *sql.DB) {
t.Helper()
execTest(t, db, `CREATE TRIGGER block_renew BEFORE UPDATE OF expires_at ON sessions
BEGIN SELECT RAISE(ABORT, '续期写库被拦下'); END`)
}
// blockSessionDelete 装一个拦 DELETE 的触发器,模拟「作废会话失败」。
func blockSessionDelete(t *testing.T, db *sql.DB) {
t.Helper()
execTest(t, db, `CREATE TRIGGER block_revoke BEFORE DELETE ON sessions
BEGIN SELECT RAISE(ABORT, '撤会话被拦下'); END`)
}
func insertSessionRow(t *testing.T, db *sql.DB, id string, userID int64, expiresAt time.Time) string {
t.Helper()
token := "token-" + id
if _, err := db.Exec(
`INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`,
id, userID, hashToken(token), expiresAt.UTC().Format(time.DateTime), expiresAt.UTC().Format(time.DateTime)); err != nil {
t.Fatalf("建会话 %s: %v", id, err)
}
return token
}
func countSessions(t *testing.T, db *sql.DB) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&n); err != nil {
t.Fatalf("统计会话: %v", err)
}
return n
}
func dbPasswordHash(t *testing.T, db *sql.DB, username string) string {
t.Helper()
var hash string
if err := db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, username).Scan(&hash); err != nil {
t.Fatalf("读密码哈希: %v", err)
}
return hash
}
func newAuthTestService(t *testing.T, db *sql.DB, opts Options) *Service {
t.Helper()
opts.Logger = quietLogger()
if opts.BcryptCost == 0 {
opts.BcryptCost = bcrypt.MinCost
}
return New(db, opts)
}
// ---------- A35:滑动续期写库失败 ----------
// 续期写库失败时不能只顺延内存里的到期时间:库里还是旧到期时间,而 /auth/me 会拿内存里那个值
// 重发 Cookie(浏览器端的 Max-Age 跟着顺延),于是「服务端说还有 24 小时、其实早已过期」,
// 用户会在毫无征兆的情况下被登出,排查时两边还对不上。
func TestLookupKeepsStoredExpiryWhenRenewFails(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
// 剩余 23h58m:已越过续期节流窗口,本次查找本该顺延到 now+24h。
token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute))
stored := dbExpiry(t, db)
blockSessionExpiryUpdate(t, db)
_, sess, err := svc.Lookup(context.Background(), token)
if err != nil {
t.Fatalf("续期写库失败不该让查找失败(会话此刻仍然有效): %v", err)
}
if want := now.Add(24*time.Hour - 2*time.Minute); !sess.ExpiresAt.Equal(want) {
t.Fatalf("续期失败时必须保持库里的到期时间 %s,得到 %s", want, sess.ExpiresAt)
}
if got := dbExpiry(t, db); got != stored {
t.Fatalf("续期失败不该改动库里的到期时间:%s → %s", stored, got)
}
// 去掉触发器后重来:续期该恢复成正常顺延(证明确实是「失败才不续」而不是「永远不续」)。
execTest(t, db, `DROP TRIGGER block_renew`)
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)
}
}
// ---------- A36:改密与作废会话同成同败 ----------
// 正常路径:密码换掉的同时会话被清空(两个动作在一个事务里)。
func TestSetPasswordRevokesSessionsSeenAsOneAction(t *testing.T) {
db := newSettingsDB(t, nil)
svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour})
userID := insertUser(t, db, "admin")
if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil {
t.Fatalf("初始化密码: %v", err)
}
insertSession(t, db, userID, time.Now().Add(time.Hour))
if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil {
t.Fatalf("改密应成功: %v", err)
}
if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err != nil {
t.Fatalf("新密码应能通过校验: %v", err)
}
if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err == nil {
t.Fatal("旧密码不该还能通过校验")
}
if n := countSessions(t, db); n != 0 {
t.Fatalf("改密后旧会话必须全部失效,库里还剩 %d 行", n)
}
}
// 失败路径(本条的要点):撤会话失败时整件事必须回滚,
// 绝不能留下「密码已改、旧会话仍全部有效」这种用户以为已经止血、实际没有的中间态。
func TestSetPasswordRollsBackWhenRevokeFails(t *testing.T) {
db := newSettingsDB(t, nil)
svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour})
userID := insertUser(t, db, "admin")
if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil {
t.Fatalf("初始化密码: %v", err)
}
insertSession(t, db, userID, time.Now().Add(time.Hour))
before := dbPasswordHash(t, db, "admin")
blockSessionDelete(t, db)
if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err == nil {
t.Fatal("撤会话失败时改密必须整体失败")
}
if got := dbPasswordHash(t, db, "admin"); got != before {
t.Fatalf("事务失败后密码哈希必须原样(回滚),%s → %s", before, got)
}
if n := countSessions(t, db); n != 1 {
t.Fatalf("事务失败后旧会话必须仍然存在(与「什么都没变」自洽),库里 %d 行", n)
}
// 外部看到的就是「什么都没变」:旧密码仍然有效、旧会话仍然可用。
if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err != nil {
t.Fatalf("回滚后旧密码应仍然有效: %v", err)
}
if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err == nil {
t.Fatal("回滚后新密码不该生效")
}
}
// 并发路径:改密期间并发的会话查找只会看到「旧世界」或「新世界」,
// 改密返回后旧 token 必须彻底查不到(不会出现「密码换了、会话还在」的残留)。
func TestSetPasswordConcurrentLookupsLeaveNoStaleSession(t *testing.T) {
db := newSettingsDB(t, nil)
svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour})
userID := insertUser(t, db, "admin")
if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil {
t.Fatalf("初始化密码: %v", err)
}
token := insertSession(t, db, userID, time.Now().Add(time.Hour))
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
// 结果不校验:并发查找允许看到事务提交前的任意中间时刻。
_, _, _ = svc.Lookup(context.Background(), token)
}
}
}()
}
if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil {
t.Fatalf("改密应成功: %v", err)
}
close(stop)
wg.Wait()
// 改密返回之后,任何一条旧会话都不该还能用。
if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound {
t.Fatalf("改密后旧会话应查不到,得到 %v", err)
}
if n := countSessions(t, db); n != 0 {
t.Fatalf("改密后库里不该还剩会话行,得到 %d", n)
}
}
// 改名走同一条事务路径:改名成功则会话一并作废。
func TestSetUsernameRevokesSessionsInSameTransaction(t *testing.T) {
db := newSettingsDB(t, nil)
svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour})
userID := insertUser(t, db, "admin")
insertSession(t, db, userID, time.Now().Add(time.Hour))
if err := svc.SetUsername(context.Background(), "admin", "boss"); err != nil {
t.Fatalf("改名应成功: %v", err)
}
if n := countSessions(t, db); n != 0 {
t.Fatalf("改名后旧会话必须失效,库里还剩 %d 行", n)
}
// 再给他一条会话,才能让「撤会话」这一步真的去删行(DELETE 不命中行时不会触发触发器)。
insertSession(t, db, userID, time.Now().Add(time.Hour))
blockSessionDelete(t, db)
if err := svc.SetUsername(context.Background(), "boss", "chief"); err == nil {
t.Fatal("撤会话失败时改名必须整体失败")
}
var name string
if err := db.QueryRow(`SELECT username FROM users`).Scan(&name); err != nil {
t.Fatalf("读用户名: %v", err)
}
if name != "boss" {
t.Fatalf("事务失败后用户名必须回滚成 boss,得到 %q", name)
}
}
// ---------- D01:过期会话行清理 ----------
// sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存),
// 挂在「查找会话」这条必经路径上按 sessionPurgeInterval 节流清理。
func TestLookupPurgesExpiredSessions(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSessionRow(t, db, "live", userID, now.Add(24*time.Hour))
insertSessionRow(t, db, "old-1", userID, now.Add(-time.Hour))
insertSessionRow(t, db, "old-2", userID, now.Add(-48*time.Hour))
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
}
if n := countSessions(t, db); n != 1 {
t.Fatalf("过期会话行应被清掉,库里还剩 %d 行", n)
}
// 节流:同一小时内不再清第二次(每次请求都扫一遍过期行纯属白写库)。
insertSessionRow(t, db, "old-3", userID, now.Add(-time.Minute))
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
}
if n := countSessions(t, db); n != 2 {
t.Fatalf("节流窗口内不该再清一次,库里 %d 行", n)
}
// 越过节流窗口后继续清理,而且不能误删仍然有效的会话。
now = now.Add(sessionPurgeInterval + time.Minute)
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
}
if n := countSessions(t, db); n != 1 {
t.Fatalf("越过节流窗口后应清掉过期行、只留有效会话,库里 %d 行", n)
}
}
// ---------- C15:登录限流不再是死配置 ----------
// 迁移 0005 种下的 security.login_rate_per_minute 此前没有任何代码读它,
// 现场改这个键不起作用。现在它真的生效(越界 / 非法一律回落到内置默认)。
func TestLoginRateFromSettings(t *testing.T) {
for _, tc := range []struct {
name string
value string
want int
}{
{"系统库设置生效", "2", 2},
{"上界 60 生效", "60", 60},
{"0 非法回默认", "0", DefaultLoginPerMinute},
{"负数非法回默认", "-1", DefaultLoginPerMinute},
{"超上限非法回默认", "61", DefaultLoginPerMinute},
{"非数字非法回默认", "五次", DefaultLoginPerMinute},
} {
t.Run(tc.name, func(t *testing.T) {
db := newSettingsDB(t, map[string]string{LoginRateKey: tc.value})
svc := newAuthTestService(t, db, Options{})
if got := svc.loginLimiter.limit; got != tc.want {
t.Fatalf("登录限流应为 %d,得到 %d", tc.want, got)
}
// 行为断言,不只看字段:第 want+1 次必须被拦下。
for i := 0; i < tc.want; i++ {
if !svc.AllowLogin("1.2.3.4") {
t.Fatalf("第 %d 次应放行", i+1)
}
}
if svc.AllowLogin("1.2.3.4") {
t.Fatalf("第 %d 次应被限流", tc.want+1)
}
})
}
t.Run("系统库没有键时用启动参数兜底", func(t *testing.T) {
db := newSettingsDB(t, nil)
svc := newAuthTestService(t, db, Options{LoginPerMinute: 3})
if got := svc.loginLimiter.limit; got != 3 {
t.Fatalf("应回落到启动参数的 3,得到 %d", got)
}
})
t.Run("系统库有键时优先于启动参数", func(t *testing.T) {
db := newSettingsDB(t, map[string]string{LoginRateKey: "2"})
svc := newAuthTestService(t, db, Options{LoginPerMinute: 9})
if got := svc.loginLimiter.limit; got != 2 {
t.Fatalf("系统库设置应优先,期望 2,得到 %d", got)
}
})
}
+12 -4
View File
@@ -4,16 +4,24 @@ import "errors"
// 引擎的哨兵错误:调用方(store / httpapi)据此区分「配置错」与「数据错」。
var (
ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在")
ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis")
ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc")
ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在")
ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis")
ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc")
// 下面这 5 个是「无返回方」的哨兵:全仓(含 _test.go)只有
// internal/httpapi/response.go 的错误映射分支在引用它们,本包没有任何一行会返回。
// 来源是已经删掉的流水作废 / 增量接口(人工调整现在走 httpapi 自己的
// stateInvalid / badRequest),映射分支实际上是悬空的。
// 它们留在 domain 里的唯一理由是让那份映射仍能编译;新增功能时不要照着它们造错误,
// 真要用先接线返回方。删掉需同步删映射(response.go)。
ErrLedgerNotFound = errors.New("domain: 积分流水不存在")
ErrLedgerAlreadyVoided = errors.New("domain: 积分流水已作废,不能重复作废")
ErrDuplicateLedgerID = errors.New("domain: 积分流水 id 重复")
ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用")
ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由")
ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作")
// ErrPointsOverrideUnsupported:成绩上的 pointsOverride 不被引擎读取。
// 与其静默忽略(设置成功但毫无效果),不如显式报错;加减分请走人工积分调整。
// 这条有真正的返回方(scoring.go 的 ScoreEvent),与上面那组不同。
ErrPointsOverrideUnsupported = errors.New("domain: 成绩不接受 pointsOverride,请改用人工积分调整")
)
+2 -1
View File
@@ -2,7 +2,8 @@ package domain
import "testing"
// 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。
// 本文件实现 devdocs/09-测试策略.md 第 2 节 17 条用例矩阵中的 13 条(01–07、10–15);
// 第 8 / 9 / 17 / 18 条没有同名测试函数,第 16 条(联队)已整条移除。
// 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。
// case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。
@@ -161,7 +161,7 @@ func TestExcludeTeamEventMedalsStillApplies(t *testing.T) {
team, _ = medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked, ExcludeTeamEventMedals: true})
if team != [3]int{0, 0, 0} {
t.Fatalf("全局排除团体奖牌后单位榜 = %v,期望 0", team)
t.Fatalf("全局排除团体项目奖牌后单位榜 = %v,期望 0", team)
}
}
@@ -0,0 +1,211 @@
package domain
import "testing"
// C04:avg_mode 的分母在「报名项退赛 / 失格」与「成绩失格 / 没录」混在一起时的口径。
//
// scoring.go 的 scoreByAggregate 定下的口径是:
// - 报名项非 registered(scratched / dq / dns):整条不进桶,既不算分子也不算分母、不占名次;
// - 报名项正常但成绩失格 / 没录:进 all 的分母(按 0 计入),不进 valid 的分子与分母。
//
// 此前只有「有成绩 / 没成绩 / 没录成绩」的对照(TestAggregateAvgMode),
// 退赛与失格混进来时分母是谁没人钉过——把退赛者算进分母会让全队平均被拉低、名次互换,
// 而现场看到的只是榜单数字莫名其妙变了。
func TestAggregateAvgModeDenominatorWithScratchedAndDQ(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
// 1001:一条正常成绩 10;一条报名项退赛(带着 6,必须整条被忽略);
// 一条报名项正常但成绩失格(all 口径要按 0 计入分母)。
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 6, entryStatus: EntryStatusScratched},
{entryID: 3, athleteID: 3, teamID: 1001, value: 4, status: ResultStatusDQ},
// 1002:一条正常成绩 8;一条报名项失格且成绩是 100(若被算进分母或分子,
// 平均会被拉成 54,名次当场互换——这是本条最容易写错的地方)。
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
{entryID: 5, athleteID: 5, teamID: 1002, value: 100, entryStatus: EntryStatusDQ},
})
}
for _, tc := range []struct {
name string
mode AvgMode
want map[int64]aggregateOutcome
}{
{
// 分子分母都只算「报名项正常且成绩有效」的人:1001 → 10/1,1002 → 8/1。
// compare=asc(越小越好)→ 1002 第一(15 分),1001 第二(10 分)。
name: "valid:退赛与失格的报名项都不算分子分母",
mode: AvgModeValid,
want: map[int64]aggregateOutcome{
1001: {value: 10, points: 10, rank: 2},
1002: {value: 8, points: 15, rank: 1},
},
},
{
// 分母 = 报名项数:1001 有 2 条正常报名项(10 + 0)/ 2 = 5;1002 只有 1 条 → 8。
// 退赛的 6 与失格的 100 都不该出现在任何一边。
name: "all:分母是报名项数,退赛者连桶都不进",
mode: AvgModeAll,
want: map[int64]aggregateOutcome{
1001: {value: 5, points: 15, rank: 1},
1002: {value: 8, points: 10, rank: 2},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := aggregateByTeam(t, es)
for teamID, want := range tc.want {
have, ok := got[teamID]
if !ok {
t.Fatalf("单位 %d 应参与排名,实际 %+v", teamID, got)
}
if !nearlyEqual(have.value, want.value) {
t.Errorf("单位 %d 平均成绩 = %v,期望 %v(分子分母口径不符)", teamID, have.value, want.value)
}
if have.rank != want.rank || !nearlyEqual(have.points, want.points) {
t.Errorf("单位 %d = 第 %d 名 %v 分,期望 第 %d 名 %v 分",
teamID, have.rank, have.points, want.rank, want.points)
}
}
if len(got) != len(tc.want) {
t.Errorf("参与排名的单位数 = %d,期望 %d(%+v)", len(got), len(tc.want), got)
}
// 流水与名次同源:每个单位的积分都能在流水里找到。
ledger := pointsByTeam(es.Ledger)
for teamID, want := range tc.want {
if !nearlyEqual(ledger[teamID], want.points) {
t.Errorf("单位 %d 流水 = %v,期望 %v", teamID, ledger[teamID], want.points)
}
}
})
}
t.Run("整队报名项全退赛:不成桶、不上榜、不得分", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1, entryStatus: EntryStatusScratched},
{entryID: 2, athleteID: 2, teamID: 1001, value: 2, entryStatus: EntryStatusDQ},
{entryID: 3, athleteID: 3, teamID: 1002, value: 8},
})
for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} {
ev := teamEvent(1)
ev.AvgMode = mode
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
if got := aggregateByTeam(t, es); got[1001].value != 0 || got[1001].points != 0 || got[1001].rank != 0 {
t.Errorf("%s:全退赛的单位不该上榜、不该得分,得到 %+v", mode, got[1001])
}
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1002], 15) || got[1001] != 0 {
t.Errorf("%s:只剩 1002 时应独占第一(15 分),得到 %v", mode, got)
}
}
})
}
// aggregateOutcome 是一次聚合通道计分后「某个单位拿到的平均成绩 / 名次 / 积分」。
type aggregateOutcome struct {
value float64
rank int
points float64
}
func aggregateByTeam(t *testing.T, es EventScore) map[int64]aggregateOutcome {
t.Helper()
out := make(map[int64]aggregateOutcome, len(es.Ranked))
for _, rr := range es.Ranked {
if rr.ResultValue == nil {
t.Fatalf("聚合通道的名次明细应带上平均成绩:%+v", rr)
}
out[rr.TeamID] = aggregateOutcome{value: *rr.ResultValue, rank: rr.Rank, points: rr.Points}
}
return out
}
// C04:并列组「跨越录取名次边界」时分怎么算。
//
// 一组并列者占用的是 startRank .. startRank+size-1 这一整段名次,这段区间跨过 AwardRanks 时
// 就出现了「组内一部分有名次、一部分没有」:
//
// skip 取起排名次的分(6 人表里并列第 6 的两人都拿第 6 名的分);
// average 把区间内所有名次的分平均,没配到的名次按 0(两人并列第 6 → (1+0)/2 = 0.5);
// higher 取组内最高分(并列第 6 → 1)。
//
// 三种口径在这里给出三个不同的数,而现场只看得到分数——之前没有用例钉住这条边界。
func TestGroupPointsTieGroupCrossingAwardRanksBoundary(t *testing.T) {
ev := individualEvent(1)
cat := testCategory()
top6 := ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules}
top3 := ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules}
cases := []struct {
name string
profile ScoringProfile
start int
size int
want float64
}{
// 录取线内(占 5、6,都在前六名里):三种口径都只用到配置了的名次。
{"两人并列第5 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 2, 1.5},
// 跨线(占 6、7,第 7 名没配分):average 把第 7 名当 0 平均掉。
{"两人并列第6 average 跨过录取线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 2, 0.5},
{"两人并列第6 higher 取组内最高", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 6, 2, 1},
{"两人并列第6 skip 取起排位", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 6, 2, 1},
// 三人跨两格(占 5、6、7)。
{"三人并列第5 average 跨线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 3, 1},
{"三人并列第5 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 5, 3, 2},
{"三人并列第6 average 只剩一格有名次", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 3, 1.0 / 3.0},
// 整组都在录取线外:三种口径都是 0,不能出现负数或异常。
{"两人并列第7 skip 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 7, 2, 0},
{"两人并列第7 average 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 7, 2, 0},
// 一组人从第 1 名一直占到第 7 名(全场并列):average 把 0 也算进去。
{"七人并列第1 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 1, 7, 30.0 / 7.0},
{"七人并列第1 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 1, 7, 10},
{"七人并列第1 skip", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 1, 7, 10},
// 录取名次更少时边界跟着走(top3 的线在第 3 名)。
{"top3 两人并列第3 average", ScoringProfile{AwardRanks: 3, TieMode: TieAverage, Rules: top3.Rules}, 3, 2, 3},
{"top3 两人并列第3 skip", ScoringProfile{AwardRanks: 3, TieMode: TieSkip, Rules: top3.Rules}, 3, 2, 6},
{"top3 两人并列第3 higher", ScoringProfile{AwardRanks: 3, TieMode: TieHigher, Rules: top3.Rules}, 3, 2, 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := groupPoints(tc.profile, tc.start, tc.size, 6, ev, cat)
if !nearlyEqual(got, tc.want) {
t.Fatalf("groupPoints(start=%d, size=%d) = %v,期望 %v", tc.start, tc.size, got, tc.want)
}
})
}
// 端到端对照:同样的并列,走完整算分后落到名次明细上的分数必须与上面一致。
t.Run("端到端:两人并列第6 的 average 口径", func(t *testing.T) {
entries, results := fixture(lineup(7))
// 第 6、7 名同分(都记 6.0)→ 并列占第 6、7 名;第 7 名没有配置分。
results[5].ResultValue = ptr(6.0)
results[6].ResultValue = ptr(6.0)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)},
Profiles: map[int64]ScoringProfile{9: {AwardRanks: 6, TieMode: TieAverage, Rules: individualTop6().Rules}},
Entries: entries, Results: results,
})
points := pointsByEntry(es.Ledger)
if !nearlyEqual(points[6], 0.5) || !nearlyEqual(points[7], 0.5) {
t.Fatalf("并列第 6 的两人应各得 0.5 分,得到 %v / %v", points[6], points[7])
}
if !nearlyEqual(points[5], 2) {
t.Fatalf("第 5 名应得 2 分,得到 %v", points[5])
}
})
}
+2 -2
View File
@@ -30,7 +30,7 @@ type StandingsInput struct {
TieBreak []string
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
IndividualToTeamWeight float64
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
// ExcludeTeamEventMedals 为 true 时单位奖牌榜不计团体项目奖牌;默认计入。
ExcludeTeamEventMedals bool
}
@@ -95,7 +95,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
return rows
}
// BuildMedalStandings 生成团体奖牌榜。
// BuildMedalStandings 生成单位奖牌榜。
// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。
func BuildMedalStandings(in StandingsInput) []MedalRow {
teamRows := BuildTeamStandings(in)
+1 -1
View File
@@ -142,7 +142,7 @@ func TestMedalStandings(t *testing.T) {
rows = BuildMedalStandings(in)
for _, r := range rows {
if r.TeamID == 1 && r.Gold != 0 {
t.Fatalf("排除团体奖牌后单位1金牌 = %d,期望 0", r.Gold)
t.Fatalf("排除团体项目奖牌后单位1金牌 = %d,期望 0", r.Gold)
}
}
}
+1 -1
View File
@@ -353,7 +353,7 @@ type StandingsRow struct {
Medals int `json:"medals"`
}
// MedalRow 团体奖牌榜的一行。
// MedalRow 单位奖牌榜的一行。
type MedalRow struct {
Rank int `json:"rank"`
TeamID int64 `json:"teamId"`
+1
View File
@@ -65,6 +65,7 @@ var athleteDetailColumns = []column{
var athleteTeamColumns = athleteDetailColumns[1:]
// ExportAthleteDetailXLSX 导出个人得分明细,落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAthleteDetailXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAthleteDetailWorkbook(db, opts)
if err != nil {
+9 -1
View File
@@ -126,6 +126,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish,
// 便于在备份列表里一眼看出哪些是发布节点留的。
//
// 当前没有生产调用方(代码卫生项 C29):生产走 Service.RunEvent("publish")(同一把 Service.mu
// 串行化,见 backup.go 的 Service),只有 backup_test.go 在用。留着是因为「reason=publish 的
// 备份名能被 ListBackups/reasonFromName 认回来」这条口径需要它来钉。
func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
if strings.TrimSpace(opts.Reason) == "" {
opts.Reason = "publish"
@@ -281,7 +285,11 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
return out, nil
}
// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。
// LatestBackup 返回最近一份备份(ListBackups 已按时间倒序,取首个)。
//
// 注意:它**不是**健康检查的输入——05 §9 明确说 GET /api/health 不返回「最近备份时间」,
// 备份列表接口走的是 ListBackups。当前只有 backup_test.go 在用它(代码卫生项 C29),
// 加调用方前先想清楚要不要真的把备份时间暴露到健康检查里。
func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
list, err := ListBackups(dir, prefix)
if err != nil || len(list) == 0 {
+10 -4
View File
@@ -404,17 +404,23 @@ func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
present := make(map[string]bool, len(sheets))
// 认表用去空白后的名字(现场整理过的表常带尾随空格),取表必须用**原名**:
// 之前拿 spec.Sheet 去 GetRows,表就在文件里却报「sheet 单位 does not exist」——
// 一句与事实相反的错,排障时最容易把人带偏。
actual := make(map[string]string, len(sheets))
for _, name := range sheets {
present[strings.TrimSpace(name)] = true
actual[strings.TrimSpace(name)] = name
}
tables := make([]BaseDataTable, 0, len(baseDataSheets))
for _, spec := range BaseDataSpecs() {
if !present[spec.Sheet] {
name, ok := actual[spec.Sheet]
if !ok {
continue
}
rows, err := f.GetRows(spec.Sheet)
// 报错与行级提示一律用契约里的表名(spec.Sheet),不用带空格的原名,
// 免得同一张表在错误信息里出现两种写法。
rows, err := f.GetRows(name)
if err != nil {
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
}
+49
View File
@@ -258,6 +258,55 @@ func TestReadBaseDataPackageErrors(t *testing.T) {
}
}
// B03:工作表名带尾随空格时也必须认得出来,且取表要用文件里的原名。
// 现场把导出件另存 / 手工改过表名后很容易多出个空格;旧代码「认表用 TrimSpace 后的名字、
// 取表却用契约里的名字」,于是报出一句与事实相反的错:表就在文件里,却说它不存在。
func TestReadBaseDataPackageTrimsSheetName(t *testing.T) {
const spaced = BaseDataSheetTeams + " " // "单位 ":末尾一个空格
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", spaced); err != nil {
t.Fatalf("重命名工作表为带尾随空格的名字: %v", err)
}
rows := [][]string{
{"单位编码", "单位名称", "启用"},
{"T9", "第九分公司", "是"},
}
for r, row := range 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(spaced, 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 != BaseDataSheetTeams {
t.Fatalf("应认出「单位」表,且 Sheet 报契约里的名字,得到 %+v", tables)
}
if len(tables[0].Rows) != 1 {
t.Fatalf("数据行数 = %d,期望 1:%+v", len(tables[0].Rows), tables[0].Rows)
}
if got := tables[0].Rows[0].Cells["code"]; got != "T9" {
t.Errorf("单位编码 = %q,期望 T9", got)
}
if got := tables[0].Rows[0].Cells["name"]; got != "第九分公司" {
t.Errorf("单位名称 = %q,期望 第九分公司", got)
}
}
func TestParseBaseDataHelpers(t *testing.T) {
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
+1
View File
@@ -67,6 +67,7 @@ var entriesColumns = []column{
}
// ExportEntriesXLSX 导出报名名单到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error {
wb, err := LoadEntriesWorkbook(db, filter, opts)
if err != nil {
+20 -3
View File
@@ -26,6 +26,11 @@ type EventResultRow struct {
// NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。
// 与 Unpublished 互斥——有成绩行才会有发布状态。
NoResult bool
// Unsettled 表示这行成绩已发布,但整组还没结算:延迟结算(分批发布)下名次与积分
// 要等该 (小项, 组别) 录完才写,此刻行上没有名次、没有流水。
// 它必须与「算出来就是 0 分、没名次」区分开(R18:占位符口径),见 eventRows。
// 与 Unpublished / NoResult 互斥——那两态是「没有值」,这一态是「值有了,名次与积分还没到」。
Unsettled bool
}
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
@@ -60,6 +65,7 @@ var eventColumns = []column{
}
// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error {
wb, err := LoadEventWorkbook(db, eventID, opts)
if err != nil {
@@ -130,10 +136,16 @@ const eventCellBlank = "—"
// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。
const eventCellUnpublished = "分数未录入"
// eventCellUnsettled 是「成绩已发布但整组未结算」行在状态列的文案。
// 只把名次 / 积分写成「—」还不够:三格里的成绩有值、状态又写「有效」,
// 打印件上仍会被读成「算出来就是 0 分、没有名次」(R18 要避免的正是这个),
// 所以状态列直接写「未结算」,让人一眼看出是没结算、不是没算出来。
const eventCellUnsettled = "未结算"
// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。
//
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值,
// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入 / 未结算时都没有可比的值,
// 半空半 0 反而像填错。已发布行原样写出(这三种行都不带 r.Points 的 0)。
func eventRows(rows []EventResultRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
@@ -141,6 +153,7 @@ func eventRows(rows []EventResultRow) [][]any {
rank any = r.Rank
result any = displayResult(r.ResultText, r.Unit)
points any = r.Points
status any = r.Status
)
switch {
case r.NoResult:
@@ -149,10 +162,14 @@ func eventRows(rows []EventResultRow) [][]any {
case r.Unpublished:
// 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。
rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank
case r.Unsettled:
// 已发布没结算:成绩照常印(它确实跑出来了),名次与积分等结算,
// 状态列写「未结算」把「还没到」与「就是 0 分」彻底分开。
rank, points, status = eventCellBlank, eventCellBlank, eventCellUnsettled
}
out = append(out, []any{
rank, r.Bib, r.TeamName, r.Participant,
result, points, r.Status, r.Remark,
result, points, status, r.Remark,
})
}
return out
+148 -1
View File
@@ -8,6 +8,153 @@ import (
"github.com/xuri/excelize/v2"
)
// seedUnsettledEvent 造一屏「延迟结算」现场:3 个人报名同一个组别,只有 1 个人出了成绩。
//
// 已发布的那条**没有名次、没有流水**——延迟结算(standings.live_on_publish=false)下名次与流水
// 要等整组录完才写(httpapi 的 scopeSettled),这正是 A17 说的「已发布未结算」。
// 另外两条分别是「有草稿没发布」与「连成绩行都没有」,三种占位态因此能并排比。
func seedUnsettledEvent(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 2, '102')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 1, '12.30', 12.30, 's', 'published')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 2, '12.40', 12.40, 's', 'draft')`,
`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)
}
}
}
// A17:延迟结算下「已发布但整组未结算」的行必须写成「名次 —、积分 —、状态 未结算」,
// 不能是「名次空 + 积分 0 + 状态 有效」——打印件上后者会被读成「算出来就是 0 分」,
// 正是 R18 立项要避免的误读。分项成绩表与单页表逐格一致(两者都走 eventRows)。
func TestUnsettledRowsPrintPlaceholders(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
dir := t.TempDir()
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
flatDst := filepath.Join(dir, "单页.xlsx")
if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil {
t.Fatalf("导出单页成绩表: %v", err)
}
// 三种占位态并排:未结算 / 草稿未发布 / 完全没录。列序见 eventColumns。
want := [][]string{
{"—", "101", "第一分公司", "张三", "12.30 秒", "—", "未结算", ""},
{"—", "102", "第一分公司", "李四", "分数未录入", "—", "草稿", ""},
{"—", "103", "第一分公司", "王五", "—", "—", "未录入", ""},
}
f := openXLSX(t, eventDst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(rows, rowHeader+i, c); got != v {
t.Errorf("分项成绩表第 %d 行第 %d 列 = %q,期望 %q", i+1, c, got, v)
}
}
}
// 单页表比多 sheet 版多三列(大项 / 小项 / 组别),后 8 列同序。
ff := openXLSX(t, flatDst)
defer func() { _ = ff.Close() }()
flatRows, err := ff.GetRows(flatSheet)
if err != nil {
t.Fatalf("读取单页成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(flatRows, rowHeader+i, c+3); got != v {
t.Errorf("单页表第 %d 行第 %d 列 = %q,期望 %q(与分项成绩表逐格一致)", i+1, c+3, got, v)
}
}
}
}
// A17 的反向钉子:实时结算(默认)下分批发布同样会让「已发布 < 应出成绩」,
// 但那一批当场结算过、名次与积分都在——不能被标成未结算。
// 同组的犯规行也不许被改写成「未结算」:判罚结果是现场明确给出的,不能弄丢。
func TestSettledAndPenalisedRowsAreNotMarkedUnsettled(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
stmts := []string{
// 已结算:名次与流水都写好了(发布即结算的形态)。
`UPDATE results SET rank = 1 WHERE entry_id = 1`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
// 第二个人改为「已发布 + 犯规」:犯规不占名次,状态列要保住「犯规」。
`UPDATE results SET state = 'published', status = 'dq' WHERE entry_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
// 已结算的那条:名次 1、积分 10、状态 有效。
if got := cell(rows, rowHeader, 0); got != "1" {
t.Errorf("已结算行名次 = %q,期望 1(不该被当成未结算)", got)
}
if got := cell(rows, rowHeader, 5); got != "10" {
t.Errorf("已结算行积分 = %q,期望 10", got)
}
if got := cell(rows, rowHeader, 6); got != "有效" {
t.Errorf("已结算行状态 = %q,期望 有效", got)
}
// 犯规的那条:状态仍是「犯规」,不能被改写成「未结算」(判罚是现场明确给出的结论)。
dq := findRowByCell(t, rows, 1, "102")
if got := cell(rows, dq, 6); got != "犯规" {
t.Errorf("犯规行状态 = %q,期望 犯规(判罚结果不能被改写成未结算)", got)
}
}
// findRowByCell 在表里找「第 col 列等于 value」的数据行下标;找不到直接失败。
func findRowByCell(t *testing.T, rows [][]string, col int, value string) int {
t.Helper()
for r := rowHeader; r < len(rows); r++ {
if cell(rows, r, col) == value {
return r
}
}
t.Fatalf("表里找不到第 %d 列 = %q 的行", col, value)
return -1
}
// seedTieAndRelay 在 seedEvent 之上补两类现场常见、却最容易把列宽顶爆的数据:
// 并列名次(名次列会写成「3(并列)」)与接力队名单(运动员/队 列写成顿号列表)。
func seedTieAndRelay(t *testing.T, db *sql.DB) {
@@ -66,7 +213,7 @@ func TestColumnsFitContent(t *testing.T) {
},
},
{
name: "团体奖牌明细",
name: "单位奖牌明细",
file: "奖牌.xlsx",
export: func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) },
sheets: []sheetLayout{
+1
View File
@@ -26,6 +26,7 @@ var flatColumns = append([]column{
}, eventColumns...)
// ExportFlatXLSX 导出「全部成绩(单页)」到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportFlatXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadFlatWorkbook(db, opts)
if err != nil {
+1 -1
View File
@@ -114,7 +114,7 @@ func TestFlatMatchesAllXLSX(t *testing.T) {
if err != nil {
t.Fatalf("读取全部: %v", err)
}
// 「全部」前 3 张是总表(总积分榜 / 团体奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。
// 「全部」前 3 张是总表(总积分榜 / 单位奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。
sheets := allF.GetSheetList()
if len(sheets) != 3+len(wb.Events.Sheets) {
t.Fatalf("全部导出 sheet 列表 = %v,与 %d 个分项对不上", sheets, len(wb.Events.Sheets))
+53 -5
View File
@@ -17,6 +17,9 @@ type Meta struct {
Subtitle string
Organizer string
GeneratedAt time.Time
// GeneratedBy 是「导出人 XXX」那一行的取值。当前没有任何生产写入口(所有调用方都只填 EventName 等),
// 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。
// 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。
GeneratedBy string
}
@@ -33,6 +36,11 @@ type Options struct {
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
//
// 当前没有生产写入口:HTTP 层所有导出 handler 都传 Options{},只有单测(load_test.go 的
// TestEventRowsIncludeDraftsKeepsDraftValues)设过 true,所以这个分支在生产不可达(代码卫生项 C30)。
// 保留它是因为「草稿值不外露」的口径必须有对照面才能被测住;真要接线请先想清楚
// 「谁能看到别人的草稿值」这个权限问题(现在连只读接口都不外露草稿)。
IncludeDrafts bool
}
@@ -454,19 +462,27 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult)。
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。
//
// gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源:
// 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩),
// 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。
// 延迟结算(standings.live_on_publish=false)下两者不等就说明这一组还没结算,
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。
query := `
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '')
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''),
r.result_value IS NOT NULL,
COALESCE(gs.required, 0), COALESCE(gs.published, 0)
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
@@ -478,8 +494,19 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
AND cr.category_id = en.category_id
LEFT JOIN (
SELECT e2.event_id, e2.category_id, COUNT(*) AS required,
SUM(CASE WHEN r2.state = 'published'
AND (r2.result_value IS NOT NULL OR r2.rank IS NOT NULL OR r2.status <> 'ok')
THEN 1 ELSE 0 END) AS published
FROM entries e2
LEFT JOIN results r2 ON r2.entry_id = e2.id
WHERE e2.event_id = ? AND e2.status = 'registered'
GROUP BY e2.event_id, e2.category_id
) gs ON gs.event_id = en.event_id AND gs.category_id = en.category_id
WHERE en.event_id = ?`
args := []any{eventID}
// 子查询里那个 ? 是同一项(先给 gs 的 event_id,再给外层 WHERE)。
args := []any{eventID, eventID}
if categoryID != 0 {
query += ` AND en.category_id = ?`
args = append(args, categoryID)
@@ -504,10 +531,12 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
hasValue int
required, published int
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote); err != nil {
&entryRemark, &resultNote, &hasValue, &required, &published); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
@@ -519,6 +548,20 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
row.NoResult = !resultState.Valid
row.Unpublished = !includeDrafts && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStateDraft
// 已发布但整组还没结算:成绩上屏了,名次与积分要等结算(见 Unsettled 的说明)。
// 这里的条件缺一不可,少任何一个都会误伤——
// published < required 且都非 0:这一组确实还没结算(与 /api/results/settlement 同判据);
// **state 是 published**:草稿行不算(它没有对外,未发布口径已经把它划走了;
// includeDrafts=true 时草稿行连值一起导出,也必须按草稿呈现,不能被这一分支抢走);
// 有成绩值、状态是 ok:这才是「等名次」的那种行;犯规 / 弃权行本来就不占名次,
// 把它们的「犯规」改写成「未结算」会把现场明确的判罚结果弄丢;
// 名次与积分都空:实时结算(默认)下分批发布也会出现 published < required,
// 但那一批已经结算过、名次与积分都在,不能标成未结算。
row.Unsettled = published > 0 && published < required &&
!row.NoResult && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStatePublished &&
hasValue != 0 && rank <= 0 && row.Points == 0 &&
domain.ResultStatus(resultStat.String) == domain.ResultStatusOK
out = append(out, row)
}
if err := rows.Err(); err != nil {
@@ -602,6 +645,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
COALESCE(r.unit, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
@@ -633,10 +677,14 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
var r TeamDetailRow
var source string
var athleteID int64
var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
// 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
r.individual = athleteID != 0
out = append(out, r)
+9 -8
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// medalDetailSheet 是「团体奖牌明细」总表的表名。
const medalDetailSheet = "团体奖牌明细"
// medalDetailSheet 是「单位奖牌明细」总表的表名。
const medalDetailSheet = "单位奖牌明细"
// 奖牌文案:与名次 1/2/3 一一对应。
const (
@@ -23,7 +23,7 @@ const (
medalBronze = "铜牌"
)
// MedalDetailRow 是团体奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。
// MedalDetailRow 是单位奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。
type MedalDetailRow struct {
SportName string
EventName string
@@ -47,7 +47,7 @@ type MedalDetailSheet struct {
Rows []MedalDetailRow
}
// MedalDetailWorkbook 是「团体奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。
// MedalDetailWorkbook 是「单位奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。
type MedalDetailWorkbook struct {
Meta Meta
Sheets []MedalDetailSheet
@@ -67,7 +67,8 @@ var medalDetailColumns = []column{
// medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。
var medalTeamColumns = medalDetailColumns[1:]
// ExportMedalDetailXLSX 导出团体奖牌明细,落盘到 dst。
// ExportMedalDetailXLSX 导出单位奖牌明细,落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadMedalDetailWorkbook(db, opts)
if err != nil {
@@ -80,7 +81,7 @@ func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error {
return saveWorkbook(f, dst)
}
// WriteMedalDetailXLSX 把团体奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。
// WriteMedalDetailXLSX 把单位奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。
func WriteMedalDetailXLSX(w io.Writer, wb MedalDetailWorkbook) error {
f, err := medalDetailFile(wb)
if err != nil {
@@ -156,7 +157,7 @@ func medalRow(r MedalDetailRow) []any {
}
// LoadMedalDetailWorkbook 读取全部奖牌来源。
// 单位顺序沿用「团体奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。
// 单位顺序沿用「单位奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。
func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) {
if db == nil {
return MedalDetailWorkbook{}, ErrNoDatabase
@@ -216,7 +217,7 @@ type medalHit struct {
// medalHits 逐「报名项」判定获奖单位。
//
// 同一报名项在多条计分通道下会展开成多条名次,只能记一枚奖牌——选通道、联队份额展开、
// 名次换牌色这些规则都交给 domain 的团体奖牌榜函数(domain.BuildMedalStandings)判定,
// 名次换牌色这些规则都交给 domain 的单位奖牌榜函数(domain.BuildMedalStandings)判定,
// 导出侧不复制计奖规则。传入的单位/流水/运动员都为空,返回的行只可能来自该报名项的奖牌。
func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medalHit {
out := make(map[int64][]medalHit)
+10 -10
View File
@@ -23,7 +23,7 @@ func addBronzeEntry(t *testing.T, db *sql.DB) {
}
}
// 团体奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
// 单位奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
func TestExportMedalDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
@@ -32,17 +32,17 @@ func TestExportMedalDetailXLSX(t *testing.T) {
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出团体奖牌明细: %v", err)
t.Fatalf("导出单位奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
@@ -105,7 +105,7 @@ func TestExportMedalDetailXLSX(t *testing.T) {
}
}
// 奖牌明细必须与团体奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
// 奖牌明细必须与单位奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
func TestMedalDetailMatchesBoard(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
@@ -136,7 +136,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
// 双计项目只贡献一枚金牌(去重成一条),不是两条。
@@ -176,7 +176,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
}
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
// 大项 / 小项 / 组别,并与单位奖牌榜逐单位对得上(devdocs 17 §11.3)。
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
@@ -201,7 +201,7 @@ func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
for _, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
@@ -239,10 +239,10 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) {
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
if got := f.GetSheetList(); !equalStrings(got, []string{"团体奖牌明细"}) {
if got := f.GetSheetList(); !equalStrings(got, []string{"单位奖牌明细"}) {
t.Fatalf("sheet 列表 = %v,期望只有总表", got)
}
rows, err := f.GetRows("团体奖牌明细")
rows, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
+5 -3
View File
@@ -9,7 +9,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。
// StandingsWorkbook 是「总表」的内容:总积分榜 + 单位奖牌榜 + 个人积分奖牌榜。
// 三个榜由 domain 的排名函数算出,本包只负责排版。
type StandingsWorkbook struct {
Meta Meta
@@ -55,6 +55,7 @@ var (
)
// ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadStandingsWorkbook(db, opts)
if err != nil {
@@ -91,7 +92,7 @@ func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook)
rows [][]any
}{
{"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)},
{"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)},
{"单位奖牌榜", sheetTitle(meta, "单位奖牌榜"), medalColumns, medalRows(wb.Medal)},
{"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)},
}
for _, s := range specs {
@@ -154,6 +155,7 @@ type AllWorkbook struct {
}
// ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAllXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAllWorkbook(db, opts)
if err != nil {
@@ -195,7 +197,7 @@ func allFile(wb AllWorkbook) (*excelize.File, error) {
}
namer := &sheetNamer{}
// 三个榜先登记名称,分项表才不会撞名。
for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} {
for _, n := range []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"} {
namer.add(n)
}
return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer)
+6
View File
@@ -300,6 +300,12 @@ func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title s
}
// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。
//
// 只被本包那 9 个 Export*XLSX 包装函数调用,而它们**只有测试在调**:生产导出(HTTP 层)
// 一律走 Load* + Write*(w io.Writer, wb),把工作簿直接写进响应体,不落临时盘。
// 所以整条「写文件」路径在生产不可达(代码卫生项 C28)。作用是让单测真的落盘、再用
// openXLSX 读回来逐格校验,比只比对内存结构更接近现场拿到的那份文件。
// 要把它接进生产,先想清楚多一份临时文件与一次磁盘写的代价,别在 handler 里随手用。
func saveWorkbook(f *excelize.File, dst string) error {
if strings.TrimSpace(dst) == "" {
return errors.New("export: 导出路径为空")
+29
View File
@@ -1,6 +1,8 @@
package export
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
@@ -56,20 +58,47 @@ func TestExportDocPropsAreNeutral(t *testing.T) {
}
}
// writeWorkbookFile 把「写 xlsx 流」的导出落到磁盘上,供本文件核对文档属性。
// 落盘是给测试读回用的,生产一律直接写响应体(见 saveWorkbook 的说明)。
func writeWorkbookFile(dst string, write func(io.Writer) error) error {
f, err := os.Create(dst)
if err != nil {
return err
}
if err := write(f); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
// 所有 xlsx 导出都走 buildWorkbook,文档属性必须一处设好、处处生效。
func TestEveryWorkbookCarriesDocProps(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
// 表里必须把 9 个落盘导出函数**全部**覆盖到:漏掉哪一个,那份工作簿的文档属性就没人守。
exports := map[string]func(string) error{
"总表.xlsx": func(dst string) error { return ExportStandingsXLSX(db, dst, Options{}) },
"全部.xlsx": func(dst string) error { return ExportAllXLSX(db, dst, Options{}) },
"分项.xlsx": func(dst string) error { return ExportEventXLSX(db, 1, dst, Options{}) },
"单页.xlsx": func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) },
"奖牌.xlsx": func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) },
"个人明细.xlsx": func(dst string) error { return ExportAthleteDetailXLSX(db, dst, Options{}) },
"单位明细.xlsx": func(dst string) error { return ExportTeamDetailXLSX(db, 1, dst, Options{}) },
"单位总明细.xlsx": func(dst string) error { return ExportAllTeamDetailsXLSX(db, dst, Options{}) },
"报名名单.xlsx": func(dst string) error { return ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}) },
// 基础数据包没有落盘版包装函数(生产与这里都走 Load + Write),用流写盘补齐这一项。
"基础数据包.xlsx": func(dst string) error {
return writeWorkbookFile(dst, func(w io.Writer) error {
wb, err := LoadBaseDataWorkbook(db, Options{})
if err != nil {
return err
}
return WriteBaseDataXLSX(w, wb)
})
},
}
for name, run := range exports {
dst := filepath.Join(dir, name)
+4 -4
View File
@@ -48,6 +48,7 @@ var teamDetailColumns = []column{
}
// ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error {
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
if err != nil {
@@ -108,11 +109,10 @@ func teamDetailDataRows(rows []TeamDetailRow, weight float64) ([][]any, float64)
return out, total
}
// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时不加合计行。
// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时也保留合计行(写 0)。
// 手册(docs/使用手册.md 第 10 节)承诺「末行是合计」,总明细版也恒有合计行;
// 单单位版若在空数据时省掉它,同一份表在两种入口下版式不一致,读表的人会以为少印了一行。
func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any {
data, total := teamDetailDataRows(rows, weight)
if len(data) == 0 {
return data
}
return append(data, []any{"合计", "", "", "", "", "", "", "", total, ""})
}
+1
View File
@@ -32,6 +32,7 @@ type AllTeamDetailsWorkbook struct {
var allTeamDetailColumns = append([]column{{"单位", 22, kindText}}, teamDetailColumns...)
// ExportAllTeamDetailsXLSX 导出所有单位的积分明细(总表 + 每单位一张分表),落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAllTeamDetailsXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAllTeamDetailsWorkbook(db, opts)
if err != nil {
+87 -5
View File
@@ -24,7 +24,7 @@ func TestExportStandingsXLSX(t *testing.T) {
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}
want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
@@ -56,12 +56,12 @@ func TestExportStandingsXLSX(t *testing.T) {
t.Errorf("第二名单位代码 = %q,期望 T2", got)
}
medal, err := f.GetRows("团体奖牌榜")
medal, err := f.GetRows("单位奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
if len(medal) != 6 {
t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal))
t.Fatalf("单位奖牌榜行数 = %d,期望 6", len(medal))
}
if got := cell(medal, 4, 3); got != "1" { // T1 金牌
t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got)
@@ -160,7 +160,7 @@ func TestExportAllXLSX(t *testing.T) {
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
@@ -207,6 +207,88 @@ func TestExportTeamDetailXLSX(t *testing.T) {
if got := cell(rows, 5, 8); got != "10" {
t.Errorf("合计积分 = %q,期望 10", got)
}
// A18:单位积分明细的成绩列必须带上单位,与分项成绩表 / 个人明细同口径——
// 同一场比赛的同一个成绩,在一份表里是「11.58 秒」、在另一份里是「11.58」,
// 两张表摆在一起核对时会被当成两个数。
if got := cell(rows, 4, 6); got != "11.58 秒" {
t.Errorf("明细成绩 = %q,期望 11.58 秒(与分项成绩表同口径)", got)
}
}
// A18 的对照面:同一个成绩在「单位积分明细」「个人积分总明细」「分项成绩表」里必须逐字一致。
func TestTeamDetailResultMatchesOtherExports(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
teamDst := filepath.Join(dir, "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, 1, teamDst, Options{}); err != nil {
t.Fatalf("导出单位积分明细: %v", err)
}
athDst := filepath.Join(dir, "个人明细.xlsx")
if err := ExportAthleteDetailXLSX(db, athDst, Options{}); err != nil {
t.Fatalf("导出个人积分总明细: %v", err)
}
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
teamRows := readSheet(t, teamDst, "第一分公司积分明细")
athRows := readSheet(t, athDst, "第一分公司个人得分明细")
eventRows := readSheet(t, eventDst, "田径·100米跑·男子")
// 单位明细第 5 行(0 基下标 4)的张三 100 米;列 6 是「成绩」。
teamResult := cell(teamRows, 4, 6)
// 个人明细分表数据从同一版式起,列 5 是「成绩」。
athResult := cell(athRows, 4, 5)
// 分项成绩表列 4 是「成绩」。
eventResult := cell(eventRows, 4, 4)
want := "11.58 秒"
for name, got := range map[string]string{
"单位积分明细": teamResult, "个人积分总明细": athResult, "分项成绩表": eventResult,
} {
if got != want {
t.Errorf("%s 里的成绩 = %q,期望 %q", name, got, want)
}
}
}
// B02:单单位「单位积分明细」在没有任何流水时也要有「合计」行(写 0),
// 与总明细版版式一致,手册承诺的「末行是合计」才成立。
func TestEmptyTeamDetailKeepsTotalRow(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 第三个单位一条流水都没有:它是「新加进来的单位,还没拿分」的现场形态。
teamID := insertTeam(t, db, "T3", "第三分公司", "三公司", 3, true)
dst := filepath.Join(t.TempDir(), "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, teamID, dst, Options{}); err != nil {
t.Fatalf("导出空单位明细: %v", err)
}
rows := readSheet(t, dst, "第三分公司积分明细")
// 4 行版式 + 只有合计行
if len(rows) != 5 {
t.Fatalf("空单位明细行数 = %d,期望 5(末行合计):%v", len(rows), rows)
}
if got := cell(rows, 4, 0); got != "合计" {
t.Errorf("末行首列 = %q,期望 合计", got)
}
if got := cell(rows, 4, 8); got != "0" {
t.Errorf("空单位合计 = %q,期望 0", got)
}
}
// readSheet 打开落盘的 xlsx 并读出某张表;导出类用例一律真的写盘再读回,见 saveWorkbook 的说明。
func readSheet(t *testing.T, path, sheet string) [][]string {
t.Helper()
f := openXLSX(t, path)
defer func() { _ = f.Close() }()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s!%s: %v", path, sheet, err)
}
return rows
}
// TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。
@@ -0,0 +1,55 @@
package httpapi_test
import (
"net/http"
"strings"
"testing"
)
// C12(后半):CHECK 类非法值必须走 HTTP 就落成 400,而不是一路落到 SQLite 的
// CHECK 上变成 500 + 原始约束原文。事件枚举、组别性别、通道 scope/basis、纪录 level、
// 用户 role、成绩 status 都已有用例;人工调整的 category(point_adjustments.category
// 的 CHECK)没有——它的写路由 POST /api/adjustments 到本轮为止一条用例都没有。
func TestAdjustmentCategoryEnumValidation(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
// 人工调整默认关闭,先打开:否则还没走到 category 就被「未启用」拦下,用例假绿。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
for _, bad := range []string{"bogus", "PENALTY", "罚款", " "} {
status, resp := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamA, "delta": 3, "category": bad, "reason": "测试",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("category=%q 应 400 VALIDATION_FAILED,得到 %d %+v", bad, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "category") {
t.Fatalf("错误文案应带字段名 category,得到 %q", resp.Error.Message)
}
// 撞 CHECK 的 500 会带出英文约束原文;这里必须一点都看不到。
for _, leak := range []string{"CHECK", "constraint", "SQLITE", "sqlite"} {
if strings.Contains(resp.Error.Message, leak) || strings.Contains(string(resp.Error.Detail), leak) {
t.Fatalf("错误里泄露了数据库约束原文 %q:%+v", leak, resp.Error)
}
}
}
// 被拒的请求一条都不许落库。
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 0 {
t.Fatalf("非法 category 的请求不该落库,得到 %d 行", n)
}
// 对照:三种合法类别都能落库(CHECK 允许 penalty / bonus / correction)。
for _, ok := range []string{"penalty", "bonus", "correction"} {
status, after := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{
"teamId": ids.teamA, "delta": -1, "category": ok, "reason": "测试",
"requestId": "adj-" + ok,
}, true)
if status != http.StatusOK || !after.OK {
t.Fatalf("category=%q 应 200,得到 %d %+v", ok, status, after.Error)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 3 {
t.Fatalf("三个合法类别应各落一条,得到 %d 行", n)
}
}
@@ -1,6 +1,7 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
"time"
@@ -224,6 +225,98 @@ func TestBatchRejectsBadInput(t *testing.T) {
}
}
// batchLimit 是服务端单次批量动作的人数上限(`maxAthleteBatch`,与 `entries/batch` 的
// `maxEntryBatch` 刻意同值 500)。用例在 httpapi 包外,拿不到那个常量,只能写字面量:
// 上限一旦改动,这里与 devdocs/05 会一起变红,正好逼着同步改口径与文档。
const batchLimit = 500
// 超限(上限 + 1):400,响应体与同一个接口上既有的 400 同构,且一个人都不动。
//
// 用「上限 + 1」构造,不真发几万条:拦的是条数,与 id 具体是什么无关;
// 但条数是真做满的(全是库里存在的人),删掉上限检查后这一批会被真的停用,
// 用例只能靠状态码与文案变红,不会靠「id 不存在」之类的旁证蒙对。
func TestBatchRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit+1-len(targets))...)
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": targets}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", batchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
// 对照用「ids 不能为空」(detail 为 nil),与超限同为「只给一句话」的校验。
refStatus, ref := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": []int64{}}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(ids 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后动作:被拒的请求一个人都不能停用。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 0 {
t.Fatalf("超限被拒后不该停用任何人,得到 %d 人", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条一次全部停用。
func TestBatchAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit-len(targets))...)
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
if result.Requested != batchLimit || result.Succeeded != batchLimit || result.Failed != 0 {
t.Fatalf("正好 %d 条应全部成功,得到 %+v", batchLimit, result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != batchLimit {
t.Fatalf("应停用 %d 人,得到 %d", batchLimit, n)
}
}
// seedAthleteBatch 建 count 个运动员并返回其 id,供「正好等于上限」这类要凑够条数的用例使用。
// 整批放在一个事务里:单连接库上逐条提交会把用例拖成几百次提交。
func seedAthleteBatch(t *testing.T, env *testEnv, teamID int64, count int) []int64 {
t.Helper()
tx, err := env.ev.Begin()
if err != nil {
t.Fatalf("开事务: %v", err)
}
defer func() { _ = tx.Rollback() }()
out := make([]int64, 0, count)
for i := 0; i < count; i++ {
res, err := tx.Exec(`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
fmt.Sprintf("批量%03d", i), teamID)
if err != nil {
t.Fatalf("建运动员: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取运动员 id: %v", err)
}
out = append(out, id)
}
if err := tx.Commit(); err != nil {
t.Fatalf("提交: %v", err)
}
return out
}
// 未登录的写操作一律 401,且不改动任何数据。
func TestBatchRequiresLogin(t *testing.T) {
env := newTestEnv(t)
@@ -81,6 +81,11 @@ func TestRestorePrunesOldSafetyCopies(t *testing.T) {
if !strings.Contains(result.Message, "已清理 2 份") {
t.Errorf("回执应说明清理了几份:%q", result.Message)
}
// A19:保留份数这句话必须固定出现(用户文档只承诺「不参与 20 份滚动清理」),
// 否则「已清理 2 份」读起来像一次性事件,看不出这是个每类库只留 5 份的常态策略。
if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) {
t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message)
}
left := safetyCopiesIn(t, backupsDir, "pre-restore-event-")
if len(left) != 5 {
@@ -1,13 +1,21 @@
package httpapi_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
@@ -241,6 +249,15 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
if result.Message == "" || !strings.Contains(result.Message, result.SafetyCopy) {
t.Errorf("回执提示应带上安全副本名:%q", result.Message)
}
// A19:安全副本只留最近 N 份这件事必须固定说在回执里。用户文档只承诺「不参与 20 份滚动清理」,
// 现场很容易理解成「它会一直在」,想退回三周前那个时间点才发现早被后几次恢复的清理删了。
// 本次没有清理任何副本(pruned=0)也要说这句;没删就别说「已清理」。
if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) {
t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message)
}
if strings.Contains(result.Message, "已清理") {
t.Errorf("本次没清理任何副本,回执不该出现「已清理」:%q", result.Message)
}
// ① 当前库回到备份时刻:备份之后加的单位没了,原有两个还在。
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
@@ -276,6 +293,169 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
}
}
// probeRestore 发一个**带自身超时**的恢复请求,返回状态码与信封;超时(服务端把这个请求
// 挂住不放)时返回 error。
//
// 为什么不用 env.do:这条用例的探测请求探的正是「第二个恢复请求会不会被挂住排队」。
// 若用不带超时的请求,被测实现一旦退化成排队,探测本身就会永远等不到响应,把整个用例
// 堵死在 client.Do 里——defers 与 t.Cleanup 全都轮不到执行,于是整个包挂到 go test
// 超时(现场表现是 600s 死掉 + panic dump 指着这条用例),而不是干脆报红。
func probeRestore(client *http.Client, url, csrf string, body any, timeout time.Duration) (int, envelope, error) {
raw, err := json.Marshal(body)
if err != nil {
return 0, envelope{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/api/backup/restore", bytes.NewReader(raw))
if err != nil {
return 0, envelope{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
resp, err := client.Do(req)
if err != nil {
return 0, envelope{}, err
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
var env envelope
if len(bytes.TrimSpace(out)) > 0 {
if err := json.Unmarshal(out, &env); err != nil {
return resp.StatusCode, env, fmt.Errorf("解析响应: %w (%s)", err, out)
}
}
return resp.StatusCode, env, nil
}
// A21:恢复必须互斥。两个管理员同时点「一键恢复」时,第二个请求要**当场**拿到
// 409 +「正在恢复,请稍后」,而不是排队等着一起动库文件(两次替换交错的后果是库被换成
// 哪一份都说不清,两份回执还各说各话)。
//
// 构造:第一个请求故意把 JSON body 分两段发——它拿到互斥锁后会阻塞在解码上,
// 这正是生产里「一次恢复正在进行」的形态;此时第二个请求进来必须被挡回。
//
// 这条用例的要求不止「能抓到回归」,还有「抓到时必须干脆地失败」:它天然要留一个卡在
// 服务端的半截请求,任何一步无上限地等待,回归发生时就会变成整包挂到 go test 超时。
// 所以三处都封了上限——探测请求各自带 1s 超时(永远不会把测试主 goroutine 堵住)、
// 收尾用 t.Cleanup 主动取消第一个在飞请求并让假 body 立刻报错(服务端 handler 随即
// 返回,httptest.Server.Close() 不会等它)、最后一等也带 20s 上限。
func TestRestoreRejectsSecondRequestWhileBusy(t *testing.T) {
env, _, _ := backupRestoreEnv(t)
env.seed(t)
client := env.loginAdmin(t)
csrf := env.csrfToken(client)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
// 半截 body 的恢复请求:只发一半 JSON,让它在 handler 里卡住。
pr, pw := io.Pipe()
// 这个请求要一直挂着,所以它的取消必须挂在清理路径上(断言失败时也执行):
// cancel 让客户端侧的在飞请求当场结束(连接随之断开,服务端读 body 立刻报错),
// 关掉写端再补一层——两条路任意一条走通,handler 都会立刻返回并释放互斥锁,
// 不会把 httptest.Server.Close() 卡在那里等。
ctx, cancelFirst := context.WithCancel(context.Background())
// t.Cleanup 按后进先出执行,本函数注册的比环境里的 srv.Close 晚,所以先于它跑。
t.Cleanup(func() {
cancelFirst()
_ = pw.CloseWithError(io.ErrClosedPipe)
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, env.server.URL+"/api/backup/restore", pr)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf)
first := make(chan struct {
status int
env envelope
}, 1)
go func() {
resp, err := client.Do(req)
if err != nil {
first <- struct {
status int
env envelope
}{status: 0}
return
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
var out envelope
_ = json.Unmarshal(raw, &out)
first <- struct {
status int
env envelope
}{status: resp.StatusCode, env: out}
}()
if _, err := io.WriteString(pw, `{"kind":"event","file":"`+eventBackup.Name+`"`); err != nil {
t.Fatalf("写半截 body: %v", err)
}
// 第二个请求:用一份**故意非法**的载荷反复试——它一旦被放行也只会拿 400(校验在任何
// 文件操作之前),不会把库改掉;拿不到 409 说明第一个请求还没进 handler,再试一次即可。
// 这样就不必靠 sleep 去赌时序。
const probeTimeout = time.Second
const deadlineWindow = 5 * time.Second
deadline := time.Now().Add(deadlineWindow)
var (
lastStatus int
lastError *apiErrorBody
queued bool
)
for {
status, resp, err := probeRestore(client, env.server.URL, csrf,
map[string]any{"file": "system-not-a-backup.txt", "kind": "system"}, probeTimeout)
if err == nil && status == http.StatusConflict {
if resp.Error == nil || !strings.Contains(resp.Error.Message, "正在恢复,请稍后") {
t.Fatalf("并发恢复的提示 = %+v,期望「正在恢复,请稍后」", resp.Error)
}
break
}
switch {
case err == nil:
// 只记第一次拿到的回应:后面反复试可能自己撞上限流(429),那反而不是要报的事实。
if lastStatus == 0 {
lastStatus, lastError = status, resp.Error
}
case errors.Is(err, context.DeadlineExceeded):
// 探测请求被服务端挂住了:正是「排队」回归的形态,记下来,等总时限到了好报清楚。
queued = true
default:
t.Fatalf("并发恢复探测请求失败: %v", err)
}
if time.Now().After(deadline) {
if queued {
t.Fatalf("第一个恢复请求占着互斥锁时,第二个恢复请求被挂住排队(%s 内没有回话);"+
"应当当场回 409「正在恢复,请稍后」,而不是排队等它做完", probeTimeout)
}
t.Fatalf("第二个恢复请求在 %s 内没拿到 409「正在恢复,请稍后」(第一次拿到的回应 status=%d %+v);"+
"要么互斥没让它撞上「正在恢复」,要么它根本没能进到互斥判断", deadlineWindow, lastStatus, lastError)
}
time.Sleep(10 * time.Millisecond)
}
// 收尾:把 body 补完,第一个请求照常做完这次恢复(回执正常,库确实被换回备份时刻)。
if _, err := io.WriteString(pw, `}`); err != nil {
t.Fatalf("补完 body: %v", err)
}
_ = pw.Close()
// 带超时地等:这一步要是无限等,被测实现的其它退化(例如恢复本身挂住)同样会把包拖到
// 测试超时,报出来的错还不如这里一句「第一个恢复请求没在 20s 内回话」。
select {
case got := <-first:
if got.status != http.StatusOK || !got.env.OK {
t.Fatalf("第一个恢复请求应正常完成,得到 status=%d %+v", got.status, got.env.Error)
}
case <-time.After(20 * time.Second):
t.Fatalf("第一个恢复请求在 20s 内没有回话(body 已补完,它本该照常做完这次恢复)")
}
}
// 恢复系统库:只换 system.db,赛事库一行不动;审计照旧写进赛事库。
func TestRestoreSystemBackupLeavesEventDBAlone(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
@@ -217,3 +217,37 @@ func TestDisplayCommandRejectsUnknownAction(t *testing.T) {
t.Fatalf("未知 action 不该下发,得到 %v", sender.sent)
}
}
// C27:announce_clear 与其它指令同路(realtime.CmdAnnounceClear 已在协议清单里),
// 不再需要「转发前先问实时层认不认」的特判。这条用例把两件事一起钉住:
// 指令要下发(否则大屏挂着旧公告),持久值也要清空(否则大屏重连又把它显示出来)。
func TestDisplayCommandAnnounceClearForwardsAndClears(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
if status, e := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "announce",
"payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30},
}, true); status != http.StatusOK {
t.Fatalf("先发一条公告应 200,得到 %d %+v", status, e.Error)
}
if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" {
t.Fatalf("公告应已落库,得到 %q", got)
}
sender.sent = nil
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "announce_clear"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清空公告应 200,得到 %d %+v", status, env2.Error)
}
if len(sender.sent) != 1 || sender.sent[0] != "announce_clear" {
t.Fatalf("清空公告必须下发给实时层,得到 %+v", sender.sent)
}
if got := settingValue(t, env, "display.announce_text"); got != "" {
t.Fatalf("清空公告应把持久值写成空串,得到 %q", got)
}
if after := waitDisplayAudit(t, env); !strings.Contains(after, `"action":"announce_clear"`) {
t.Fatalf("清空公告也要留 display.command 审计,得到 %s", after)
}
}
@@ -134,6 +134,66 @@ func TestSetEntriesHeatValidatesInput(t *testing.T) {
assertNoHeat(t, env, ids.entryA)
}
// entryHeatBatchLimit 与 POST /api/entries/batch 的 `maxEntryBatch` 刻意同值 500:
// 两个接口处理的都是「一批报名项」,上限与文案就该是同一句(服务端直接复用同一个常量)。
// 用例在 httpapi 包外拿不到常量,写字面量;上限改动时这里会红。
const entryHeatBatchLimit = 500
// 超限(上限 + 1):400,响应体与既有 400 同构,且一条报名项的批次都没改。
//
// 501 条全是同一个小项里的**真实**报名项(复用 partial_publish_test.go 的 seedBatch:
// 同样是在 seed 基础上补齐到 N 条):删掉上限检查后这一批会被真的写进库,
// 用例只能靠状态码与文案变红,不会靠「部分报名项不存在」之类的旁证蒙对。
func TestSetEntriesHeatRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit+1)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 1}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", entryHeatBatchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
refStatus, ref := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": []int64{}, "heat": 1}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(entryIds 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后写库:被拒的请求一条批次都不能落。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE heat IS NOT NULL`); n != 0 {
t.Fatalf("超限被拒后不该写任何批次,得到 %d 条", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条同小项的报名项一次写成同一批次。
func TestSetEntriesHeatAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 2}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("正好 %d 条应放行,得到 %d %+v", entryHeatBatchLimit, status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["updated"] != float64(entryHeatBatchLimit) {
t.Fatalf("应更新 %d 条,得到 %+v", entryHeatBatchLimit, result)
}
assertCount(t, env, `SELECT COUNT(*) FROM entries WHERE heat = 2`, entryHeatBatchLimit)
}
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
env, ids, _ := recorderWithSeed(t)
+7 -3
View File
@@ -2,7 +2,9 @@ package httpapi
import "strings"
// 枚举型入参的取值校验集中在本文件。
// 枚举型入参的取值校验集中在本文件(**但不全**:`entries.status` 的归一只有导入那条路走
// entry_status.go 的 normalizeImportStatus;`scope` / `basis` / `records.level` / `/api/users`
// 的 `role`,以及性别、`avgMode` 等仍在各自原处)。
//
// 这些列在迁移里都写了 CHECK (col IN (...)),但 CHECK 是最后一道闸:非法值一路落到
// SQLite 才被拒,报出来的是原始约束错误,被统一错误处理当成服务端异常,前端看到
@@ -10,8 +12,10 @@ import "strings"
//
// 每个函数都直接调用 badRequest:拦截点靠它捕获调用栈,中间再包一层错误构造 helper
// 会让拦截点退化成 response.go 里的构造函数(有测试钉着,见 intercept_log_test.go)。
// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;返回值做 TrimSpace,
// 避免「前后带空格」这种近失值又落回 CHECK。
// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;本文件里的函数返回值都做
// TrimSpace,避免「前后带空格」这种近失值又落回 CHECK。
// ⚠️ 例外:`normalizeAvgMode` 还在 repo_events.go(本文件之外),且**不做 TrimSpace**——
// 前后带空格的 `valid` / `all` 会被直接拒成 400,不会被裁掉(P2-C24b)。
// normalizeEventType 校验 events.event_type。
// 取值来自 0004 的 CHECK,0016 用 writable_schema 追加了 individual_team。
+3
View File
@@ -90,7 +90,10 @@ func TestUpdateEventEnumValidation(t *testing.T) {
client := recorderClient(t, env)
// 非法值在事务前就被拦下,不该碰库。
// 请求体带上必填字段:更新路径已与新建同口径(缺 sportId / code / name 先报「必填」),
// 这条用例钉的是枚举的拦截点,别让它被更早的必填校验挡在前面。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
"sportId": ids.sport, "code": "M100", "name": "100米跑",
"eventType": "bogus",
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
@@ -4,6 +4,7 @@ import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -306,6 +307,72 @@ func TestBaseDataImportNeverDeletes(t *testing.T) {
}
}
// A20:基础数据包里带了「已彻底删除」的单位时,导入必须说清「这行被跳过了、因为单位已删除」。
// 旧行为是照常计入「无变化 / 更新」并写库:现场点预览看到「更新 1 条」,确认写入后界面上仍然
// 没有这个单位(它还是隐藏状态),看不出任何解释,像是导入没生效。
func TestBaseDataImportSkipsPurgedTeamAndSaysWhy(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")
// 彻底删除 B 单位:假删除——deleted_at 非空、active=0,业务数据一行不动。
status, resp := env.do(client, http.MethodDelete,
fmt.Sprintf("/api/teams/%d?purge=1", ids.teamB), nil, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, resp.Error)
}
pack := buildTestXLSX(t, []testSheet{{
name: "单位",
rows: [][]string{
{"单位编码", "单位名称"},
{"B", "第二分公司(导入改名)"},
},
}})
// 预览:该行必须单列一条说明,且不计进「新增 / 更新 / 无变化」。
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.Updated != 0 || res.Unchanged != 0 {
t.Errorf("命中已删除单位的那行不该计入变更:新增 %d / 更新 %d / 无变化 %d",
res.Added, res.Updated, res.Unchanged)
}
if res.ErrorCount != 1 || len(res.Errors) != 1 {
t.Fatalf("应单列 1 条说明,得到 错误 %d %v", res.ErrorCount, res.Errors)
}
// 文案里只给界面路径:这条会直接显示给现场操作员,不该出现内部接口路径。
for _, want := range []string{"第 2 行", "B", "已被彻底删除", "跳过", "基础数据 → 参赛单位", "恢复"} {
if !strings.Contains(res.Errors[0], want) {
t.Errorf("说明 %q 应包含 %q", res.Errors[0], want)
}
}
if strings.Contains(res.Errors[0], "/api/") {
t.Errorf("给现场看的说明不该带内部接口路径:%q", res.Errors[0])
}
// 落地:同样跳过,不改它的字段(改了名它照样是隐藏的,现场只会更困惑)。
res = importPackage(t, env, client, pack, nil)
if res.ErrorCount != 1 || res.Updated != 0 {
t.Fatalf("落地也应跳过该行,得到 错误 %d / 更新 %d %v", res.ErrorCount, res.Updated, res.Errors)
}
var (
name string
deleted sql.NullString
activeIn int
)
if err := env.ev.QueryRow(`SELECT name, deleted_at, active FROM teams WHERE id = ?`, ids.teamB).
Scan(&name, &deleted, &activeIn); err != nil {
t.Fatalf("读单位: %v", err)
}
if name != "第二分公司" {
t.Errorf("单位名 = %q,期望仍是「第二分公司」(隐藏单位的字段一行都不该被改)", name)
}
if !deleted.Valid || activeIn != 0 {
t.Errorf("隐藏状态被改动了:deleted_at=%v active=%d", deleted, activeIn)
}
}
// 新建的单位 / 大项不能被建成停用:缺省 = 启用。
func TestBaseDataImportCreatesEnabledRows(t *testing.T) {
env := newTestEnv(t)
@@ -0,0 +1,252 @@
package httpapi_test
import (
"strconv"
"testing"
)
// C34:「列缺席 / 格子空 / 显式值」三态口径按铁律每一列都要有对照用例。
//
// 本轮之前只有排序与布尔列有这三组对照;权重 / 年龄 / 文本列没有——
// 而这三类各自的口径并不一样(都来自文档写死的行为):
//
// 列缺席 = 不动该字段(四张表、所有列一律如此)
// 格子空(权重) = 取默认值 1
// 格子空(年龄) = 「不限」(清成 NULL)
// 格子空(文本) = 清空
// 显式值 = 照写
//
// 三态本来是分两轮踩出来的坑(「缺列把全部停用改回启用」「留空被重置成默认」),
// 每一列都必须自己钉住,否则改一列的口径不会有人发现。
type baseDataTriStateColumn struct {
sheet string
header string
seed string // 库里先改成这个非默认值
blank string // 格子空之后的期望库值
explicit string // 表格里显式填的合法值
want string // 显式填值之后的期望库值(数值列会归一化)
rows int // 这张表里会被整列改写影响到的数据行数(单位表有两行,其余各一行)
apply func(t *testing.T, env *testEnv, ids seedIDs)
read func(t *testing.T, env *testEnv, ids seedIDs) string
}
// affectedRows 是整列改写会改动到的行数:改一列就是改这张表每一行的这一列。
func (c baseDataTriStateColumn) affectedRows() int {
if c.rows <= 0 {
return 1
}
return c.rows
}
// textCol 读某表某列的文本现值(NULL 按空串)。
func textCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v string
if err := env.ev.QueryRow(
`SELECT COALESCE(`+col+`, '') FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
return v
}
}
// floatCol 读某表某列的数值现值;用与差异报告同一个格式化口径(formatFloat 的 %g),
// 这样「2.5 与 2.50 是同一个值」不会让断言假红。
func floatCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v float64
if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
}
// intCol 读某表某列的可空整数现值(NULL 按空串 = 不限)。
func intCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
return func(t *testing.T, env *testEnv, ids seedIDs) string {
t.Helper()
var v *int
if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil {
t.Fatalf("读 %s.%s: %v", table, col, err)
}
if v == nil {
return ""
}
return strconv.Itoa(*v)
}
}
// idsFor 把「表 → 种子行的 id」这件事收在一处:三张表在种子里各只有一行。
func idsFor(table string, ids seedIDs) int64 {
switch table {
case "teams":
return ids.teamA
case "events":
return ids.event
default:
return ids.category
}
}
// triStateColumns 是本条要覆盖的三态列:权重(项目与组别)、年龄上下限、场地 / 备注 / 颜色三列文本。
func triStateColumns() []baseDataTriStateColumn {
return []baseDataTriStateColumn{
{
sheet: "小项", header: "权重", rows: 1,
seed: "2.5", blank: "1", explicit: "3.5", want: "3.5",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET multiplier = 2.5 WHERE id = ?`, ids.event)
},
read: floatCol("events", "multiplier"),
},
{
sheet: "组别", header: "权重", rows: 1,
seed: "4", blank: "1", explicit: "0.5", want: "0.5",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET multiplier = 4 WHERE id = ?`, ids.category)
},
read: floatCol("event_categories", "multiplier"),
},
{
sheet: "组别", header: "年龄下限", rows: 1,
seed: "30", blank: "", explicit: "12", want: "12",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category)
},
read: intCol("event_categories", "age_min"),
},
{
sheet: "组别", header: "年龄上限", rows: 1,
seed: "40", blank: "", explicit: "45", want: "45",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category)
},
read: intCol("event_categories", "age_max"),
},
{
sheet: "小项", header: "场地", rows: 1,
seed: "田径场A", blank: "", explicit: "副场", want: "副场",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET venue = '田径场A' WHERE id = ?`, ids.event)
},
read: textCol("events", "venue"),
},
{
sheet: "小项", header: "备注", rows: 1,
seed: "雨后备注", blank: "", explicit: "改过的备注", want: "改过的备注",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE events SET remark = '雨后备注' WHERE id = ?`, ids.event)
},
read: textCol("events", "remark"),
},
{
// 单位表有两行数据:整列改写会同时动到两个单位,期望条数跟着行数走。
sheet: "单位", header: "颜色", rows: 2,
seed: "#FF0000", blank: "", explicit: "#00FF00", want: "#00FF00",
apply: func(t *testing.T, env *testEnv, ids seedIDs) {
env.exec(t, `UPDATE teams SET color = '#FF0000' WHERE id = ?`, ids.teamA)
env.exec(t, `UPDATE teams SET color = '#0000FF' WHERE id = ?`, ids.teamB)
},
read: textCol("teams", "color"),
},
}
}
func TestBaseDataImportTriStateColumns(t *testing.T) {
for _, col := range triStateColumns() {
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
// 三个期望值必须两两不同,否则「不做区分」的实现也能让用例全绿。
if col.seed == col.blank || col.seed == col.want || col.blank == col.want {
t.Fatalf("三态期望值必须两两不同:seed=%q blank=%q explicit=%q", col.seed, col.blank, col.want)
}
t.Run("列缺席=不动", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
dropColumn(t, f, col.sheet, col.header)
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("整列删掉 = 不动该字段,不该产生任何差异:新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
assertNoFieldDiff(t, res, col.header)
res = importPackage(t, env, client, pack, nil)
if res.Added != 0 || res.Updated != 0 {
t.Fatalf("落地后也不该有改动:新增 %d / 更新 %d", res.Added, res.Updated)
}
if got := col.read(t, env, ids); got != col.seed {
t.Fatalf("「%s」缺列后库里应保持原值 %q,得到 %q", col.header, col.seed, got)
}
})
t.Run("格子空=按本列口径", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteColumn(t, f, col.sheet, col.header, "")
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.ErrorCount != 0 || res.Added != 0 {
t.Fatalf("格子空不该报错、也不该新建:新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
// 预览必须先报出这一列的差异再说:预览说「无变化」而落地改了,是最坏的一种不一致。
// 三态期望值两两不同(上面断言过),所以格子空必然产生差异。
if res.Updated != col.affectedRows() {
t.Fatalf("格子空应报 %d 条更新,得到 %d %+v", col.affectedRows(), res.Updated, res.Changes)
}
assertNoFieldDiff(t, res, "单位名称", "大项名称", "小项名称", "组别名")
res = importPackage(t, env, client, pack, nil)
if res.ErrorCount != 0 {
t.Fatalf("落地不该报错,得到 %v", res.Errors)
}
if got := col.read(t, env, ids); got != col.blank {
t.Fatalf("「%s」格子空后库里应为 %q(本列口径),得到 %q", col.header, col.blank, got)
}
})
t.Run("显式值=照写", func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
col.apply(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteColumn(t, f, col.sheet, col.header, col.explicit)
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.ErrorCount != 0 || res.Added != 0 || res.Updated != col.affectedRows() {
t.Fatalf("显式填 %q 应报 %d 条更新、无错误,得到 新增 %d / 更新 %d / 错误 %d %v",
col.explicit, col.affectedRows(), res.Added, res.Updated, res.ErrorCount, res.Errors)
}
res = importPackage(t, env, client, pack, nil)
if res.Updated != col.affectedRows() {
t.Fatalf("落地应更新 %d 条,得到 %d", col.affectedRows(), res.Updated)
}
if got := col.read(t, env, ids); got != col.want {
t.Fatalf("「%s」显式填 %q 后库里应为 %q,得到 %q", col.header, col.explicit, col.want, got)
}
// 再导入一次同样的包:已经等于目标值,必须回到「无变化」。
res = importPackage(t, env, client, pack, nil)
if res.Updated != 0 {
t.Fatalf("同一份包再导一次应无变化,得到 %d %+v", res.Updated, res.Changes)
}
})
})
}
}
@@ -52,10 +52,12 @@ func TestExportContentDispositionHasASCIIFallback(t *testing.T) {
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", ".xlsx"},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", ".xlsx"},
{"/api/export/all-flat.xlsx", "all-flat.xlsx", "赛事成绩总表(单页).xlsx", ".xlsx"},
{"/api/export/medals.xlsx", "medals.xlsx", "团体奖牌明细.xlsx", ".xlsx"},
{"/api/export/medals.xlsx", "medals.xlsx", "单位奖牌明细.xlsx", ".xlsx"},
{"/api/export/athletes.xlsx", "athletes.xlsx", "个人积分总明细.xlsx", ".xlsx"},
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", ".xlsx"},
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", ".xlsx"},
{"/api/export/entries.xlsx", "entries.xlsx", "报名名单.xlsx", ".xlsx"},
{"/api/export/basedata.xlsx", "basedata.xlsx", "基础数据包.xlsx", ".xlsx"},
{"/api/export/project.zip", "project.zip", "赛事项目包.zip", ".zip"},
}
for _, tc := range cases {
+2 -2
View File
@@ -95,8 +95,8 @@ func TestExistingExportsUnchanged(t *testing.T) {
file string
sheets []string
}{
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"}},
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
+80 -14
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -24,8 +25,27 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// maxProjectImportBytes 是赛事包上传的硬上限。
//
// **故意与另外两个批量导入(名单 CSV 32MB、基础数据包 32MB)不同**:赛事包是唯一
// 带二进制资源的包——库里导出 + 赛事会标 / 大屏背景图 / 单位 logo,以及**字体**
// (字体上传的单文件上限本身就是 20MB,见 fonts.go 的 maxFontBytes,且允许传多个)。
// 按 32MB 卡会让「两个字体 + 一张背景图」的正常赛事包直接导不进来,属于生产事故。
//
// 真正防「整个包驻留内存」的是 ParseMultipartForm 的 maxMemory(见 multipartMemoryBytes):
// 超出部分 Go 会自动落到临时文件,所以上限放宽不等于内存放大。
//
// 它是 var 而不是 const,只为给测试留一个「临时调小再验拒绝路径」的口子
// (见 SetMaxProjectImportBytesForTest);生产代码没有任何改动它的入口。
var maxProjectImportBytes int64 = 512 << 20
// SetMaxProjectImportBytesForTest 临时调整赛事包上限,返回原值便于 defer 还原。
// 仅供测试使用;调用方不要在 t.Parallel() 的用例里用它。
func SetMaxProjectImportBytesForTest(v int64) int64 {
prev := maxProjectImportBytes
maxProjectImportBytes = v
return prev
}
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
@@ -34,7 +54,7 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
// 单位奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
@@ -135,13 +155,13 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
})
}
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
// exportMedalsXLSX 导出单位奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return s.streamDownload(w, "medals.xlsx", "单位奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteMedalDetailXLSX(out, wb)
})
}
@@ -249,7 +269,7 @@ type baseDataChange struct {
// baseDataImportResult 是差异预览 / 导入落地的结果。
// 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数;
// errorCount 是另计的被拦下的行(它们不写库)。
// errorCount 是另计的被拦下的行(它们不写库),命中「已彻底删除」的单位也在这一档里。
type baseDataImportResult struct {
DryRun bool `json:"dryRun"`
Added int `json:"added"`
@@ -270,7 +290,7 @@ type baseDataImportResult struct {
// 表里没有的行一律不动——导入永远不做删除。
func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
@@ -325,6 +345,8 @@ type baseDataExistingTeam struct {
color *string
sortOrder int
active bool
// deleted 表示这行是「已彻底删除」(teams.deleted_at 非空)的单位:库里有、界面上没有。
deleted bool
}
type baseDataExistingSport struct {
@@ -495,8 +517,10 @@ func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDat
func (p *baseDataPlanner) loadExisting() error {
// 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。
// 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。
// deleted_at 也一并读出来:命中隐藏单位时要把它跳掉并明确告诉用户(见 planTeams),
// 只按「更新」走的话现场改完名字界面上仍然没有它,看起来像导入没生效。
teamRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT id, code, name, short_name, color, sort_order, active
SELECT id, code, name, short_name, color, sort_order, active, deleted_at IS NOT NULL
FROM teams ORDER BY sort_order, code`)
if err != nil {
return internal(err)
@@ -508,11 +532,12 @@ func (p *baseDataPlanner) loadExisting() error {
code string
shortName, color sql.NullString
)
var active int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil {
var active, deleted int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active, &deleted); err != nil {
return internal(err)
}
t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0
t.deleted = deleted != 0
p.teams[code] = t
}
if err := teamRows.Err(); err != nil {
@@ -623,6 +648,16 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
if !ok {
existing = baseDataExistingTeam{active: true}
}
// 命中「已彻底删除」(deleted_at 非空)的单位:库里那行还在、界面上和导出件里都没有它。
// 旧行为是照常走「更新 / 无变化」并真的写库,于是现场会看到「更新 1 条」却怎么也找不到这个单位
// (它仍是隐藏状态),像是导入没生效。改成整行跳过 + 单独报一句原因与恢复入口。
// 文案只指界面路径:这句话会直接显示给现场操作员,内部接口路径对他们没有意义。
if ok && existing.deleted {
p.rowError(sheet, line,
"单位 %s 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」后点「恢复」",
code)
continue
}
plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
if row.Has["active"] {
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
@@ -1400,9 +1435,17 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
// multipartMemoryBytes 是所有 multipart 上传共用的**内存档位**(不是大小上限):
// 解析时最多在内存里留这么多,超出部分 Go 自动落到临时文件。
// 防「整个包驻留内存」靠的是它,所以各入口的大小上限可以按内容类型各不相同。
const multipartMemoryBytes = 16 << 20
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
// 内存里只留 multipartMemoryBytes,超出的部分由 multipart 落在系统临时目录。
// 注意别把 maxMemory 与上面的大小上限搞混:上限可以宽(赛事包带字体/图片),
// 内存档位必须窄。
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
@@ -1496,6 +1539,19 @@ type backupRestoreResult struct {
Warning string `json:"warning,omitempty"`
}
// restoreMu 串行化「一键恢复」。
//
// 恢复动的是数据目录里的库文件本身(关连接 → 整文件替换 → 触发重启),
// 同一次操作里没有任何一步能并发安全地跑两遍:两个管理员同时点(连点两次、两个窗口、
// 两个人各按一次)会让两次替换交错——一个刚拍完安全副本、另一个已经换了文件,
// 回执还会各说各话(一个说已用 A 恢复、另一个说已用 B 恢复),而现场只看到「恢复了」。
//
// 与备份侧 export.Service.mu 是同一套做法(同一时刻只让一个动库文件的操作在跑),
// 区别在这里是全局一把:要保护的是数据目录里的库文件,它不属于某一个 server 实例。
// 后来者不排队静默执行——排队会让请求一直挂着,页面上的「正在重启」会变成假象,
// 所以用 TryLock 当场返回「正在恢复,请稍后」。
var restoreMu sync.Mutex
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
//
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
@@ -1510,6 +1566,12 @@ type backupRestoreResult struct {
// → 关连接、替换文件
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
// 整段串行化(含参数校验):忙的时候直接回话,连文件都不碰。
if !restoreMu.TryLock() {
return nil, conflict("正在恢复,请稍后:上一次恢复还在进行,等它重启完成、页面恢复后再试")
}
defer restoreMu.Unlock()
var in restoreBackupPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
@@ -1629,10 +1691,14 @@ func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (a
} else {
base += ";服务端正在重启,请稍候刷新页面"
}
notes := []string{"恢复前安全副本:" + safety.Name}
// 安全副本自己那份保留策略要固定说清楚:用户文档只承诺「不参与 20 份滚动清理」,
// 现场很容易理解成「这些副本会一直在」。实际上每类库只留最近 DefaultSafetyCopyKeep 份,
// 第 N 次恢复会把最早的删掉——不写死这句话,想退回三周前那个时间点时会扑空。
// 但只在真清理了才说「已清理 N 份」:没删就说删了也是假话。
notes := []string{fmt.Sprintf("恢复前安全副本:%s(每类库保留最近 %d 份,更旧的会被后续恢复清理)",
safety.Name, export.DefaultSafetyCopyKeep)}
if pruned > 0 {
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
pruned, export.DefaultSafetyCopyKeep))
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本", pruned))
}
if kind == export.RestoreKindEvent {
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
@@ -67,3 +67,25 @@ func TestExportProjectNameMessageIsActionable(t *testing.T) {
t.Errorf("超长名称应提示长度上限,得到 %q", got)
}
}
// D06:赛事包的上传**上限**故意高于另外两个批量导入,但**内存驻留**必须与它们同档。
//
// 背景:名单 CSV 与基础数据包是纯文本/表格,32MB 上限够用;赛事包是唯一带二进制
// 资源的包——赛事会标 / 大屏背景图 / 单位 logo / **字体**(单文件上限就有 20MB,
// 且允许多个)。所以上限不能按 32MB 卡(`两个字体 + 一张背景图` 的正常包会被拒),
// 真正要防的是「整个包驻留内存」,那由 ParseMultipartForm 的 maxMemory 决定:
// 超出 maxMemory 的部分 Go 会自动落到临时文件。
//
// 这条用例钉两件事:①内存档位不许被单独放大;②上限不许被收回到与纯文本导入同档。
func TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous(t *testing.T) {
if multipartMemoryBytes > 32<<20 {
t.Errorf("multipart 内存档位 = %d,超过 32MB 就得重新权衡内存占用", multipartMemoryBytes)
}
if maxProjectImportBytes < 128<<20 {
t.Errorf("赛事包上限 = %d,低于 128MB 会让带字体/图片的正常赛事包导不进来", maxProjectImportBytes)
}
if maxEntryImportBytes > 64<<20 || maxBaseDataImportBytes > 64<<20 {
t.Errorf("纯文本导入的上限不该超过 64MB:名单 = %d,基础数据包 = %d",
maxEntryImportBytes, maxBaseDataImportBytes)
}
}
@@ -66,6 +66,55 @@ func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []by
return resp.StatusCode, raw
}
// D06:超过上限的赛事包必须在解析阶段就被挡住(400 + 中文提示),
// 超限赛事包必须在读进内存/走完导入流程之前就被拒绝。
//
// ⚠️ 这里把上限**临时调小**再验拒绝路径:赛事包的上限故意放宽到 512MB(它是唯一带
// 字体/图片等二进制资源的包,字体单文件上限就有 20MB),拿真的 512MB 请求体做用例
// 太重;而「上限够宽」由 TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous
// 与下面那条 40MB 的用例钉住。
func TestImportProjectZipRejectsOversizeUpload(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
original := httpapi.SetMaxProjectImportBytesForTest(1 << 20)
defer httpapi.SetMaxProjectImportBytesForTest(original)
// 2MB:内容不重要,超限本身就够了(MaxBytesReader 会在解析 multipart 时截断请求体)。
oversize := bytes.Repeat([]byte("x"), 2<<20)
status, raw := env.postProjectImport(t, client, oversize, nil)
if status != http.StatusBadRequest {
t.Fatalf("超限赛事包应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "解析上传失败") {
t.Errorf("超限提示 = %s,期望「解析上传失败」", raw)
}
}
// 40MB 的赛事包(超过纯文本导入那档 32MB)必须**过得了大小闸门**:
// 带两个字体 + 一张背景图的正常赛事包就是这个量级,按 32MB 卡会让现场导不进去。
// 这里只断言「不再因体积被拒」,内容仍是假 zip,所以会走到后面的格式校验报错。
func TestImportProjectZipAcceptsPackagesAboveTextImportLimit(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
body := bytes.Repeat([]byte("x"), 40<<20)
status, raw := env.postProjectImport(t, client, body, nil)
if status == http.StatusBadRequest && strings.Contains(string(raw), "解析上传失败") {
t.Fatalf("40MB 赛事包不该因体积被拒(上限必须容得下带字体/图片的包):%s", raw)
}
}
// 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。
func TestImportProjectZipRejectsRunningProject(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
@@ -53,7 +53,7 @@ func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
}
}
// guest 能下载团体奖牌明细;每块奖牌对应一条成绩。
// guest 能下载单位奖牌明细;每块奖牌对应一条成绩。
func TestExportMedalsXLSXGuest(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
@@ -64,16 +64,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body)
}
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "团体奖牌明细.xlsx")
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "单位奖牌明细.xlsx")
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
// 单位 B 只有银牌也有分表;两个单位都有奖牌 → 总表 + 2 张分表。
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalSheetList(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
@@ -94,16 +94,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
t.Errorf("分表小计 = %q", got)
}
// 与团体奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。
// 与单位奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。
_, _, standingsBody := env.getRaw(t, client, "/api/export/standings.xlsx")
sf := openWorkbook(t, standingsBody)
defer func() { _ = sf.Close() }()
medalRows, err := sf.GetRows("团体奖牌榜")
medalRows, err := sf.GetRows("单位奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
if got := masterCell(medalRows, 4, 3); got != "1" {
t.Errorf("团体奖牌榜首队金牌 = %q,期望 1", got)
t.Errorf("单位奖牌榜首队金牌 = %q,期望 1", got)
}
}
+67 -16
View File
@@ -70,11 +70,18 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
// 登出是真删会话行(服务端强制下线),属于写操作,得和登录 / 改密一样留审计:
// 现场问「谁在什么时候把谁的会话撤了」时,只有这几条动作能对上。
// 访客没有会话可撤,不记(否则 audit_logs 里会出现 user_id 为空的行)。
if u.Role != auth.RoleGuest {
s.audit(r.Context(), "auth.logout", "user", u.ID, nil, map[string]any{"username": u.Username})
}
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
return nil, nil
@@ -105,6 +112,20 @@ func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
csrf = c.Value
}
if csrf == "" {
// 会话有效但 CSRF Cookie 不在了(浏览器清理、升级/换端口后残留、手工拷了会话 Cookie):
// 双提交方案下没有它,所有写操作会永久 403,而唯一的自愈路径是「退出后重新登录」——
// 刷新页面走的还是这条路径,补不回来。这里就地签发一枚新 token 并下发,
// 会话与 token 的绑定关系没变(token 不落库、只做双提交比对),不影响任何安全口径。
token, err := auth.NewCSRFToken()
if err != nil {
// crypto/rand 失败已经意味着整个进程不可信,但 /auth/me 是只读接口,
// 不能因为补票失败就把它变成 500:记下来,本次不补发。
s.logger.Error("补发 CSRF Cookie 失败", "err", err)
} else {
csrf = token
}
}
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
return out, nil
}
@@ -125,7 +146,10 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if len(in.NewPassword) < 8 {
// 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来,
// 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节——
// bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。
if utf8.RuneCountInString(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
if len(in.NewPassword) > auth.MaxPasswordBytes {
@@ -563,13 +587,20 @@ func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (a
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
// 漏传 lastPlaceZero 不能当成「用户把最后一名归零关掉了」:PUT 是全量覆盖语义,
// 而 Go 的 bool 零值就是 false——只想改个名字的调用方会把这一项静默关掉,
// 算分行为跟着变,界面上却看不出是谁改的(同族的 active 已按这个口径处理)。
if !keys.has("lastPlaceZero") {
in.LastPlaceZero = before.LastPlaceZero
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
@@ -755,7 +786,10 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
// 审计动作一律以 entity_type 的单数族名打头:本族已有 entry.heat / entry.scratch /
// entry.restore / entry.remove,导入原先写成 entries.import 自成第二族,
// 现场按文档示例填 `entry.` 一筛会漏掉两条导入审计(清单 B01)。
s.audit(r.Context(), "entry.import", "entry", 0, nil, result)
return result, nil
}
@@ -789,7 +823,7 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
s.audit(r.Context(), "entry.import.resolve", "entry", 0, nil, result)
return result, nil
}
@@ -987,6 +1021,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
// 空串有专门含义:登记者没有证件号(个人行没填,或本来就是团体行),复用它一律按重号处理。
bibOwners := make(map[string]string)
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
@@ -1066,11 +1101,29 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}
}
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
idCard := cell("id_card")
bib := cell("bib")
if bib != "" {
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
// 团体行没有「同一个人」这回事(它没有证件号可言),因此一律按重号处理。
owner := idCard
if athleteName == "" {
owner = ""
}
if prev, seen := bibOwners[bib]; seen && (owner == "" || prev != owner) {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
bibOwners[bib] = owner
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
@@ -1083,15 +1136,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if bib != "" {
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
bibOwners[bib] = idCard
}
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
duplicate := false
@@ -1141,7 +1185,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Bib: nilIfEmpty(bib),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
@@ -1153,6 +1197,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
case outcome.Created:
result.Created++
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
for _, moved := range outcome.MovedEntries {
result.Notices = append(result.Notices, fmt.Sprintf(
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
}
default:
result.Updated++
if outcome.StatusChanged {
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -554,7 +555,7 @@ func TestImportResolveKeepExisting(t *testing.T) {
var audit int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entry.import.resolve'`).Scan(&audit); err != nil {
t.Fatalf("统计审计: %v", err)
}
if audit == 0 {
@@ -598,6 +599,61 @@ func TestImportResolveKeepNew(t *testing.T) {
}
}
// B01:报名这一族的审计动作必须共用单数前缀 entry.(导入、批次、批量退赛/恢复/移出都算)。
// 用例真发三个写请求,再走日志页同一条查询(`/api/audit-logs?action=entry.`,子串模糊)
// 断言三条都在 —— 只看字符串常量的话,改回 entries.import 这类回归照样能绿。
func TestEntryAuditActionsShareOnePrefix(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// ① 报名表导入(新增一个人 + 一条报名)。
body := importHeader + "\nM100,男子,A,前缀用例选手,110101199001019999,男,,X900,,registered,\n"
if status, env2 := env.postImport(t, client, "/api/entries/import", body, ""); status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
// ② 批量设置批次。
if status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
}
// ③ 批量退赛。
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryB}, "reason": "前缀用例",
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
}
want := []string{"entry.import", "entry.heat", "entry.scratch"}
// /api/audit-logs 是 admin-only;审计在响应之后落库,轮询到齐再断言。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
found := map[string]bool{}
for i := 0; i < 100 && len(found) < len(want); i++ {
_, env2 := env.do(adminClient, http.MethodGet, "/api/audit-logs?action=entry.&size=200", nil, false)
items, _ := decodeData[map[string]any](t, env2)["items"].([]any)
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
action, _ := item["action"].(string)
if slices.Contains(want, action) {
found[action] = true
}
}
if len(found) < len(want) {
time.Sleep(10 * time.Millisecond)
}
}
for _, action := range want {
if !found[action] {
t.Fatalf("按 entry. 前缀应能筛到 %s,实际只筛到 %+v", action, found)
}
}
}
// decisions 非法时返回 400。
func TestImportResolveRejectsBadDecisions(t *testing.T) {
env, _, client := recorderWithSeed(t)
+6 -9
View File
@@ -228,10 +228,6 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
// ---------- 大屏控制与运维 ----------
// cmdAnnounceClear 是清空当前公告的指令;实时层的协议清单(realtime.ValidDisplayAction)还没登记它,
// 因此转发前要先看实时层认不认,见 displayCommandHandler。
const cmdAnnounceClear = "announce_clear"
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
// payload 由 JSON 解出,缺失的字段按零值处理,由落库侧补默认时长。
func announceFromPayload(payload any) (string, int) {
@@ -260,9 +256,9 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
return nil, badRequest("action 必填", nil)
}
matched := 0
// 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收),
// 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
// 大屏读到靠的是 settings + change_log,与这一次广播无关。
if s.display != nil {
n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets)
if err != nil {
// 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。
@@ -281,7 +277,8 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
}
}
// 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」,
// 新连上的大屏也拿不到当前公告。
// 新连上的大屏也拿不到当前公告。清空公告走同一份持久值(写空串),
// 否则大屏重连后会把已经撤掉的公告再显示一遍。
actorID := middlewareUserID(r)
switch in.Action {
case realtime.CmdAnnounce:
@@ -289,7 +286,7 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
if err := s.saveAnnouncement(r.Context(), text, seconds, actorID); err != nil {
return nil, err
}
case cmdAnnounceClear:
case realtime.CmdAnnounceClear:
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
return nil, err
}
@@ -222,8 +222,8 @@ func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetai
}, nil
}
// parentSnapshot 返回父节点快照:优先用显式 parent_id;
// 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。
// parentSnapshot 返回父节点快照:优先用显式 parent_id(recordRevision 写入的「同小项上一条版本」);
// parent_id 为 NULL(每个小项的第一条版本、以及本轮改动之前的老数据)时,退化为按 id 找「同小项上一条记录」。
func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) {
var (
raw string
+8 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
@@ -138,7 +139,10 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt
if err != nil {
return userListItemDTO{}, err
}
if len(in.Password) < 8 {
// 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来,
// 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节——
// bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。口径与改密接口一致(见 handlers_config.go)。
if utf8.RuneCountInString(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
if len(in.Password) > auth.MaxPasswordBytes {
@@ -228,7 +232,9 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa
return resetPasswordResultDTO{}, internal(err)
}
generated = true
} else if len(password) < 8 {
} else if utf8.RuneCountInString(password) < 8 {
// 与建号、改密同一口径:下限按字符(rune)算——3 个汉字是 9 字节,
// 按字节判会把前端(按字符数)拒绝的口令放进来;上限仍按字节(bcrypt 只吃前 72 字节)。
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
} else if len(password) > auth.MaxPasswordBytes {
return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"testing"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
@@ -42,3 +43,74 @@ func TestUserPasswordLengthLimits(t *testing.T) {
t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error)
}
}
// 「至少 8 位」按字符(rune)算,不是按字节:3 个汉字是 9 字节,
// 按字节判会把前端(按字符数)拒绝的口令放进来,同一口令界面拒、接口收。
// 上限仍按字节——bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。
// 口径必须与改密接口(handlers_config.go)完全一致(P2 A37 同源缺口)。
func TestUserPasswordLengthCountsRunes(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 3 个汉字 + 5 个字母 = 8 字符 / 14 字节:字数够,必须放行。
eightRunes := "密码呀abcde"
if utf8.RuneCountInString(eightRunes) != 8 || len(eightRunes) != 14 {
t.Fatalf("用例构造错误:应 8 字符 / 14 字节,得到 %d 字符 / %d 字节",
utf8.RuneCountInString(eightRunes), len(eightRunes))
}
// 7 个汉字 = 7 字符 / 21 字节:字数不够,必须拒(按字节判的老口径会放行)。
sevenRunes := strings.Repeat("汉", 7)
// 25 个汉字 = 25 字符 / 75 字节:字数够但超 bcrypt 上限,必须拒。
tooManyBytes := strings.Repeat("汉", 25)
status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-runes", "password": eightRunes, "role": "recorder",
}, true)
if status != http.StatusOK {
t.Fatalf("8 字符(14 字节)的密码建号应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
id := int64(created["id"].(float64))
if id == 0 {
t.Fatalf("建号响应缺少 id: %+v", created)
}
// 建号的密码能直接登录,说明服务端真的按这一串收下了(没有被截断成别的口令)。
// 用新 client:带会话再登录要过 CSRF,而这里要的正是「换个人重新登」。
fresh, _ := env.client()
env.login(fresh, "u-runes", eightRunes)
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-short", "password": sevenRunes, "role": "recorder",
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" {
t.Fatalf("7 个汉字是 7 字符,建号应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u-long", "password": tooManyBytes, "role": "recorder",
}, true)
if status != http.StatusBadRequest || env2.Error == nil ||
env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("25 个汉字是 75 字节,建号应 400 字节上限,得到 %d %+v", status, env2.Error)
}
// 重置密码与建号同口径:8 字符放行、7 字符拒绝。
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": eightRunes}, true)
if status != http.StatusOK {
t.Fatalf("8 字符(14 字节)的密码重置应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": sevenRunes}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" {
t.Fatalf("7 个汉字是 7 字符,重置应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id),
map[string]any{"password": tooManyBytes}, true)
if status != http.StatusBadRequest || env2.Error == nil ||
env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("25 个汉字是 75 字节,重置应 400 字节上限,得到 %d %+v", status, env2.Error)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
"请求被拦截",
"无权访问该资源",
"请先登录",
"CSRF 校验失败,请刷新页面重试",
"CSRF 校验失败,请退出后重新登录",
"请先修改初始密码",
"internal/middleware/middleware.go:",
} {
+343
View File
@@ -0,0 +1,343 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// cookieValue 读 jar 里某个 Cookie 的当前值;没有则返回空串。
func (e *testEnv) cookieValue(t *testing.T, client *http.Client, name string) string {
t.Helper()
u, err := url.Parse(e.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == name {
return c.Value
}
}
return ""
}
// ---------- A38:/auth/me 在 CSRF Cookie 丢失时补发 ----------
// 会话有效但这台机器没有 CSRF Cookie 时,写操作会永久 403,而刷新页面走的还是 /auth/me,
// 补不回 Cookie。修法是就地签发一枚新 token 并下发——会话本身不轮换。
func TestMeReissuesMissingCSRFCookie(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, jar := env.client()
env.login(client, "admin", "admin-pass")
session := env.cookieValue(t, client, auth.CookieSession)
// 模拟丢票:浏览器清理、换端口 / 换域名后残留、手工拷了会话 Cookie。
u, _ := url.Parse(env.server.URL)
jar.SetCookies(u, []*http.Cookie{{Name: auth.CookieCSRF, Value: "", MaxAge: -1, Path: "/"}})
if got := env.csrfToken(client); got != "" {
t.Fatalf("前置条件:此时 jar 里不该还有 CSRF Cookie,得到 %q", got)
}
status, header, body := env.getRaw(t, client, "/api/auth/me")
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d:%s", status, body)
}
var setCookie bool
for _, v := range header.Values("Set-Cookie") {
if strings.Contains(v, auth.CookieCSRF+"=") {
setCookie = true
}
}
if !setCookie {
t.Fatalf("/auth/me 必须补发 CSRF Cookie,响应头是 %v", header.Values("Set-Cookie"))
}
if env.csrfToken(client) == "" {
t.Fatal("补发的 CSRF Cookie 应落进 jar 且有值")
}
// 补的是票,不是新登录:会话 Cookie 不能被换掉。
if got := env.cookieValue(t, client, auth.CookieSession); got != session {
t.Fatalf("补票不该轮换会话:%s → %s", session, got)
}
// 新票立即可用:带上它写就通过(这正是「能看不能写」的自愈路径)。
if status, resp := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"event": map[string]any{"name": "补票后写入"}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("持补发票的写请求应通过,得到 %d %+v", status, resp.Error)
}
}
// ---------- A37:密码下限按字符判 ----------
// 「至少 8 位」是按字符说的,前端也按字符数判;按字节判会放行 7 个汉字这种
// 界面明确拒绝的口令,同一口令两个口径。上限仍按字节(bcrypt 只吃 72 字节)。
func TestChangePasswordLengthCountsRunes(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 7 个汉字 = 21 字节:按字节判会放行,必须按字符拒掉。
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七"}, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Message != "新密码至少 8 位" {
t.Fatalf("7 个字符的口令应 400「新密码至少 8 位」,得到 %d %+v", status, body.Error)
}
// 25 个汉字 = 75 字节:字符数够,但超过 bcrypt 的 72 字节上限,仍要拒。
long := strings.Repeat("汉", 25)
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": long}, true)
if status != http.StatusBadRequest || body.Error == nil ||
body.Error.Message != fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("超 72 字节应 400 字节上限,得到 %d %+v", status, body.Error)
}
// 失败的改密不该动会话:紧接着还能再改一次。
// 8 个汉字 = 24 字节:字符与字节都合法,收下。
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七八"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("8 个字符的口令应被接受,得到 %d %+v", status, body.Error)
}
var hash string
if err := env.sys.QueryRow(`SELECT password_hash FROM users WHERE username = 'admin'`).Scan(&hash); err != nil {
t.Fatalf("读密码哈希: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("一二三四五六七八")); err != nil {
t.Fatalf("落库的应是这串汉字口令: %v", err)
}
}
// ---------- A05:漏传 lastPlaceZero 不能静默变成 false ----------
func TestUpdateProfileKeepsLastPlaceZeroWhenOmitted(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-LPZ-PUT", "name": "最后一名归零", "lastPlaceZero": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建模板应 200,得到 %d %+v", status, resp.Error)
}
id := int64(decodeData[map[string]any](t, resp)["id"].(float64))
path := fmt.Sprintf("/api/scoring-profiles/%d", id)
// 只想改名字的 PUT:漏传 lastPlaceZero 必须沿用库里的值,而不是被 bool 零值写成 false。
status, resp = env.do(client, http.MethodPut, path,
map[string]any{"code": "P-LPZ-PUT", "name": "改名不改开关"}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改模板应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("漏传 lastPlaceZero 不该把它写成 0,库里是 %d", got)
}
// 显式传 false 才是真的关掉。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式关掉", "lastPlaceZero": false,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 false 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 0 {
t.Fatalf("显式 false 应落库为 0,库里是 %d", got)
}
// 显式传 true 再打开。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式打开", "lastPlaceZero": true,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 true 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("显式 true 应落库为 1,库里是 %d", got)
}
}
func profileLastPlaceZero(t *testing.T, env *testEnv, id int64) int {
t.Helper()
var v int
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE id = ?`, id).Scan(&v); err != nil {
t.Fatalf("读 last_place_zero: %v", err)
}
return v
}
// ---------- A11:号码布查重必须覆盖团体行 ----------
// 团体行(athlete_name 为空)原先完全不参与号码布查重:填了号码布既不登记也不检查,
// 同一张表里两个团体行共用一个号码布会静默落库。
func TestImportChecksBibOnTeamRows(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{
name: "两个团体行共用一个号码布",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3001,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3001,,registered,王五/赵六\n",
},
{
name: "团体行与个人行共用一个号码布(团体行在前)",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3002,,registered,张三/李四\n" +
"M100,男子,B,张三,,男,,B3002,,registered,\n",
},
{
name: "团体行与个人行共用一个号码布(个人行在前)",
body: importHeader + "\n" +
"M100,男子,A,张三,,男,,B3003,,registered,\n" +
"M100,男子,B,,,男,,B3003,,registered,王五/赵六\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, resp := env.postImport(t, client, "/api/entries/import", tc.body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应落库、重号行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
t.Fatalf("应报号码布重复,得到 %+v", errs)
}
})
}
// 号码布不同的团体行照常导入,且号码布真的落进报名行。
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,,,男,,B3101,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3102,,registered,王五/赵六\n"
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[map[string]any](t, resp); result["created"] != float64(2) || result["skipped"] != float64(0) {
t.Fatalf("号码布不重复的团体行都该落库: %+v", result)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries WHERE bib IN ('B3101', 'B3102')`).Scan(&n); err != nil {
t.Fatalf("统计报名号码布: %v", err)
}
if n != 2 {
t.Fatalf("团体行的号码布应写进报名,得到 %d 行", n)
}
}
// ---------- C14:登出要写审计 ----------
func TestLogoutWritesAudit(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
var actorID sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.logout' ORDER BY id DESC LIMIT 1`).Scan(&actorID); err != nil {
t.Fatalf("登出会真删会话行,必须写审计: %v", err)
}
if !actorID.Valid || actorID.Int64 != userID {
t.Fatalf("审计应记在真实用户 %d 上,得到 %+v", userID, actorID)
}
// 访客没有会话可撤:不记(否则 audit_logs 里会出现 user_id 为空的行)。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/auth/logout", nil, false); status != http.StatusOK {
t.Fatalf("访客登出应 200,得到 %d", status)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout'`).Scan(&n); err != nil {
t.Fatalf("统计登出审计: %v", err)
}
if n != 1 {
t.Fatalf("访客登出不该写审计,得到 %d 条", n)
}
}
// ---------- A39:反代改写 Host 时的登录(端到端) ----------
// 现场场景:nginx 把 Host 改写成回源地址(127.0.0.1:port),浏览器带的 Origin 是外部域名。
// 只要直连对端在可信代理白名单里、并透传了原 Host,登录就该照常成功;
// 反过来,没配可信代理时服务端不采信任何代理头——同源校验不放水。
func TestLoginBehindTrustedProxyWithForwardedHost(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
forwarded := map[string]string{
"Origin": "http://scoreboard.local",
"X-Forwarded-Host": "scoreboard.local",
}
// 没配可信代理:r.Host(回源地址)与 Origin 不一致 → 403,提示指向反代配置。
status, resp := env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusForbidden || resp.Error == nil || resp.Error.Code != "FORBIDDEN" {
t.Fatalf("未配置可信代理时应 403(不采信代理头),得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "Host") {
t.Fatalf("提示应说明 Host 与 Origin 不一致,得到 %q", resp.Error.Message)
}
// 把直连对端登记为可信代理:代理透传的原 Host 与 Origin 一致 → 登录放行。
if err := middleware.SetTrustedProxies([]string{"127.0.0.1"}); err != nil {
t.Fatalf("配置可信代理: %v", err)
}
t.Cleanup(func() { _ = middleware.SetTrustedProxies(nil) })
status, resp = env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusOK || !resp.OK {
t.Fatalf("可信代理透传原 Host 时登录应成功,得到 %d %+v", status, resp.Error)
}
if env.cookieValue(t, client, auth.CookieSession) == "" {
t.Fatal("登录成功应下发会话 Cookie")
}
}
// ---------- C15:登录限流不再是死配置 ----------
// 迁移 0005 种下的 security.login_rate_per_minute 必须真的被读(此前全仓无人引用)。
func TestLoginRateSettingFromMigrationIsRead(t *testing.T) {
env := newTestEnv(t)
var seeded string
if err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, auth.LoginRateKey).Scan(&seeded); err != nil {
t.Fatalf("迁移应种下 %s: %v", auth.LoginRateKey, err)
}
if seeded != strconv.Itoa(auth.DefaultLoginPerMinute) {
t.Fatalf("迁移种下的值应与内置默认一致,得到 %q", seeded)
}
// 「重启后」(新起一个 Service)读库里的值:改成 2 之后第 3 次登录就该被限流。
if _, err := env.sys.Exec(`UPDATE settings SET value = '2' WHERE key = ?`, auth.LoginRateKey); err != nil {
t.Fatalf("改登录限流设置: %v", err)
}
svc := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
for i := 0; i < 2; i++ {
if !svc.AllowLogin("127.0.0.1") {
t.Fatalf("第 %d 次应放行", i+1)
}
}
if svc.AllowLogin("127.0.0.1") {
t.Fatal("第 3 次应被限流——库里的登录限流设置没有被读到")
}
}
@@ -0,0 +1,109 @@
package httpapi_test
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// C21:写限流的 429 是「中间件拦下的」——它不经 httpapi 的 writeAPIError,
// 走的是 middleware.intercept。此前只有人肉验证,没有用例盯着它是否真的落进
// interceptions/:拦截点写错、类别标记漏传、乃至 429 干脆不记日志,都不会有人发现。
//
// 用真实日志装配(logging.New 写临时目录)而不是内存缓冲:要验的正是「落哪个目录」。
func TestRateLimitInterceptionLandsInInterceptions(t *testing.T) {
logDir := t.TempDir()
handler, closer := logging.New(logging.Options{
Dir: logDir, Level: slog.LevelInfo, Console: io.Discard, MaxSizeMB: 20, KeepFiles: 10,
})
t.Cleanup(func() { _ = closer.Close() })
logger := slog.New(handler)
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = logger
o.Auth = auth.New(o.System, auth.Options{
Logger: logger,
BcryptCost: bcrypt.MinCost,
WritePerMinute: 2,
})
})
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for i := 1; i <= 2; i++ {
if status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": fmt.Sprintf("T%d", i), "name": "阈值内的单位"}, true); status != http.StatusOK {
t.Fatalf("第 %d 次写应在阈值内放行,得到 %d %+v", i, status, body.Error)
}
}
status, body := env.do(client, http.MethodPost, "/api/teams",
map[string]any{"code": "T9", "name": "超限单位"}, true)
if status != http.StatusTooManyRequests {
t.Fatalf("超出阈值应 429,得到 %d %+v", status, body.Error)
}
inter := readHTTPAPILog(t, logDir, logging.DirInterceptions)
// WARN 是默认 info 级别下现场能看到的那行:为什么没让他写 + 是谁拦的 + 什么码。
for _, want := range []string{
`"level":"WARN"`,
"请求被拦截",
"操作过于频繁,请稍后再试",
`"code":"RATE_LIMITED"`,
`"actor":"recorder#`,
"internal/middleware/middleware.go:",
"RateLimitWrite",
} {
if !strings.Contains(inter, want) {
t.Fatalf("429 的拦截日志应含 %q,得到:\n%s", want, inter)
}
}
// DEBUG 那行补上下文:现场要拿它判断是哪个账号、哪条路径、哪台机器在刷。
for _, want := range []string{`"level":"DEBUG"`, "拦截上下文", `"path":"/api/teams"`, `"code":"RATE_LIMITED"`} {
if !strings.Contains(inter, want) {
t.Fatalf("429 的拦截上下文应含 %q,得到:\n%s", want, inter)
}
}
// 429 不该被记成 5xx:它是一次有意为之的拦截,不是服务端异常。
if strings.Contains(inter, `"level":"ERROR"`) {
t.Fatalf("429 不该出现 ERROR 级日志,得到:\n%s", inter)
}
// 拦截类不能落 operations/:两类混在一起,「今天被拦过几次、为什么」就查不出来了。
ops := readHTTPAPILog(t, logDir, logging.DirOperations)
for _, unwanted := range []string{"请求被拦截", "拦截上下文"} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations/ 不该含 %q,得到:\n%s", unwanted, ops)
}
}
// 反向对照:放行的那两次写仍要落 operations/,别把两类日志整体搞反。
if !strings.Contains(ops, "操作已放行") {
t.Fatalf("放行的写操作应落 operations/,得到:\n%s", ops)
}
}
// readHTTPAPILog 读某类日志今天的文件内容;文件不存在返回空串。
func readHTTPAPILog(t *testing.T, dir, category string) string {
t.Helper()
path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
+64 -4
View File
@@ -222,7 +222,10 @@ func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
}
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
// 编码与名字落库前先裁掉首尾空白:编码是导入按 team_code 匹配的键,
// 「 A 」与「A」肉眼一样却互不相等(UNIQUE 按字节比),现场只会看到「单位不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
@@ -260,6 +263,12 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
// 更新与新建同口径:必填校验 + 裁空白。漏了这一段,一次 PUT 就能把单位的 code/name
// 清成空串或纯空格(编码是导入的匹配键,清掉之后导入再也认不出这个单位)。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
@@ -441,7 +450,8 @@ func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
}
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
// 与 isUniqueViolation 的区别不是「谁更严」,而是**按索引名认人**:这里只看错误串里有没有
// "athletes.id_card",好把证件号冲突的文案与号码布冲突(isBibConflict)及其它唯一冲突分开。
func isIDCardConflict(err error) bool {
if err == nil {
return false
@@ -639,6 +649,8 @@ func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, erro
defer func() { _ = tx.Rollback() }()
// 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。
// 这个局部 scope 与下面的包级 purgeScope 完全同构(P2-C24c)——合并不改任何行为,
// 但属于代码改动,本轮只按注释记一笔,留给下一轮统一。
type scope struct{ eventID, categoryID int64 }
scopeRows, err := tx.QueryContext(ctx,
`SELECT DISTINCT e.event_id, e.category_id
@@ -753,6 +765,17 @@ const (
athleteBatchPurge = "purge"
)
// maxAthleteBatch 是单次批量动作的人数上限,与 maxEntryBatch(报名项,entries_batch.go)同量级:
// 前端一次勾选几十人是常态,500 足够覆盖「一个单位全选」,再大说明是脚本误用或误粘贴。
//
// 不直接复用 maxEntryBatch:那个常量从名字到文案都是「报名项」,拿它去限「人」会把两类对象说混,
// 操作员照着提示去报名表里找问题只会更糊涂。数值刻意保持一致,同族的批量接口用同一把尺子,
// 前端不必为每个接口记不同的分批阈值(上限值本身的分批依据与 entries_batch.go 的那条注释同源)。
//
// 没有这层上限时,几万条 id 会先被拼成一条 IN (...) 的巨型 SQL:撞穿 SQLite 的变量上限,
// 报出来是 500,而且单连接库会被这条 SQL 长时间占住(审查 D03 实测 4 万条即触发)。
const maxAthleteBatch = 500
// athleteBatchItem 是批量动作里逐条的结果。
//
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
@@ -852,6 +875,11 @@ func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int6
if len(ids) == 0 {
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
}
// 上限拦在「读库 / 开事务」之前,与 entries_batch.go:73 同一个位置口径:
// 超限的请求不该先查一遍 id 是否存在、再拼出一条巨 SQL 去占住单连接。
if len(ids) > maxAthleteBatch {
return athleteBatchResult{}, badRequest(fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", maxAthleteBatch), nil)
}
if action == athleteBatchPurge {
return s.batchPurgeAthletes(ctx, ids, dryRun)
}
@@ -1109,7 +1137,8 @@ func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
}
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
@@ -1119,6 +1148,12 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error)
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
// default_profile_id 指向 scoring_profiles(id)(迁移 0004):指到不存在的模板时,
// 不认这个外键错就报 500「服务端异常」,而同一类错在 events.sport_id、channels.profile_id
// 上都是 400。按 badRequest 报出来,现场才知道该去改哪个字段。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
if err != nil {
return sportDTO{}, internal(err)
}
@@ -1130,6 +1165,11 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error)
}
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
// 更新与新建同口径:必填校验 + 裁空白,否则一次 PUT 就能把大项编码清成空串。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
WHERE id = ?`,
@@ -1137,6 +1177,10 @@ func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportD
if isUniqueViolation(err) {
return sportDTO{}, conflict("大项编码已存在")
}
// 同新建:改到一个不存在的模板 id 上是 400(字段名 + 原值),不是 500。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
if err != nil {
return sportDTO{}, internal(err)
}
@@ -1247,7 +1291,8 @@ func validateAwardRanks(awardRanks int) error {
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tieMode, err := normalizeTieMode(in.TieMode)
@@ -1300,6 +1345,13 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
// 与新建同口径。这里不是「顺手补个校验」:清空 code 的模板会让 admin 的
// GET /api/rules/export 与 project.zip 永久 500(rules/validate.go 仍要求 code 非空),
// 而 PUT 是 recorder 档——低级角色能把管理员的导出打成不可用,且没有任何提示。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return profileDTO{}, badRequest("code 与 name 必填", nil)
}
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
@@ -1352,10 +1404,18 @@ func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
// 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE,
// 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。
// 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。
seen := make(map[int]bool, len(rules))
for _, r := range rules {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
if seen[r.Rank] {
return badRequest(fmt.Sprintf("积分名次 %d 重复", r.Rank), r)
}
seen[r.Rank] = true
if _, err := tx.ExecContext(ctx,
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
profileID, r.Rank, r.Points); err != nil {
+43 -1
View File
@@ -92,7 +92,11 @@ func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
// 与单位 / 大项 / 模板同口径:必填校验前先裁掉首尾空白,落库的也是裁过的值。
// 编码是基础数据包按 event_code 匹配的键,带空格的编码与肉眼相同的正常编码并存时,
// 导入只会报「小项不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
@@ -159,6 +163,12 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
// 与新建同口径:更新路径此前完全不校验,一次 PUT 就能把小项编码清成空串
// (规则包/项目包导出与导入匹配都会因此出错),且错误要到很久以后才暴露。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
@@ -298,6 +308,9 @@ func multiplierOrOne(v float64) float64 {
}
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
//
// 它没跟其它枚举校验一起进 enums.go,也不在比对前 TrimSpace:`" valid"` 这类值会被当成
// 非法直接拒成 400,而不是裁掉空格收下(P2-C24b)。
func normalizeAvgMode(v string) (string, error) {
switch v {
case "":
@@ -312,6 +325,11 @@ func normalizeAvgMode(v string) (string, error) {
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
// 只读接口也先确认小项存在:否则它给 200 空数组,而同一件事在整表替换上是
// 404 / 500 / 400 三种答案(见 replaceCategories 的注释)。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
@@ -364,6 +382,13 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
}
defer func() { _ = tx.Rollback() }()
// 先确认小项存在:这是同一个「小项不存在」的四分之三——空表走到末尾 bumpEvent 才是 404,
// 非空表会一路撞 event_categories.event_id 的外键,报成 500「服务端异常」。
// 在同一个事务里读,免得检查完、DELETE 前对方把小项删掉。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
@@ -577,6 +602,10 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
// 与 listCategories 同口径:小项不存在就 404,不给「200 空数组」。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals
@@ -625,6 +654,12 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
}
defer func() { _ = tx.Rollback() }()
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
@@ -778,6 +813,13 @@ func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int
if len(ids) == 0 {
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
}
// 与 POST /api/entries/batch 同一把尺子、同一句文案(直接复用 maxEntryBatch):
// 两个接口处理的都是「一批报名项」,上限不该各说各话。
// 位置与 entries_batch.go:73 一致——拦在任何一次读库 / 写库之前,先于下面那条
// IN (…) 的巨型 SQL,几万条 id 的请求不会被拼进 SQL 再被拒。
if len(ids) > maxEntryBatch {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
}
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
}
@@ -0,0 +1,66 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// openEventDBForRepoTest 建一个跑过迁移的赛事库,外键按生产口径打开(DSN 里的 foreign_keys(1))。
func openEventDBForRepoTest(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite",
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return db
}
func assertNotFound(t *testing.T, err error, what string) {
t.Helper()
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("%s 应返回业务错误,得到 %v", what, err)
}
if api.status != http.StatusNotFound || api.message != "小项不存在" {
t.Fatalf("%s 应 404「小项不存在」,得到 %d %s", what, api.status, api.message)
}
}
// 整表替换这一层自己也要拦住不存在的小项。
//
// HTTP 层现在会先经 listCategories / listChannels 拿到 404(handler 读 before),
// 所以这一条在端到端用例里看不到差别;但 repo 函数是独立入口,不能靠「调用方恰好先读了一次」
// 兜底——漏了它,非空表会撞外键报 500、空表则要走到末尾 bumpEvent 才是 404。
func TestReplaceCategoriesAndChannelsRejectMissingEvent(t *testing.T) {
s := &server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), ev: openEventDBForRepoTest(t)}
ctx := context.Background()
const missing = 99999
_, err := s.replaceCategories(ctx, missing, []categoryDTO{{Name: "男子", Multiplier: 1}})
assertNotFound(t, err, "replaceCategories(非空表)")
_, err = s.replaceCategories(ctx, missing, nil)
assertNotFound(t, err, "replaceCategories(空表)")
_, err = s.replaceChannels(ctx, missing, []channelDTO{{Scope: "athlete", Basis: "result", ProfileID: 1}})
assertNotFound(t, err, "replaceChannels(非空表)")
_, err = s.replaceChannels(ctx, missing, nil)
assertNotFound(t, err, "replaceChannels(空表)")
}
@@ -0,0 +1,86 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 「小项不存在」在组别 / 通道这一族接口上必须只有一个答案:404「小项不存在」。
//
// 改前是四个:组别整表替换(非空表)撞外键报 500;组别整表替换(空表)走到末尾
// bumpEvent 才是 404;通道整表替换报 400「profileId 不存在」(profileId 本身没问题,
// 缺的是小项,照着提示去查积分模板只会白费功夫);两个列表接口给 200 空数组。
func TestNonexistentEventIsNotFoundOnCategoryAndChannelEndpoints(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
const missing = 99999
cats := fmt.Sprintf("/api/events/%d/categories", missing)
chans := fmt.Sprintf("/api/events/%d/channels", missing)
for _, tc := range []struct {
label string
method string
path string
body any
}{
{"组别列表", http.MethodGet, cats, nil},
{"通道列表", http.MethodGet, chans, nil},
{"组别整表替换(非空表)", http.MethodPut, cats,
map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}},
{"组别整表替换(空表)", http.MethodPut, cats,
map[string]any{"categories": []map[string]any{}}},
{"通道整表替换(非空表)", http.MethodPut, chans,
map[string]any{"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}}}},
{"通道整表替换(空表)", http.MethodPut, chans,
map[string]any{"channels": []map[string]any{}}},
} {
status, resp := env.do(client, tc.method, tc.path, tc.body, true)
if status != http.StatusNotFound || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s 对不存在的小项应 404,得到 %d %+v", tc.label, status, resp.Error)
}
if resp.Error.Message != "小项不存在" {
t.Fatalf("%s 的 404 文案应是「小项不存在」,得到 %q", tc.label, resp.Error.Message)
}
}
// 对照:真小项上照常可用,免得这条校验把合法请求一起挡掉。
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/categories", ids.event), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的组别列表应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "P2A-CTRL", "name": "对照小项",
"eventType": "individual", "resultMode": "auto", "resultType": "time",
"compare": "asc", "scored": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建对照小项应 200,得到 %d %+v", status, resp.Error)
}
ctrl := int64(decodeData[map[string]any](t, resp)["id"].(float64))
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", ctrl), map[string]any{
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的组别整表替换应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("真小项的通道整表替换应 200,得到 %d %+v", status, resp.Error)
}
// 通道整表替换的「profileId 不存在」仍要指对字段:小项存在、模板不存在才是 400 指向 profileId。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 99999}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "profileId") {
t.Fatalf("模板不存在应 400 并点明 profileId,得到 %d %+v", status, resp.Error)
}
}
+215 -15
View File
@@ -53,8 +53,8 @@ type athleteImportMatch struct {
Skip bool
}
// sameNameNewPerson 是「身份字段不同 → 按同名的另一个人新建」的清单项:
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
// sameNameNewPerson 是「新建的档案与库中已有的同名人员疑似重复」的清单项:
// 导入响应里单独列出来给现场核对,避免新建了档案却毫无提示。
// 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。
type sameNameNewPerson struct {
Name string `json:"name"`
@@ -63,8 +63,22 @@ type sameNameNewPerson struct {
ExistingIDCard string `json:"existingIdCard"`
NewBib string `json:"newBib"`
ExistingBib string `json:"existingBib"`
// Reason 说明为什么判成「另一个人」:identity = 证件号 / 号码布两边都有值且对不上;
// team = 这个单位下一个人都没有,但别的单位下有同名档案(多半是换了单位)。
// 现场核对的重点完全不同,所以两者要分得开。
Reason string `json:"reason"`
// ExistingTeamCode 是库中那条同名档案的单位编码(换单位场景要核对的就是它)。
ExistingTeamCode string `json:"existingTeamCode"`
}
// sameNameReason* 是 sameNameNewPerson.Reason 的取值。
const (
// sameNameReasonIdentity:表里的证件号(或号码布)与库中那条同名档案对不上 —— 多半是号写错了。
sameNameReasonIdentity = "identity"
// sameNameReasonTeam:本次这个单位下查不到同名的,但别的单位下有 —— 换了单位(也可能真是两个人同名)。
sameNameReasonTeam = "team"
)
// existingAthlete 是库中已有人员的可比字段。
type existingAthlete struct {
ID int64
@@ -110,7 +124,7 @@ func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow,
}
// matchByNameTeam 处理「证件号与号码布都没命中」的情况,按姓名 + 单位找候选:
// 0 条 → 新建;1 条且身份字段有一边没填或都对得上 → 命中并回填;
// 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)
@@ -119,7 +133,19 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
}
switch len(candidates) {
case 0:
return s.createImportedAthlete(ctx, in)
created, err := s.createImportedAthlete(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
// 换了单位的人正是走到这一支:这个单位下查不到同名的,别的单位下却有一个。
// 不能替用户认人(同名不同单位往往真是两个人),但更不能悄悄多建一份档案 ——
// 两份档案各自带报名,成绩与积分从此分家(清单 A13)。
reported, err := s.sameNameElsewhere(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
case 1:
ex := candidates[0]
// 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」:
@@ -130,14 +156,11 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(ex.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(ex.Bib),
reported, err := s.sameNameReport(ctx, in, ex, sameNameReasonIdentity)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
@@ -146,6 +169,41 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
}
}
// sameNameReport 组装一条「新建的档案与库中这条同名人员疑似重复」的报告。
// existing 是库中那条同名档案(同单位的候选,或跨单位找到的那条),reason 说明为什么怀疑重复。
func (s *server) sameNameReport(ctx context.Context, in athleteImportRow, existing existingAthlete, reason string) (*sameNameNewPerson, error) {
code, err := s.teamCodeByID(ctx, existing.TeamID)
if err != nil {
return nil, err
}
return &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(existing.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(existing.Bib),
Reason: reason,
ExistingTeamCode: code,
}, nil
}
// sameNameElsewhere 找库中同名、但不在本次这个单位下的人员,用于报告「换了单位」造成的新建档案。
// 只报第一条(与多人候选只以第一条为代表同一口径):现场要核对的是「这个名字是不是本来就该是
// 同一个人」,列一串同名人反而看不出重点。
func (s *server) sameNameElsewhere(ctx context.Context, in athleteImportRow) (*sameNameNewPerson, error) {
ex, err := scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id <> ? ORDER BY id LIMIT 1`,
in.Name, in.TeamID))
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, internal(err)
}
return s.sameNameReport(ctx, in, ex, sameNameReasonTeam)
}
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
@@ -329,8 +387,12 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
if ex.Name != in.Name {
changed = append(changed, "name")
}
// 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
// 性别与证件号/号码布同一口径(清单 A09):表里没填(含填了 男/女 以外的值)= 不参与比较;
// 库中为空 = 待回填,不算差异。之前是「库里为空就算差异」,一份没填性别列的旧名单会让
// 所有库里性别为空的人全掉进冲突面板(这些人本来还在静默回填证件号/号码布/年龄),
// 而性别又从不回填 —— 现场只能逐条点「使用新数据」,与 devdocs/05 §7 的匹配表相反。
// 只有两边都有值且不同(男 ↔ 女)才是真冲突。
if in.Gender != "" && ex.Gender != nil && *ex.Gender != "" && *ex.Gender != in.Gender {
changed = append(changed, "gender")
}
// 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。
@@ -354,7 +416,7 @@ func identityDiffers(dbVal *string, inVal string) bool {
return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal
}
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
// 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 {
@@ -379,6 +441,13 @@ func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthl
return internal(err)
}
}
// 性别与证件号/号码布/年龄一并回填:历史名单里性别为空是常态,口径就是「库中为空 = 待回填」,
// 不回填就等于把这批人永远留在空性别上,下一份表再导入还会被当成差异(清单 A09)。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET gender = ? WHERE id = ?`, in.Gender, ex.ID); err != nil {
return internal(err)
}
}
return nil
}
@@ -456,6 +525,18 @@ type entryImportOutcome struct {
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
StatusChanged bool
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
MovedEntries []movedSameScopeEntry
}
// movedSameScopeEntry 报告一条「换单位后旧单位那条报名已被移出」。
// 姓名与新/旧单位都要带上:现场要核对的正是「这个人是不是真的换了单位」。
type movedSameScopeEntry struct {
Name string
OldTeamCode string
NewTeamCode string
}
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
@@ -465,10 +546,111 @@ func entryStatusRankable(status string) bool {
return status == "" || status == string(domain.EntryStatusRegistered)
}
// staleScopeEntry 是「同一个人 + 同一个小项 + 同一个组别」下、单位与本次导入不同的旧报名。
type staleScopeEntry struct {
ID int64
Name string
TeamCode string
}
// releaseStaleSameScopeEntries 在给这个人**新建**报名之前,处置同一个小项同一个组别里
// 单位与本次不同的旧报名。
//
// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:keep-new 把人员
// 改到新单位后,旧单位那条报名会原样留着 —— 同一个人在同一个组别出现两条,成绩录入页看得见
// 两条,现场各录一次成绩就是同一人同一组别两条成绩、重复计分(清单 A10);
// docs/报名名单导入规范.md 也承诺同一个项目的报名「不会产生重复报名」。
//
// 处置只有两种,都不许凭空销毁数据:
// - 旧报名没有任何成绩痕迹(results / score_ledger / record_breaks 全空)→ 按批量移出的口径
// 物理移出。它只是一条过期的名单行,删掉不丢任何数据;新单位这条紧接着新建,
// 调用方拿到 Created 就会重算这一组(与 recomputeImportedScopes 同一口径,
// 团体平均口径的分母也跟着这一增一减重算)。
// - 旧报名已经留下成绩痕迹 → 一行不动并拦下本行:删掉等于抹掉已经产生的名次与积分,
// 那属于「成绩 / 榜单」的处置,必须由用户到成绩录入页用批量移出显式做
// (removeEntriesTx 会连带清流水并重算),导入不许替他做这个决定。
//
// 返回被移出的旧报名(可能为空):调用方要拿它给操作员一条 notices 提示(清单 A10)。
func (s *server) releaseStaleSameScopeEntries(ctx context.Context, in entryDTO) ([]staleScopeEntry, error) {
// 团体行(athlete_id 为空)不受影响:同一个组别里两个单位各报一支队伍本来就是正常的。
if in.AthleteID == nil {
return nil, nil
}
rows, err := s.ev.QueryContext(ctx,
`SELECT e.id, a.name, t.code FROM entries e
JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? AND e.category_id = ? AND e.athlete_id = ? AND e.team_id <> ?
ORDER BY e.id`, in.EventID, in.CategoryID, *in.AthleteID, in.TeamID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
stale := make([]staleScopeEntry, 0, 1)
for rows.Next() {
var item staleScopeEntry
if err := rows.Scan(&item.ID, &item.Name, &item.TeamCode); err != nil {
return nil, internal(err)
}
stale = append(stale, item)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
for _, item := range stale {
scored, err := s.entryHasResultTraces(ctx, item.ID)
if err != nil {
return nil, err
}
if scored {
return nil, staleScoredEntryError(item)
}
}
for _, item := range stale {
// 上面已确认这条旧报名没有任何成绩痕迹,所以只删报名行本身:批量移出(removeEntriesTx)
// 还要连带清流水 / 成绩 / 破纪录,是因为那边允许移出已有成绩的报名,这里没有可清的。
if _, err := s.ev.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, item.ID); err != nil {
// 并发下刚有人给这条旧报名录了成绩:results 对 entries 没有 ON DELETE,外键会挡住删除。
// 那就按「已有成绩」处理并拦下本行,绝不强行删掉别人的成绩痕迹。
if isForeignKeyViolation(err) {
return nil, staleScoredEntryError(item)
}
return nil, internal(err)
}
}
return stale, nil
}
// entryHasResultTraces 判断一条报名是否已经留下成绩痕迹:录过成绩(草稿也算)、
// 有过流水、或登记过破纪录,任意一样成立就不再是「可以随手移出的过期名单行」。
func (s *server) entryHasResultTraces(ctx context.Context, entryID int64) (bool, error) {
var traces int
if err := s.ev.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM results WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM score_ledger WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM record_breaks WHERE entry_id = ?)`,
entryID, entryID, entryID).Scan(&traces); err != nil {
return false, internal(err)
}
return traces != 0, nil
}
// staleScoredEntryError 是「旧报名已有成绩」时的行级提示:不能只说不能做,必须给出下一步。
// 返回纯 error 而不是 apiError:调用方(handlers_config.go 的导入主循环)对 upsert 的失败
// 用 err.Error() 原样回显,包成 apiError 会多出「CONFLICT:」前缀,现场看不懂。
func staleScoredEntryError(item staleScopeEntry) error {
return fmt.Errorf("%s 在本组别还有一条单位 %s 的报名(已录过成绩),换单位后同一个人在同一组别不能留两条;请先到成绩录入页把单位 %s 那条移出,再重新上传这一行",
item.Name, item.TeamCode, item.TeamCode)
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
//
// 唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:新建之前必须先处置
// 同一个小项同一个组别里另一单位的那条旧报名(见 releaseStaleSameScopeEntries)。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) {
var (
existing int64
@@ -488,6 +670,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
status := orDefault(in.Status, "registered")
switch {
case errors.Is(err, sql.ErrNoRows):
moved, err := s.releaseStaleSameScopeEntries(ctx, in)
if err != nil {
return entryImportOutcome{}, err
}
if _, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -495,7 +681,21 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, nil
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
if len(moved) > 0 {
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
// 平时的导入不为它付代价。
newCode, err := s.teamCodeByID(ctx, in.TeamID)
if err != nil {
return entryImportOutcome{}, err
}
for _, item := range moved {
outcome.MovedEntries = append(outcome.MovedEntries, movedSameScopeEntry{
Name: item.Name, OldTeamCode: item.TeamCode, NewTeamCode: newCode,
})
}
}
return outcome, nil
case err != nil:
return entryImportOutcome{}, err
default:
+405
View File
@@ -0,0 +1,405 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件覆盖报名导入里三条联动口径(清单 2026-09-11 的 A09 / A10 / A13):
// - A09 性别:库中为空 = 待回填(不算冲突),表里没填 = 不参与比较,库中 ↔ 表里都是男/女且不同才是真冲突;
// - A10 换单位:同一个人在同一个小项同一个组别只留一条报名,旧报名已有成绩时不许悄悄删;
// - A13 换单位(没号码布):新建的第二份档案必须在 sameNameNewPersons 里报告出来。
//
// 断言全部落到库里的字段值与榜单副作用上,不停在 HTTP 200。
// importGenderRow 拼一行「M100 / 男子 / 单位 A / 张伟」,只给号码布与性别(性别可为空串)。
// 表头顺序见 importHeader:event_code, category, team_code, athlete_name, id_card, gender, age, bib, seed, status, member_names。
func importGenderRow(gender string, bib string) string {
return "M100,男子,A,张伟,," + gender + ",," + bib + ",,registered,\n"
}
// A09:库里性别为空、表里填了 男 → 命中同一人并**回填**性别,不产生冲突。
// 历史名单里性别为空是常态,按旧口径这些人会整批掉进冲突面板(而性别又从不回填),
// 现场只能逐条点「使用新数据」。
func TestImportBackfillsEmptyGenderInsteadOfConflict(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 种子人员张伟在库里性别是 男,先清空成历史数据的样子(UI 允许性别为空)。
env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA)
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("男", "A900"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("库里性别为空不该算差异: %+v", conflicts)
}
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("应命中已有人员并更新报名: %+v", result)
}
var gender sql.NullString
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if !gender.Valid || gender.String != "男" {
t.Fatalf("表里给了性别就该回填,得到 %+v", gender)
}
// 回填与报名同一条路径:库里那条报名照样在,人没有被拦在冲突里。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = ?`, ids.event, ids.athleteA); n != 1 {
t.Fatalf("应有 1 条报名,得到 %d", n)
}
// 号码布一并回填,证明走的是「命中并补空字段」那条路,而不是别的新建路径。
var bib sql.NullString
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
}
if !bib.Valid || bib.String != "A900" {
t.Fatalf("号码布应一并回填,得到 %+v", bib)
}
}
// A09:表里性别列留空 → 不参与比较:既不算冲突,也不去清库里的值(库中为空就保持为空)。
func TestImportBlankGenderColumnIsNotCompared(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA)
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("", "A900"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("表里没填性别不该算差异: %+v", conflicts)
}
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("应照常更新报名: %+v", result)
}
var gender sql.NullString
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender.Valid {
t.Fatalf("表里没填性别就不该写库,得到 %+v", gender)
}
}
// A09:真正的性别冲突(库里 男 ↔ 表里 女)仍要进冲突面板,不能被新口径放过。
func TestImportRealGenderConflictStillGoesToPanel(t *testing.T) {
env, ids, client := recorderWithSeed(t)
row := "M100,男子,A,张伟,,女,,A900,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+row, "")
if status != http.StatusOK || !env2.OK {
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("男 ↔ 女必须进冲突面板,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "gender" {
t.Fatalf("差异应只有 gender,得到 %+v", changed)
}
if result["updated"] != float64(0) || result["created"] != float64(0) {
t.Fatalf("冲突行不该落库: %+v", result)
}
var gender string
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender != "男" {
t.Fatalf("未决策前不该改库,得到 %s", gender)
}
// 决策「使用新数据」:库里的性别才被覆盖成表里的值。
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", importHeader+"\n"+row,
`[{"bib":"A900","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("keep-new 应更新已有报名: %+v", result)
}
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("查询人员性别: %v", err)
}
if gender != "女" {
t.Fatalf("keep-new 应用表里的性别覆盖,得到 %s", gender)
}
}
// A10:换单位 + keep-new 后,同一个人在同一个小项同一个组别只留一条报名。
// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开唯一索引:
// 旧单位那条报名会原样留着,现场各录一次成绩就是同一人同一组别两条成绩、重复计分。
func TestImportTeamChangeKeepsOneEntryPerScope(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
// 前置:张伟在「男子」组有一条 A 单位的报名(种子 entryA),没有成绩。
pushesBefore := standingsPushes(t, env)
body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
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["updated"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("换单位应在新单位下建这条报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
// 数据对了还不够:旧单位的报名被移出这件事必须在导入结果里讲出来,
// 否则现场只看到「旧单位那条不见了」,分不清是移出还是丢了(清单 A10)。
notices, _ := result["notices"].([]any)
if len(notices) != 1 {
t.Fatalf("换单位应给 1 条提示,得到 %+v", notices)
}
want := "第 2 行:张伟 换了单位,原单位 A 在本组别的报名已移出,已改在单位 B 加入"
if notices[0] != want {
t.Fatalf("提示应写明姓名与新/旧单位,want %q got %q", want, notices[0])
}
// 旧单位那条过期的报名行被移出(它没有任何成绩痕迹,删掉不丢数据)。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 0 {
t.Fatalf("旧单位的过期报名应被移出,得到 %d 条", n)
}
// 同一人在同一组别只剩新单位那一条。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.athleteA); n != 1 {
t.Fatalf("同一人同一组别只应留一条报名,得到 %d 条", n)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM entries WHERE event_id = ? AND athlete_id = ?`,
ids.event, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询留下的报名: %v", err)
}
if teamID != ids.teamB {
t.Fatalf("留下的那条应属于新单位,得到 %d", teamID)
}
// 别人的报名不受影响。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 {
t.Fatalf("别的报名不该被牵连,得到 %d 条", n)
}
// 名单一增一减动了这一组的参赛成员 → 必须重算并推榜(与 recomputeImportedScopes 同一口径)。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("换单位应重算这一组并推 1 次榜单,得到 %d", got)
}
// 已经在 B 单位了,再传同一份表:只是就地更新,没有谁被移出,不该再冒这条提示。
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, env2.Error)
}
again := decodeData[map[string]any](t, env2)
if again["updated"] != float64(1) || again["created"] != float64(0) {
t.Fatalf("重传应就地更新: %+v", again)
}
if notices, _ := again["notices"].([]any); len(notices) != 0 {
t.Fatalf("没有报名被移出就不该有提示,得到 %+v", notices)
}
}
// A10:旧单位那条报名已经录过成绩 → 一行不动并拦下本行。
// 删掉它等于抹掉已经产生的名次与积分,那是「成绩 / 榜单」的处置,必须由用户用批量移出显式做。
func TestImportTeamChangeWithScoredOldEntryIsBlocked(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
publishResults(t, env, client, ids.event, "import-team-change",
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 got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("前置条件:张伟应是第 1 名 10 分,得到 %v", got)
}
pushesBefore := standingsPushes(t, env)
body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body,
`[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`)
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["updated"] != float64(0) || result["skipped"] != float64(1) {
t.Fatalf("这一行应被拦下(不建第二条报名),得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("应给出一条行级提示,得到 %+v", errs)
}
line, _ := errs[0].(string)
if !strings.Contains(line, "移出") || !strings.Contains(line, "第 2 行") {
t.Fatalf("提示要带行号并说清下一步(先把旧报名移出),得到 %q", line)
}
// 旧报名与它的成绩、流水、名次、积分一个都没动。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
t.Fatalf("已有成绩的旧报名不该被删,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, ids.entryA); n != 1 {
t.Fatalf("旧报名的已发布成绩应原样保留,得到 %d 条", n)
}
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("旧报名的流水应原样保留,得到 %v", got)
}
if got := rankOfEntry(t, env, ids.entryA); got != 1 {
t.Fatalf("旧报名的名次应原样保留,得到 %d", got)
}
// 新单位下没有多出第二条报名。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND team_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.teamB, ids.athleteA); n != 0 {
t.Fatalf("被拦下的行不该落库,得到 %d 条", n)
}
// 本行没有落库,就没有「谁还算数」的变化:不重算、不推榜。
if got := standingsPushes(t, env) - pushesBefore; got != 0 {
t.Fatalf("本行未落库不该推榜,得到 %d", got)
}
// 现场怎么补救:先在成绩录入页把旧报名移出(会连带清成绩与流水并重算),再重新上传这一行。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryA}, "reason": "换单位,移出旧报名",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量移出应 200,得到 %d %+v", status, env2.Error)
}
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 n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`,
ids.event, ids.category, ids.athleteA); n != 1 {
t.Fatalf("补救后同一人同一组别应只有一条报名,得到 %d 条", n)
}
}
// A13:没号码布的人换了单位 → 这个单位下查不到同名的,会新建第二份档案,
// 必须把「别的单位下有个同名档案」列进 sameNameNewPersons,不能悄悄多一个人。
func TestImportTeamChangeReportsSameNameElsewhere(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 种子张伟在 A 单位,既没有号码布也没有证件号;表里换到 B 单位并带上他的证件号与号码布。
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,B900,,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("换单位新建的档案必须报告出来,得到 %+v", reported)
}
item := reported[0].(map[string]any)
want := map[string]any{
"name": "张伟", "teamCode": "B", "reason": "team", "existingTeamCode": "A",
"newIdCard": "110101199001011234", "existingIdCard": "",
"newBib": "B900", "existingBib": "",
}
for key, value := range want {
if item[key] != value {
t.Fatalf("报告字段 %s 应为 %q,得到 %+v", key, value, item)
}
}
// 两份档案都还在(新建是既有口径,这一条修的是「不许无声」),老档案一点没被动。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE name = '张伟'`); n != 2 {
t.Fatalf("应有 2 份同名档案,得到 %d 份", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND team_id = ? AND id_card IS NULL AND bib IS NULL`,
ids.athleteA, ids.teamA); n != 1 {
t.Fatalf("A 单位那份老档案不该被改,得到 %d 份", n)
}
}
// A13 的反例保护:同单位、同名、身份字段对不上 → 报告的原因是 identity,不能被跨单位这条口径顶掉。
func TestImportSameTeamSameNameStillReportsIdentityReason(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)
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("同名不同号码布应报告 1 条,得到 %+v", reported)
}
item := reported[0].(map[string]any)
if item["reason"] != "identity" || item["existingTeamCode"] != "A" {
t.Fatalf("同单位这条应报 identity 并带上库中单位,得到 %+v", item)
}
}
// A09 的现场形态:一整份没有性别列的旧名单先导一遍,第二天补上性别再传一遍 ——
// 第二遍必须零冲突、全员回填,且一个人都不会变成两份报名。
func TestImportRosterWithoutGenderThenBackfillsAtScale(t *testing.T) {
env, ids, client := recorderWithSeed(t)
const rows = 60
build := func(gender string) string {
body := importHeader + "\n"
for i := 0; i < rows; i++ {
body += fmt.Sprintf("M100,男子,A,选手%02d,11010119900101%04d,%s,,B%03d,,registered,\n",
i, i+1, gender, i+1)
}
return body
}
status, env2 := env.postImport(t, client, "/api/entries/import", build(""), "")
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(rows) || result["skipped"] != float64(0) {
t.Fatalf("第一遍应新建 %d 条报名: %+v", rows, result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("第一遍不该有冲突: %+v", conflicts)
}
// 第一遍的性别列整列没填 → 新档案里就是空性别(历史数据的常态)。
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender IS NULL`, ids.teamA); n != rows {
t.Fatalf("应有 %d 人性别为空,得到 %d", rows, n)
}
// 第二遍补上性别:库里性别为空 = 待回填,一个人都不该掉进冲突面板。
status, env2 = env.postImport(t, client, "/api/entries/import", build("男"), "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("第二遍导入应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("库里性别为空不该算差异,第二遍不该有冲突: %+v", conflicts)
}
if result["updated"] != float64(rows) || result["created"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("第二遍应就地更新 %d 条: %+v", rows, result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender = '男'`, ids.teamA); n != rows+1 {
t.Fatalf("应有 %d 人回填成 男,得到 %d", rows+1, n)
}
// 重传同一份表不会把名单变成两份:加了换单位去重后,同一个人在同一组别始终只留一条报名。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, ids.event); n != rows+2 {
t.Fatalf("报名总数应是 %d(%d 行 + 种子两人的报名),得到 %d", rows+2, rows, n)
}
}
+262
View File
@@ -490,3 +490,265 @@ func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) {
}
}
}
// assertCodeNameRequired 钉住「必填」的三态:传空串、传纯空格、整段漏传,都必须是
// 400 而不是「200 + 把库里的编码清掉」。历史伤疤:漏传被当成清空,编码是导入匹配的键,
// 清掉之后导入只会报「单位不存在」,而现场当场看不出是刚才那次编辑干的。
func assertCodeNameRequired(t *testing.T, env *testEnv, client *http.Client, what, path, table string, id int64, base map[string]any) {
t.Helper()
for _, bad := range []struct {
label string
code string
name string
}{
{"空串", "", ""},
{"纯空格", " ", " "},
{"漏传", "", ""}, // code/name 两个键都不出现在请求体里
} {
body := map[string]any{}
for k, v := range base {
body[k] = v
}
if bad.label != "漏传" {
body["code"], body["name"] = bad.code, bad.name
}
status, resp := env.do(client, http.MethodPut, path, body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s code/name %s 应 400 VALIDATION_FAILED,得到 %d %+v", what, bad.label, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("%s code/name %s 的 400 文案应点明必填,得到 %q", what, bad.label, resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM `+table+` WHERE id = ?`, id).Scan(&code); err != nil {
t.Fatalf("读%s编码: %v", what, err)
}
if strings.TrimSpace(code) == "" {
t.Fatalf("%s 被拒的请求不该改库中编码,得到 %q", what, code)
}
}
// PUT 单位 / 大项 / 小项时,code 与 name 是必填:空串、纯空格、漏传一律 400,库里的编码不动。
// 改前这三类请求都是 200,落库 code=""(或 " ")——小项与单位的编码正是导入按
// team_code / event_code 匹配的键,清空之后导入永远报「不存在」。
func TestUpdateRequiresCodeAndName(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
assertCodeNameRequired(t, env, client, "单位", fmt.Sprintf("/api/teams/%d", ids.teamA), "teams", ids.teamA, nil)
assertCodeNameRequired(t, env, client, "大项", fmt.Sprintf("/api/sports/%d", ids.sport), "sports", ids.sport, nil)
assertCodeNameRequired(t, env, client, "小项", fmt.Sprintf("/api/events/%d", ids.event), "events", ids.event,
map[string]any{"sportId": ids.sport})
// 对照:合法的更新照常 200,且首尾空白被裁掉后再落库(编码「 A2 」与「A2」从此不可能并存)。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/teams/%d", ids.teamA), map[string]any{
"code": " A2 ", "name": " 第一分公司 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("带空白的合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "A2" || got["name"] != "第一分公司" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
// 新建侧同口径:POST 带空白的编码也按裁过的值落库。
status, resp = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": " WS ", "name": " 带空格 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "WS" || got["name"] != "带空格" {
t.Fatalf("新建应落裁过空白的值,得到 %+v", got)
}
}
// A01:PUT /api/scoring-profiles/{id} 空 code/name 必须是 400。
// 改前它是 200:recorder 一按保存就能把模板的 code 清空,而 rules/validate.go 仍要求
// code 非空 —— admin 的 GET /api/rules/export 与 project.zip 从此永久 500,且没有任何提示。
func TestUpdateProfileCodeRequiredKeepsRulesExportUsable(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
rec := recorderClient(t, env)
rulesExport := func() int {
t.Helper()
resp, err := admin.Get(env.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("取规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("改前规则包导出应 200,得到 %d", got)
}
for _, body := range []map[string]any{
{"code": "", "name": ""},
{"code": " ", "name": " "},
{"tieMode": "skip"}, // 漏传 code / name
} {
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%v 应 400 VALIDATION_FAILED,得到 %d %+v", body, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("400 文案应点明必填,得到 %q", resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM scoring_profiles WHERE id = 1`).Scan(&code); err != nil {
t.Fatalf("读模板编码: %v", err)
}
if code != "individual_top6" {
t.Fatalf("被拒的更新不该改库中编码,得到 %q", code)
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("被拒的更新之后规则包导出仍应 200,得到 %d", got)
}
// 对照:合法的更新照常 200,落库的是裁掉空白后的值。
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": " P1 ", "name": " 个人项目前六名 ",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "P1" || got["name"] != "个人项目前六名" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
}
// profileRulesView 取模板的名次表,用来断言「被拒的更新没有把名次删空」。
type profileRulesView struct {
Rules []struct {
Rank int `json:"rank"`
} `json:"rules"`
}
func profileRuleRanks(t *testing.T, env *testEnv, client *http.Client, id int64) []int {
t.Helper()
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", id), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读模板应 200,得到 %d %+v", status, resp.Error)
}
view := decodeData[profileRulesView](t, resp)
ranks := make([]int, 0, len(view.Rules))
for _, r := range view.Rules {
ranks = append(ranks, r.Rank)
}
return ranks
}
// rules[] 里两行同 rank 撞 scoring_profile_rules 主键 (profile_id, rank):
// 改前是 500 + 英文约束原文,现场只看到「服务端异常」,无从知道是哪一格填重了。
func TestProfileRulesDuplicateRankRejected(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "DUP-RANK", "name": "重复名次",
"rules": []map[string]any{{"rank": 1, "points": 10}, {"rank": 1, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "1") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'DUP-RANK'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 更新路径同口径;replaceProfileRules 是「先 DELETE 再 INSERT」,被拒时整个事务必须回滚,
// 否则模板会被存成「名次表空了」这种更坏的状态。
before := profileRuleRanks(t, env, client, 1)
if len(before) == 0 {
t.Fatalf("种子模板应自带名次表")
}
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名",
"rules": []map[string]any{{"rank": 2, "points": 9}, {"rank": 2, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "2") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if !strings.Contains(string(resp.Error.Detail), `"rank":2`) {
t.Fatalf("400 的 detail 应带上重复的那一行,得到 %s", resp.Error.Detail)
}
after := profileRuleRanks(t, env, client, 1)
if len(after) != len(before) {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
}
}
// defaultProfileId 指向不存在的模板:改前撞 scoring_profiles 的外键报 500「服务端异常」,
// 同类的 events.sport_id / channels.profile_id 都是 400。两条写入路径都要一致。
func TestSportDefaultProfileMustExist(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建指向不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "defaultProfileId") {
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM sports WHERE code = 'SWIM'`); n != 0 {
t.Fatalf("被拒的大项不该落库,得到 %d 行", n)
}
status, resp = env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 1, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建大项应 200,得到 %d %+v", status, resp.Error)
}
sportID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新到不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
var profileID sql.NullInt64
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 1 {
t.Fatalf("被拒的更新不该改默认模板,得到 %+v", profileID)
}
// 对照:指向真实模板照常可写。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 2, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("指向真实模板应 200,得到 %d %+v", status, resp.Error)
}
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 2 {
t.Fatalf("合法更新应落库 2,得到 %+v", profileID)
}
}
@@ -0,0 +1,74 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 8 MiB 上限必须落在最通用的解码路径上:三十多个写接口走的都是 decodeJSON,
// 此前只有 decodeJSONPresence(建人员 / 单位那几个)卡过大小,普通 JSON 写接口没有上限。
func TestDecodeJSONRejectsOversizedBody(t *testing.T) {
body := `{"name":"` + strings.Repeat("a", maxJSONBodyBytes) + `"}`
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(body))
var dst map[string]any
err := decodeJSON(req, &dst)
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("超限应返回业务错误,得到 %v", err)
}
if api.status != http.StatusRequestEntityTooLarge {
t.Fatalf("超限应 413(而不是笼统 400「不是合法 JSON」),得到 %d %s", api.status, api.message)
}
if api.message != "请求体超过上限" {
t.Fatalf("超限文案应说清是请求体太大,得到 %q", api.message)
}
}
// 上限不能顺手把正当请求拦掉:普通写请求照常解析。
func TestDecodeJSONAcceptsNormalBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings",
strings.NewReader(`{"event":{"name":"秋季运动会"}}`))
var dst struct {
Event struct {
Name string `json:"name"`
} `json:"event"`
}
if err := decodeJSON(req, &dst); err != nil {
t.Fatalf("正常请求体应解析成功: %v", err)
}
if dst.Event.Name != "秋季运动会" {
t.Fatalf("解析结果不对: %+v", dst)
}
}
// 非法 JSON 仍然是 400「请求体不是合法 JSON」,不要被超限分支吞掉。
func TestDecodeJSONStillReportsBadJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(`{"event":`))
var dst map[string]any
err := decodeJSON(req, &dst)
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("非法 JSON 应返回业务错误,得到 %v", err)
}
if api.status != http.StatusBadRequest || api.message != "请求体不是合法 JSON" {
t.Fatalf("非法 JSON 应 400「请求体不是合法 JSON」,得到 %d %s", api.status, api.message)
}
}
// 空体仍要报错(「没传参数」不是合法写操作)——加了上限不能改变这条既有口径。
func TestDecodeJSONStillRejectsEmptyBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(""))
var dst map[string]any
if err := decodeJSON(req, &dst); err == nil {
t.Fatal("空请求体应报错")
}
}
+12 -1
View File
@@ -195,9 +195,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。
// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId,
// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。
//
// 与 decodeJSONPresence 用同一把尺卡住请求体大小(maxJSONBodyBytes):
// 走这条更通用的路径的写接口有三十多个,此前一个上限都没有,
// 一次请求就能把几百 MB 读进内存(单进程服务,内存放大即整站不可用)。
func decodeJSON(r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
// 超限要报 413 而不是 400「不是合法 JSON」:前端据此提示「请求体过大」,
// 而不是让现场去查一个其实没写错的 JSON。
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return tooLarge("请求体超过上限")
}
return badRequest("请求体不是合法 JSON", err.Error())
}
return nil
@@ -205,7 +216,7 @@ func decodeJSON(r *http.Request, dst any) error {
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
// 配合 decodeJSONPresence 的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
const maxJSONBodyBytes = 8 << 20
// keySet 是「请求体里真的出现过哪些键」。
+96 -9
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
@@ -583,6 +584,10 @@ func preferComputedRank(a, b domain.RankedResult) bool {
// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft:
// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行,
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
//
// 落在这一档里的还有「填了文本却没有成绩值」的草稿(客户端没算出 resultValue):
// 同样发不出去,但外表上像是录过了,所以发布时要单独计数并写进修订(countTextOnlyDrafts),
// 不能让它静默消失。
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
@@ -675,6 +680,51 @@ func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bo
return published == total, nil
}
// eventFullyPublished 判断整小项是否「所有该出成绩的报名项都已发布」。
//
// 判据与结算同源(scopeStats):退赛 / 缺赛 / 失格的报名项不进分母,因为它们本来就不等成绩。
// 必须要求 total > 0——一条报名项都没有的小项没有任何成绩可发,标成「已发布」是假信息。
func eventFullyPublished(ctx context.Context, q queryer, eventID int64) (bool, error) {
total, published, err := scopeStats(ctx, q, eventID, 0)
if err != nil {
return false, err
}
return total > 0 && published == total, nil
}
// countTextOnlyDrafts 数出「这次发布带不上、看起来却已经录过」的草稿:
// 有成绩文本,却没有成绩值、没有手工名次、状态也还是 ok。
//
// 这批行按 publishableResultCond 永远发不出去(缺的正是那个比较值),而发布接口照回 200:
// 客户端只送 resultText 时成绩就这样静默漏掉。纯空行(连文本都没有)不算——
// 分批比赛里「还没跑」是正常状态,一起数进去只会淹没真正的问题。
func countTextOnlyDrafts(ctx context.Context, q queryer, eventID, categoryID int64) (int, error) {
query := `SELECT COUNT(*) FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ? AND r.state = 'draft'
AND r.result_value IS NULL AND r.rank IS NULL AND r.status = 'ok'
AND TRIM(COALESCE(r.result_text, '')) <> ''`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
var n int
if err := q.QueryRowContext(ctx, query, args...).Scan(&n); err != nil {
return 0, internal(err)
}
return n, nil
}
// skipNote 把「跳过多少条只填了文本的草稿」写成给现场看的一句话,没有跳过时为空串
// (空串不落显眼文案,变更历史里也不会多出一行无用说明)。
func skipNote(skipped int) string {
if skipped == 0 {
return ""
}
return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped)
}
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
var raw string
@@ -723,6 +773,18 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil {
return nil, internal(err)
}
// 没被带上的「只有成绩文本」的草稿要报出来:它们按 publishableResultCond 永远发不出去
// (既没有成绩值、也没有手工名次、状态还是 ok),而接口照样回 200。
// 客户端只送 resultText 时成绩就这样静默漏掉,现场几乎发现不了;数出来写进本次修订
// (变更历史会显示),并留一条服务端日志。纯空行不算——分批比赛里「还没跑」是正常状态。
skipped, err := countTextOnlyDrafts(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
if skipped > 0 {
s.logger.Warn("发布跳过了只填成绩文本的草稿",
"eventId", in.EventID, "categoryId", catID, "count", skipped)
}
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
@@ -752,7 +814,20 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
return nil, err
}
}
if catID == 0 {
// 小项进度(events.status)里的 published 只有一种含义:整项都发完了。
// 整项发布(catID == 0)由调用方明确声明,照旧直接写;按组别发布则要先确认整项所有
// 该出成绩的报名项都已发布——录入页永远带 categoryId,少了这一步,「一组一组发完」的
// 小项在界面上会一直停在「待录入」,侧栏与仪表盘的已发布计数也永远漏掉它。
// 还没发完时一个字符都不写:一组发布 ≠ 整项已发布,把状态写成 published 只会制造
// 「已发布」的假象(大屏 / 导出虽然不读这一列,人读)。
done := catID == 0
if !done {
done, err = eventFullyPublished(ctx, tx, in.EventID)
if err != nil {
return nil, err
}
}
if done {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
@@ -762,7 +837,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
@@ -834,10 +909,16 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
// 小项进度与发布对称地退回:整项撤销到 finished(与既有行为一致);
// 按组别撤销只在整项此前被标成 published 时降级——这次撤销让整项不再是「全部已发布」,
// 状态不跟着退,界面就会写着「已发布」而其中一组其实已经撤回。
// pending / ongoing 是人工设的进度,这里不覆盖。
revert := `UPDATE events SET status = 'finished' WHERE id = ?`
if catID > 0 {
revert += ` AND status = 'published'`
}
if _, err := tx.ExecContext(ctx, revert, in.EventID); err != nil {
return nil, internal(err)
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
@@ -1071,15 +1152,21 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin
if err != nil {
return internal(err)
}
// 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。
// 父节点 = 该小项当前的 MAX(id),即上一条版本:一个小项串成**一条链**。
// 撤销后再发布也是接在链尾(新行 id 最大,就是上一条),**不会从撤销点分出分支**——
// result_revisions 实际不是 DAG(P2-C06)。每个小项的第一条版本 parent_id 为 NULL。
var parentID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil {
return internal(err)
}
// 这条 INSERT 不再写 ledger_ids:该列从来没有读取侧(变更历史只读 snapshot / action /
// message),前端却按它渲染「积分流水 N 条」,于是每次发布都在变更历史里显示 0 条——
// 一句对用户可见的错信息。收口方向是连同前端那一栏一起删掉,写入侧先与它脱钩:
// 列上有 NOT NULL DEFAULT '[]',不写照样满足约束,删列迁移落地后这条语句也依然成立。
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, actor_id, message)
VALUES (?, ?, ?, ?, ?, ?)`,
parentID, eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
+155
View File
@@ -3,6 +3,7 @@ package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
@@ -171,3 +172,157 @@ func TestVoidResultRewritesRemainingRanks(t *testing.T) {
}
}
}
// eventStatus 读小项进度(events.status)。
func eventStatus(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
var s string
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, eventID).Scan(&s); err != nil {
t.Fatalf("读取小项状态: %v", err)
}
return s
}
// A07:result_revisions.ledger_ids 没有任何读取侧(变更历史只读 snapshot / action / message),
// 写入侧与它脱钩之后,列被迁移删掉也必须不影响发布记录修订——否则删列一上线就发不出成绩。
func TestPublishRevisionWriteDoesNotNeedLedgerIdsColumn(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.exec(t, `ALTER TABLE result_revisions DROP COLUMN ledger_ids`)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("删掉 ledger_ids 列后发布仍应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM result_revisions WHERE event_id = ?`, 1, ids.event)
}
// A14:只有成绩文本的草稿按 publishableResultCond 永远发不出去,而接口照回 200。
// 发布必须把「跳过几条」写进本次修订(变更历史直接显示 message),静默漏成绩才算被打破。
func TestPublishReportsTextOnlyDrafts(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第三条报名项连成绩都没录:它是正常的「还没跑」,不能被算进跳过的条数里。
athlete := 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, athlete)
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
// 客户端只送了文本、没算出成绩值(resultValue 缺失)。
{"entryId": ids.entryB, "resultText": "11.92", "unit": "s", "status": "ok"},
{"entryId": entryC, "status": "ok"},
},
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
// 只有文本的那条留在 draft,有值的那条上屏。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, 1, ids.entryA)
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'draft'`, 1, ids.entryB)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读变更列表应 200,得到 %d %+v", status, env2.Error)
}
items := decodeData[map[string]any](t, env2)["items"].([]any)
if len(items) != 1 {
t.Fatalf("发布后应有 1 条修订,得到 %d", len(items))
}
message, _ := items[0].(map[string]any)["message"].(string)
if !strings.Contains(message, "跳过 1 条") {
t.Fatalf("修订应写明跳过 1 条无值草稿(空行不算),得到 %q", message)
}
}
// A15:录入页永远带 categoryId,按组别发布是常规路径。小项进度(events.status)
// 不能在只发完一组时就写 published,也不能在整项都发完后还停在原状态——
// 后者会让界面一直显示「待录入」、侧栏与仪表盘的已发布计数永远漏掉这一项。
func TestCategoryPublishAdvancesEventStatusWhenDone(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第二个组别 + 两条报名项:整项共有 4 条该出成绩的报名项。
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
athD := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵敏', '女', ?, 1)`, ids.teamB)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamA, athC)
entryD := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, athD)
save := func(categoryID int64, rows []map[string]any) {
t.Helper()
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": categoryID, "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 存草稿应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
publish := func(categoryID int64) {
t.Helper()
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": categoryID,
}, true); status != http.StatusOK {
t.Fatalf("组别 %d 发布应 200,得到 %d %+v", categoryID, status, e.Error)
}
}
save(ids.category, []map[string]any{
{"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"},
})
save(cat2, []map[string]any{
{"entryId": entryC, "resultText": "12.30", "resultValue": 12.30, "unit": "s", "status": "ok"},
{"entryId": entryD, "resultText": "12.44", "resultValue": 12.44, "unit": "s", "status": "ok"},
})
publish(ids.category)
if got := eventStatus(t, env, ids.event); got != "ongoing" {
t.Fatalf("只发完一个组不得把小项标成已发布,得到 %q", got)
}
publish(cat2)
if got := eventStatus(t, env, ids.event); got != "published" {
t.Fatalf("整项所有组都发完后小项应标成 published,得到 %q", got)
}
if status, e := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("撤销一个组应 200,得到 %d %+v", status, e.Error)
}
if got := eventStatus(t, env, ids.event); got != "finished" {
t.Fatalf("撤回一个组后小项不该再是已发布,得到 %q", got)
}
}
@@ -0,0 +1,210 @@
package httpapi_test
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// countUserSessions 数某账号在库里的会话行。
func countUserSessions(t *testing.T, env *testEnv, userID int64) int {
t.Helper()
var n int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil {
t.Fatalf("统计会话: %v", err)
}
return n
}
// meRole 走 /api/auth/me 读当前身份:会话被撤之后这里会退回 guest。
func meRole(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读 /api/auth/me 应 200,得到 %d %+v", status, body.Error)
}
return decodeData[map[string]any](t, body)["role"].(string)
}
// 会话失效的三条管理动作(重置密码 / 停用 / 自己改密)必须**当场**生效:
// 旧 Cookie 下一次请求就该被当成访客,库里那行会话也该没了。
// 在此之前这些承诺只有 auth 包内的函数级断言,没有一条走 HTTP 的端到端用例——
// 管理端 handler 漏调撤会话(或撤了却忘了清 Cookie)不会有任何东西变红。
func TestAdminActionsInvalidateSessionsImmediately(t *testing.T) {
env := newTestEnv(t)
admin := adminClient(t, env)
uid := env.createUser("recorder", "recorder-pass", "recorder")
worker, _ := env.client()
env.login(worker, "recorder", "recorder-pass")
if got := meRole(t, env, worker); got != "recorder" {
t.Fatalf("登录后身份应为 recorder,得到 %q", got)
}
// ① 管理员重置密码:旧会话立即失效(旧 Cookie 直接当访客用),库里的行也没了。
status, body := env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", uid),
map[string]any{"password": "new-recorder-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("重置密码后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, worker); got != "guest" {
t.Fatalf("重置密码后旧会话应退回 guest,得到 %q", got)
}
status, body = env.do(worker, http.MethodPost, "/api/teams",
map[string]any{"code": "X1", "name": "旧会话不该还能写"}, true)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("重置密码后旧会话的写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
// 旧密码登不进去,新密码可以:证明失效的是会话,不是账号。
fresh, _ := env.client()
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("重置密码后旧密码不该还能登录,得到 %d", status)
}
env.login(fresh, "recorder", "new-recorder-pass")
if got := meRole(t, env, fresh); got != "recorder" {
t.Fatalf("新密码登录后身份应为 recorder,得到 %q", got)
}
// ② 停用账号:刚建好的这条新会话也必须当场失效。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", uid),
map[string]any{"active": false}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("停用账号应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("停用后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, fresh); got != "guest" {
t.Fatalf("停用后旧会话应退回 guest,得到 %q", got)
}
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "new-recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("停用后不该还能登录,得到 %d", status)
}
}
// 自己改密:本人的会话也要当场作废,并明确告诉前端「重新登录」。
// 只清 Cookie 不撤会话(或反过来)都算没做到——前者旧 Cookie 还能用,后者页面看着没退。
func TestChangeOwnPasswordInvalidatesOwnSession(t *testing.T) {
env := newTestEnv(t)
uid := env.createUser("recorder", "recorder-pass", "recorder")
client, jar := env.client()
env.login(client, "recorder", "recorder-pass")
// 改密前先把旧会话 token 记下来:改密之后要用它重放(模拟「Cookie 被抄走/浏览器还没收到清除指令」)。
u, err := url.Parse(env.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
oldSession := ""
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession {
oldSession = c.Value
}
}
if oldSession == "" {
t.Fatalf("登录后 Cookie 里应有 %s", auth.CookieSession)
}
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "recorder-pass", "newPassword": "brand-new-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改密应 200,得到 %d %+v", status, body.Error)
}
if relogin, _ := decodeData[map[string]any](t, body)["relogin"].(bool); !relogin {
t.Fatalf("改密回执应带 relogin=true 引导重新登录,得到 %+v", decodeData[map[string]any](t, body))
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("改密后自己的会话行应清空,库里还剩 %d 行", n)
}
// 服务端下发了删除指令:浏览器不会再带着这枚 Cookie 发请求。
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession && c.Value != "" && c.MaxAge >= 0 {
t.Fatalf("改密应答应清掉会话 Cookie,得到 %+v", c)
}
}
// 旧 Cookie 手工塞回来重放也是访客:撤会话与清 Cookie 两件事必须都做到,
// 只做后者的话,任何抓到过旧 Cookie 的地方都还能继续用这个身份。
replay, err := http.NewRequest(http.MethodGet, env.server.URL+"/api/auth/me", nil)
if err != nil {
t.Fatalf("构造重放请求: %v", err)
}
replay.AddCookie(&http.Cookie{Name: auth.CookieSession, Value: oldSession})
resp, err := (&http.Client{}).Do(replay)
if err != nil {
t.Fatalf("重放旧 Cookie: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(raw), `"role":"guest"`) {
t.Fatalf("旧 Cookie 重放应被当成访客,得到 %d %s", resp.StatusCode, raw)
}
}
// 滑动续期必须带着 Cookie 的 Max-Age 一起走,并且 Max-Age 跟的是**该会话的剩余寿命**,
// 不是当前有效期:有效期被改短之后,老会话仍按自己的到期时间下发(否则浏览器比服务端早丢 Cookie)。
func TestMeRefreshesCookieMaxAgeFromSessionExpiry(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 登录时会话到期 = 登录时刻 + 24h;把有效期改短成 1 小时(不动已存在的会话)。
if status, body := env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
t.Fatalf("把有效期改成 1 小时应 200,得到 %d %+v", status, body.Error)
}
stored := sessionExpiry(t, env, "admin")
resp, err := client.Get(env.server.URL + "/api/auth/me")
if err != nil {
t.Fatalf("请求 /auth/me: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
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 || csrf == nil {
t.Fatalf("/auth/me 应重发会话与 CSRF Cookie(滑动续期要同步浏览器端的 Max-Age),得到会话 %+v / CSRF %+v",
session, csrf)
}
// 剩余寿命 = 库里那条会话的到期时间 - 现在(容许几秒误差)。
want := int(time.Until(stored).Seconds())
if diff := session.MaxAge - want; diff > 3 || diff < -3 {
t.Fatalf("会话 Cookie 的 Max-Age 应等于这条会话的剩余寿命 %d 秒,得到 %d", want, session.MaxAge)
}
// 关键对照:有效期已经改成 1 小时了,Max-Age 不能跟着它变成 3600。
if session.MaxAge < 20*60*60 {
t.Fatalf("Max-Age 跟错了对象(疑似用了当前有效期而非会话到期时间):%d,库里到期时间是 %s",
session.MaxAge, stored.Format(time.RFC3339))
}
// CSRF 不轮换:值必须与 Cookie 里原样一致,否则飞行中的写请求会集体 403。
if _, err := url.Parse(env.server.URL); err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
if csrf.MaxAge != session.MaxAge {
t.Fatalf("CSRF Cookie 应与会话 Cookie 同一期限,得到 %d / %d", csrf.MaxAge, session.MaxAge)
}
if strings.TrimSpace(csrf.Value) == "" {
t.Fatalf("重发的 CSRF Cookie 不该是空值")
}
}
+41 -3
View File
@@ -194,7 +194,17 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler {
// 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
// 因此对带 Origin/Referer 的登录请求单独做同源校验。
if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil)
// 文案必须指向真因:这条拦截最常见的场景不是「浏览器缓存了旧页面」,
// 而是反向代理改写了 Host(浏览器带外部域名、服务端只看到回源地址),
// 照旧写「请刷新页面重试」会把现场引到永远试不好的方向。
// detail 带上实际比对的两个值:现场照着改一行反代配置就能好。
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN",
"登录请求来源校验失败:本站 Origin 与服务端收到的 Host 不一致(反向代理通常改写 Host,需让它原样透传 Host 或透传 X-Forwarded-Host 并把代理加入可信代理)",
map[string]any{"source": source, "host": r.Host, "forwardedHost": r.Header.Get("X-Forwarded-Host")})
return
}
if User(r.Context()).Role == auth.RoleGuest {
@@ -207,7 +217,10 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler {
cookie = c.Value
}
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil)
// 「请刷新页面重试」是错的:页面加载走的是 /auth/me,而它此前不会补发丢失的 CSRF Cookie,
// 刷新一百次也还是 403。当前唯一有效的自愈路径是退出后重新登录(登录会重新下发两枚 Cookie)。
// 配套修复见 internal/httpapi/handlers_config.go 的 me:缺票时补发一枚新的。
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请退出后重新登录", nil)
return
}
next.ServeHTTP(w, r)
@@ -216,6 +229,23 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler {
// sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer),
// 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。
//
// 反代改写 Host 的部署(nginx `proxy_set_header Host 127.0.0.1:19540;`)下,浏览器带的 Origin 是
// 外部域名、r.Host 是回源地址,两者永远不相等 → 所有人登录 403。这里在直连对端命中可信代理时,
// 额外采信 X-Forwarded-Host(代理透传的原 Host)。
//
// 为什么这不等于「把同源校验放水到任意 Origin」:
// 1. Origin 由浏览器自动生成,跨站页面改不了它——攻击者页面发来的请求,Origin 仍是攻击者的域名;
// 2. X-Forwarded-Host 同样只有代理或非浏览器客户端能设:跨站 fetch/XHR 带自定义头会触发预检,
// 本服务不返回任何 Access-Control-* 头,预检必失败;HTML 表单压根设不了自定义头;
// 3. 能自行编造任意请求头的客户端(curl / 脚本)本来就没有受害者的 Cookie,CSRF 对它不成立,
// 给它放宽不改变任何安全结论;
// 4. 还要求直连对端在可信代理白名单里(与 ClientIP 同口径),挡住「绕过代理直连、自己塞头」的路径;
// 5. 采信的值仍必须与 Origin 相等,不是无条件放行——校验的本质(Origin 属于本站)没有变,
// 只是「本站」由 r.Host 一个候选变成 r.Host 或代理声明的原 Host。
//
// 保守取舍:只做全等比较,不做「忽略端口」这类归一化。
// nginx 的 $host 会去掉端口,部署在非默认端口时请用 `X-Forwarded-Host $http_host`。
func sameOrigin(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
@@ -228,7 +258,15 @@ func sameOrigin(r *http.Request) bool {
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
if strings.EqualFold(parsed.Host, r.Host) {
return true
}
if !isTrustedProxy(net.ParseIP(remoteHost(r.RemoteAddr))) {
return false
}
// 多值时取第一个:代理链上离客户端最近的那一跳写的才是浏览器真正用过的 Host。
forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0])
return forwarded != "" && strings.EqualFold(parsed.Host, forwarded)
}
// RateLimitWrite 对已登录用户的写操作限流。
@@ -0,0 +1,121 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// withTrustedProxy 临时配置可信代理;可信代理是包级全局变量,测试结束必须恢复原状。
func withTrustedProxy(t *testing.T, entries ...string) {
t.Helper()
t.Cleanup(func() { _ = SetTrustedProxies(nil) })
if err := SetTrustedProxies(entries); err != nil {
t.Fatalf("配置可信代理: %v", err)
}
}
// 反代改写 Host 的部署里,浏览器带的 Origin 是外部域名、r.Host 是回源地址,
// 直连对端命中可信代理时采信它透传的原 Host(X-Forwarded-Host)。
func TestSameOriginAcceptsForwardedHostFromTrustedProxy(t *testing.T) {
withTrustedProxy(t, "10.0.0.0/8")
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.RemoteAddr = "10.0.0.7:51234" // 直连对端 = 可信代理
req.Host = "127.0.0.1:19540" // 代理改写了 Host
req.Header.Set("Origin", "http://scoreboard.local")
req.Header.Set("X-Forwarded-Host", "scoreboard.local")
if !sameOrigin(req) {
t.Fatal("可信代理透传的原 Host 与 Origin 一致时应放行")
}
// 多值时只认第一个:代理链上离客户端最近的那一跳写的才是浏览器用过的 Host。
req.Header.Set("X-Forwarded-Host", "scoreboard.local, inner.local")
if !sameOrigin(req) {
t.Fatal("多值 X-Forwarded-Host 应取第一个比较")
}
// 放行的只是「代理透传的原 Host」,不是任意 Origin:跨站 Origin 仍然拦下。
req.Header.Set("Origin", "http://evil.example")
if sameOrigin(req) {
t.Fatal("跨站 Origin 必须被拒(同源校验没有被放水成任意 Origin)")
}
// 代理没透传原 Host 时,Host 与 Origin 不一致依旧拦下——
// 这正是「代理只改了 Host、既没透传 Host 也没透传 X-Forwarded-Host」的现场,
// 修法在代理一侧(proxy_set_header Host $host),服务端不猜。
req.Header.Set("Origin", "http://scoreboard.local")
req.Header.Set("X-Forwarded-Host", "")
if sameOrigin(req) {
t.Fatal("没有原 Host 可比时不能放行")
}
}
// 直连对端不在可信代理白名单时,X-Forwarded-Host 一律不采信——代理头是客户端可伪造的,
// 与 ClientIP 同口径(只有可信代理的转发头才作数)。
func TestSameOriginIgnoresForwardedHostFromUntrustedPeer(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.RemoteAddr = "203.0.113.9:5000" // 公网直连,不在白名单
req.Host = "127.0.0.1:19540"
req.Header.Set("Origin", "http://scoreboard.local")
req.Header.Set("X-Forwarded-Host", "scoreboard.local")
if sameOrigin(req) {
t.Fatal("不可信对端塞进来的 X-Forwarded-Host 不能放行")
}
// 真·同源(Host 就等于 Origin)不受影响,照常放行。
req.Host = "scoreboard.local"
if !sameOrigin(req) {
t.Fatal("Host 与 Origin 一致时应放行")
}
}
// 登录来源校验的提示不能再指向「刷新页面」:真因多半是反代改写 Host,
// 文案必须把现场引到代理配置上(错误方向的提示会让人反复刷新)。
func TestLoginOriginFailureMessagePointsAtProxy(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := CSRF(nil, next)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "127.0.0.1:19540"
req.Header.Set("Origin", "http://scoreboard.local")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("Host 与 Origin 不一致应 403,得到 %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "刷新页面") {
t.Fatalf("提示不该再指向「刷新页面」:%s", body)
}
if !strings.Contains(body, "Host") || !strings.Contains(body, "X-Forwarded-Host") {
t.Fatalf("提示应说明是 Host 与 Origin 不一致以及代理怎么改:%s", body)
}
}
// CSRF 校验失败时刷新页面补不回丢失的 Cookie,提示必须指向唯一有效的自愈路径:退出后重新登录。
func TestCSRFFailureMessagePointsAtRelogin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := CSRF(nil, next)
req := httptest.NewRequest(http.MethodPut, "/api/teams/1", nil)
req = req.WithContext(WithUser(req.Context(),
&auth.User{ID: 1, Username: "recorder", Role: auth.RoleRecorder}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("缺 CSRF 头的写请求应 403,得到 %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "刷新页面") {
t.Fatalf("提示不该再指向「刷新页面」(刷新走的是 /auth/me,补不回 Cookie):%s", body)
}
if !strings.Contains(body, "请退出后重新登录") {
t.Fatalf("提示应指向「退出后重新登录」:%s", body)
}
}
+145
View File
@@ -0,0 +1,145 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// panicOnce 第一次请求 panic,之后正常应答:这样一条链既能验「panic 被兜住」,
// 又能验「兜住之后同一条链照常服务」(panic 没有被吞成静默死循环、也没有把进程带走)。
func panicOnce(secret string) http.Handler {
var hits int
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
if hits == 1 {
panic(secret)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"ok":true}`))
})
}
// internalErrorBody 是 500 响应的形状:与 httpapi 的统一错误体同构
// (writeError 与 httpapi.writeAPIError 是两份实现,形状必须对得上,前端只按一种解)。
type internalErrorBody struct {
OK bool `json:"ok"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
} `json:"error"`
}
// 全仓此前没有任何「让 handler panic」的用例:Recover 写错状态码、写成 200、
// 把 panic 原文(含栈、含内部数据)回给客户端,都没有东西会红。
func TestRecoverReturnsInternalErrorAndKeepsServing(t *testing.T) {
const secret = "重算名次时下标越界:athleteID=8848"
chain := RequestID(Recover(slog.New(slog.NewTextHandler(io.Discard, nil)))(panicOnce(secret)))
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/standings", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("panic 应回 500,得到 %d(body %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Fatalf("500 的 Content-Type 应为 application/json; charset=utf-8,得到 %q", ct)
}
// 响应体必须是一份完整、可解析的统一错误体:半截 JSON 会让前端把它当网络错误。
var body internalErrorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("500 响应体应为完整 JSON: %v\n%s", err, rec.Body.String())
}
if body.OK {
t.Fatalf("500 不该是 ok:true,得到 %s", rec.Body.String())
}
if body.Error.Code != "INTERNAL" || body.Error.Message != "服务端异常" {
t.Fatalf("500 形状应为 code=INTERNAL / message=服务端异常,得到 %+v", body.Error)
}
if body.Error.Detail != nil {
t.Fatalf("panic 不该回 detail(内部细节不外泄),得到 %s", body.Error.Detail)
}
// panic 原文与栈一律不回客户端:里面常有 id、SQL、路径。
for _, leak := range []string{secret, "recover_test.go", "goroutine "} {
if strings.Contains(rec.Body.String(), leak) {
t.Fatalf("500 响应体泄露了 %q:%s", leak, rec.Body.String())
}
}
// 同一条链的下一个请求照常:panic 只结束这一个请求。
rec2 := httptest.NewRecorder()
chain.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/standings", nil))
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), `"ok":true`) {
t.Fatalf("panic 之后同一条链应照常服务,得到 %d %s", rec2.Code, rec2.Body.String())
}
}
// 日志侧:handler panic 是排查类事件,必须落 interceptions/(不是 operations/),
// 且要带 panic 值、路径、request_id 与真实栈——只写一句「服务端异常」等于什么都没留。
func TestRecoverLogsPanicToInterceptions(t *testing.T) {
const secret = "重算名次时下标越界:athleteID=8848"
dir := t.TempDir()
console := &bytes.Buffer{}
handler, closer := logging.New(logging.Options{
Dir: dir, Level: slog.LevelInfo, Console: console, MaxSizeMB: 20, KeepFiles: 10,
})
t.Cleanup(func() { _ = closer.Close() })
chain := RequestID(Recover(slog.New(handler))(panicOnce(secret)))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/recompute", nil)
chain.ServeHTTP(rec, req)
inter := readLogFile(t, dir, logging.DirInterceptions)
for _, want := range []string{
`"level":"ERROR"`,
"请求处理 panic",
secret,
`"path":"/api/recompute"`,
`"requestId":"`, // 请求 id 必须能对上现场那次请求
`"stack":"`,
"recover_test.go", // 栈是这次 panic 的真实栈,不是一句占位文案
} {
if !strings.Contains(inter, want) {
t.Fatalf("interceptions 日志应含 %q,得到:\n%s", want, inter)
}
}
// request_id 不能是空串:空串等于没有追踪键。
if strings.Contains(inter, `"requestId":""`) {
t.Fatalf("panic 日志的 requestId 不该为空:\n%s", inter)
}
// 拦截类不落 operations/:两类日志混在一起时,「有没有被拦过」就查不出来了。
if ops := readLogFile(t, dir, logging.DirOperations); strings.Contains(ops, "请求处理 panic") {
t.Fatalf("panic 日志不该落 operations/,得到:\n%s", ops)
}
// 控制台仍要能看到(现场直接跑二进制时只有控制台)。
if !strings.Contains(console.String(), "请求处理 panic") {
t.Fatalf("控制台应看到 panic 日志,得到:\n%s", console.String())
}
}
// readLogFile 读某类日志今天的文件内容;文件不存在返回空串。
func readLogFile(t *testing.T, dir, category string) string {
t.Helper()
path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
+27
View File
@@ -4,11 +4,32 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。
//
// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到变量,只能按原文认。
// httpapi 包里有一份同口径的实现(那一份也导不出来,实时层又依赖不了 httpapi——会成环),
// 两份必须保持同一套语义:库已关闭 = 恢复/重启动作的中间状态,不是故障。
var errDBClosed = errors.New("sql: database is closed")
// dbClosed 判定错误是否只是「库已经被关掉」。
//
// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:别的读库失败(锁超时、
// SQL 写错、库损坏)都得继续按 WARN/ERROR 报出来,不能被这条静默规则顺手吞掉。
func dbClosed(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if e.Error() == errDBClosed.Error() {
return true
}
}
return false
}
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
// NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。
func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
@@ -26,12 +47,18 @@ func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
}
// currentVersionCached 在数据库不可用时退回内存里的版本,保证心跳与广播不会因一次查询失败而中断。
// 「库已关闭」(恢复/重启窗口)只记 Debug:那不是故障,而且它每个心跳周期都会发生一次,
// 记 WARN 会让现场日志刷成一片红。
func (h *Hub) currentVersionCached(ctx context.Context) int64 {
if h.db == nil {
return h.Version()
}
v, err := h.currentVersion(ctx)
if err != nil {
if dbClosed(err) {
h.log.Debug("读取版本号:库已关闭(恢复/重启中),沿用内存版本", "version", h.Version())
return h.Version()
}
h.log.Warn("读取版本号失败,沿用内存版本", "err", err, "version", h.Version())
return h.Version()
}
+117 -4
View File
@@ -51,10 +51,13 @@ type client struct {
// 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。
snapshotPending atomic.Bool
slow atomic.Bool
// lastSeen 是最近一次真正收到对端帧的时间(unix 纳秒),含普通数据帧与 ping 的 pong。
// 「这条连接还活着吗」只认它:服务端自己发过什么、写过几次 socket 都不算证据。
lastSeen atomic.Int64
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client {
return &client{
c := &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
@@ -65,9 +68,36 @@ func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *
sessionID: id.SessionID,
settingsAdmin: id.SettingsAdmin,
}
// 建立连接本身就是一次「听到对端」:不初始化的话,存活探测会把刚连上的
// 大屏当成已经静默很久,第一条 ping 立刻发出去。
c.markSeen(time.Now())
return c
}
// stop 只负责让两条 goroutine 退出;真正的关闭动作由 serve 统一执行,
// markSeen 记下「刚刚听到了对端」。
func (c *client) markSeen(now time.Time) { c.lastSeen.Store(now.UnixNano()) }
// lastSeenAt 返回最近一次听到对端的时间。
func (c *client) lastSeenAt() time.Time { return time.Unix(0, c.lastSeen.Load()) }
// instanceIDValue 读实例 id;调用方拿到的是快照值,不持有锁。
func (c *client) instanceIDValue() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.instanceID
}
// isDone 判断本连接是否已经进入关闭流程。
func (c *client) isDone() bool {
select {
case <-c.done:
return true
default:
return false
}
}
// stop 只负责让读写与存活探测三条 goroutine 退出;真正的关闭动作由 serve 统一执行,
// 避免在持有广播锁时做可能阻塞的关闭握手。
func (c *client) stop(code websocket.StatusCode, reason string) {
c.closeOnce.Do(func() {
@@ -135,12 +165,21 @@ func (c *client) sendRaw(msg []byte) bool {
}
}
// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。
// readLoop 解析 C→S 消息;握手必须在 helloTimeout 内完成,否则断开,
// 防止「连上就不说话」的空连接一直占着 goroutine 与 fd。
// 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
defer c.guardLoop("读")
// 握手 deadline 在进入循环时算一次,整个「未握手」阶段共用它。
//
// 以前是每轮 Read 都新建一个 WithTimeout:只要对端在超时前随便发一帧(哪怕是个
// 客户端不认识的事件),计时就被重置,未握手的连接可以无限期挂着——
// 现场用脚本反复重连就能堆出一堆永不释放的连接。绝对 deadline 让「打开连接却不
// hello」在固定时间后必然被断开;已握手的连接不受它限制。
helloDeadline := time.Now().Add(c.hub.helloTimeout())
for {
c.mu.Lock()
greeted := c.greeted
@@ -149,7 +188,7 @@ func (c *client) readLoop(ctx context.Context) {
readCtx := ctx
var cancel context.CancelFunc
if !greeted {
readCtx, cancel = context.WithTimeout(ctx, c.hub.helloTimeout())
readCtx, cancel = context.WithDeadline(ctx, helloDeadline)
}
_, data, err := c.conn.Read(readCtx)
if cancel != nil {
@@ -158,10 +197,84 @@ func (c *client) readLoop(ctx context.Context) {
if err != nil {
return
}
// 收到任何一帧都是「对端还活着」的证据,与它是不是合法事件无关。
c.markSeen(time.Now())
c.handleMessage(ctx, data)
}
}
// livenessLoop 定期求证对端还在不在:静默(没有任何 C→S 帧)超过一个探测周期就发一次
// WebSocket ping 并等 pong,连续 maxProbeFailures 次等不到就断开这条连接。
//
// 为什么必须单独探:大屏在数据没变时只收不发(hello 只在连接与翻页时发、ack 只在应用
// 变更时发),所以「没有上行帧」不等于掉线,不能当判据;而 TCP 层面「连接对象还在」
// 什么也证明不了——拔网线、断电、机器休眠都不会让服务端立刻收到错误,内核缓冲会让写
// 一直“成功”,注册表里的「在线」就会永远挂着那块已经不在的屏。
// ping/pong 是唯一的真实往返,且由浏览器在网络层自动应答,不需要前端改代码。
func (c *client) livenessLoop(ctx context.Context) {
defer c.guardLoop("存活探测")
failures := 0
for {
interval := c.hub.livenessIntervalValue()
if interval <= 0 {
// <= 0 表示关闭探测,只给「单独验证某个判定口径」的用例用:
// 生产路径上 NewHub 一定给了默认值,不会走到这里。
return
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-c.done:
timer.Stop()
return
case <-timer.C:
}
if time.Since(c.lastSeenAt()) < interval {
// 静默期内收到过真实上行帧:这条连接有人,不必多问一句。
failures = 0
continue
}
if c.probePeer(ctx) {
failures = 0
continue
}
if ctx.Err() != nil || c.isDone() {
return
}
failures++
if failures < maxProbeFailures {
// 一次不回应可能只是对端正忙(例如正在收一份大快照,写锁被占住),
// 再给一个周期;连续失败才说明这条连接上已经没有人了。
continue
}
c.hub.log.Info("实时连接长时间无响应(未回 pong),按掉线断开",
"remote", c.remoteAddr, "instance", c.instanceIDValue(),
"silent", time.Since(c.lastSeenAt()).Truncate(time.Second).String())
c.stop(unresponsiveCloseCode, "连接无响应")
return
}
}
// probePeer 发一次 ping 并等 pong;返回 true 表示对端应答了。
func (c *client) probePeer(ctx context.Context) bool {
pingCtx, cancel := context.WithTimeout(ctx, c.hub.pongTimeoutValue())
defer cancel()
if err := c.conn.Ping(pingCtx); err != nil {
c.hub.log.Debug("存活探测未收到 pong", "remote", c.remoteAddr, "err", err)
return false
}
now := time.Now()
c.markSeen(now)
// 这次往返是真实的客户端应答,注册表的「最后活跃」也该跟着走。
c.hub.touchInstance(c, now)
return true
}
func (c *client) handleMessage(ctx context.Context, data []byte) {
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
+84 -8
View File
@@ -17,7 +17,10 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 默认参数;现场可通过 Set* 调整。
// 默认参数。
//
// 注意:Set* 目前只有测试在调用——现场调优要求先把它们接到 config.yaml / main.go 上,
// 那是本包之外的决定,所以不要照着 Set* 的存在去宣称「现场可调」。
const (
defaultHeartbeatInterval = 10 * time.Second
defaultMaxCatchup = 500
@@ -26,6 +29,19 @@ const (
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// defaultLivenessInterval 是「一点客户端帧都没收到」的静默期上限:超过它就发一次
// WebSocket ping 求证对端还在不在(见 client.livenessLoop)。
//
// 不能把「静默」直接当掉线:大屏在数据没变时本来就不上行。取 15s,配合
// maxProbeFailures = 2 与 defaultPongTimeout = 5s,真正掉线的屏最迟
// 2 × (15s + 5s) = 40s 内被判定并断开;稳态下每块屏每分钟只多发 4 个 ping 帧。
defaultLivenessInterval = 15 * time.Second
// defaultPongTimeout 是等一次 pong 的超时。ping 帧本身要走写锁,正常浏览器在
// 网络层立刻应答,5s 已经宽裕。
defaultPongTimeout = 5 * time.Second
// maxProbeFailures 是连续多少次探测失败才断开:单次失败可能只是对端在忙
// (正在收一份大快照,写锁被占住),连续失败才说明连接上没有人了。
maxProbeFailures = 2
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
//
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
@@ -43,6 +59,10 @@ const (
// 不能用 1008:前端把所有 1008 当「登录失效」并永久停止重连,慢客户端被踢后大屏会冻结。
const slowCloseCode = websocket.StatusCode(4000)
// unresponsiveCloseCode 是踢掉「不回 pong」的连接时使用的关闭码。同样避开 1008:
// 那块屏只是掉线了,重连回来就能补上数据,不该被引导去重新登录。
const unresponsiveCloseCode = websocket.StatusCode(4001)
// Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。
//
// 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更,
@@ -76,6 +96,11 @@ type Hub struct {
allowedOrigins []string
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
snapshotMinInterval time.Duration
// livenessInterval 是「一点客户端帧都没收到」的静默期上限;pongTimeout 是等一次
// pong 的超时。两者只影响存活探测(见 client.livenessLoop),默认值见常量区注释;
// livenessInterval <= 0 表示关闭探测(只有测试会这么设)。
livenessInterval time.Duration
pongTimeout time.Duration
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
sessionChecker SessionChecker
}
@@ -108,6 +133,8 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub {
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
snapshotMinInterval: defaultSnapshotMinInterval,
livenessInterval: defaultLivenessInterval,
pongTimeout: defaultPongTimeout,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
@@ -159,7 +186,11 @@ func (h *Hub) setVersion(v int64) {
h.mu.Unlock()
}
// SetHeartbeatInterval 调整心跳间隔并立即生效;用于现场调优与测试。
// SetHeartbeatInterval 调整心跳间隔并立即生效。
//
// 唯一调用方是测试(把心跳调快,好在毫秒级跑完「会话撤销即断开」这类用例);
// 生产路径用的是 defaultHeartbeatInterval。要做成现场可调,得先在 main.go 里
// 从 config.yaml 读出来再调这里——本包不读配置,也没有别的消费方。
func (h *Hub) SetHeartbeatInterval(d time.Duration) {
if d <= 0 {
return
@@ -174,7 +205,7 @@ func (h *Hub) SetHeartbeatInterval(d time.Duration) {
}
}
// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。
// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。同样只有测试在调用。
func (h *Hub) SetMaxCatchup(n int) {
if n <= 0 {
return
@@ -184,7 +215,7 @@ func (h *Hub) SetMaxCatchup(n int) {
h.mu.Unlock()
}
// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。
// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。同样只有测试在调用。
func (h *Hub) SetSlowLag(n int64) {
h.mu.Lock()
h.slowLag = n
@@ -261,6 +292,18 @@ func (h *Hub) writeTimeout() time.Duration {
return h.writeTO
}
func (h *Hub) livenessIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.livenessInterval
}
func (h *Hub) pongTimeoutValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.pongTimeout
}
func (h *Hub) allowedOriginsValue() []string {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -336,9 +379,11 @@ func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
defer cancel()
var loops sync.WaitGroup
loops.Add(2)
loops.Add(3)
go func() { defer loops.Done(); c.readLoop(ctx) }()
go func() { defer loops.Done(); c.writeLoop(ctx) }()
// 存活探测:服务端「连接对象还在」不等于对端还在,注册表的在线判定靠这条循环求证。
go func() { defer loops.Done(); c.livenessLoop(ctx) }()
<-c.done
// 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接
@@ -487,6 +532,15 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
cur, err := h.currentVersion(ctx)
if err != nil {
if dbClosed(err) {
// 恢复/重启窗口(库已经被关掉、文件还没换完)里的新连接不能踢:
// 现场看到的是「每块屏刚连上就被 1011 打回、日志一屏 ERROR」,像恢复把系统
// 搞坏了。与 httpapi/realtime.go 的降级口径一致——这类错误只记 Debug,
// 连接留着(心跳照发,客户端看门狗不会误判),等库回来后的下一次
// resync / 重连自然就能拿到数据。
h.log.Debug("跳过首包:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err)
return
}
h.log.Error("读取版本号失败,无法下发首包", "err", err)
c.stop(websocket.StatusInternalError, "版本号不可用")
return
@@ -584,6 +638,13 @@ func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
snap, err := h.Snapshot(ctx)
if err != nil {
if dbClosed(err) {
// 同 sendInitial:恢复窗口里「快照读不出来」不是故障,是停机动作的一部分。
// 这里既不能记 ERROR,也不能拿 1011 把大屏踢回去重连(那正是现场
// 「恢复把系统搞坏了」的观感来源);连接留着,心跳继续。
h.log.Debug("跳过整库快照:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err)
return
}
h.log.Error("构建快照失败", "err", err)
c.stop(websocket.StatusInternalError, "快照不可用")
return
@@ -604,6 +665,10 @@ func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
func (h *Hub) Broadcast(changes []Change) { h.fanout("", changes) }
// BroadcastTo 把变更推给指定房间(如 RoomAdmin、AdminRoom(uid))。
//
// 同样只有测试在调用:生产的推送要么是 Broadcast(所有已握手连接),要么是
// SendCommandTo(大屏指令)。保留它是为了让「房间」这层语义有可用的出口,
// 删掉或接到生产调用方都要连 devdocs/05 §1 一起改(审查清单 C36)。
func (h *Hub) BroadcastTo(room string, changes []Change) { h.fanout(room, changes) }
// fanout 在 sendMu 下按版本去重后下发:已经发过的版本不再重复投递。
@@ -701,10 +766,18 @@ func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int,
return h.onlineDisplayCount(), nil
}
// 归一化只做一次,并且**匹配与写注册表共用同一个值**:管理端从表格里带过来的 id
// 常有多余空白(粘贴/换行),以前匹配用 TrimSpace、写「当前页」用原值,
// 结果是指令真的发到了那块屏,注册表里的当前页却停在旧值上,
// 管理端下一次轮询又把界面上刚切过去的页显示回来。
wanted := make(map[string]bool, len(targets))
normalized := make([]string, 0, len(targets))
for _, id := range targets {
if id = strings.TrimSpace(id); id != "" {
wanted[id] = true
if !wanted[id] {
wanted[id] = true
normalized = append(normalized, id)
}
}
}
@@ -723,7 +796,7 @@ func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int,
h.sendMu.Unlock()
if cmd.Action == CmdGoto {
h.setInstancePage(pageFromPayload(cmd.Payload), targets)
h.setInstancePage(pageFromPayload(cmd.Payload), normalized)
}
return len(matched), nil
}
@@ -819,7 +892,10 @@ func (h *Hub) beat() {
continue
}
c.sendEvent(EventHeartbeat, data)
h.touchInstance(c, now)
// 只同步 ack / 慢标记,**不刷 lastHeartbeat**:注册表里的「在线」与「最后活跃」
// 必须来自对端。服务端每 10s 自己给自己盖一个时间戳,会让拔了网线的屏一直
// 显示在线、指令发过去还报「命中 N 块屏」。真正的存活求证在 livenessLoop。
h.syncInstance(c)
h.checkSlow(c, version)
if c.snapshotPending.Load() {
+279 -1
View File
@@ -1,6 +1,7 @@
package realtime
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -13,6 +14,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -50,9 +52,17 @@ func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
// *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。
func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv {
t.Helper()
return newTestEnvFull(t, u, sessionID, io.Discard)
}
// newTestEnvFull 与 newTestEnvAsSession 相同,但把实时层的日志写进调用方给的 writer,
// 且放行 Debug:需要断言「这条路径不该刷 WARN/ERROR」的用例(如恢复窗口降级)用它。
// 其余用例传 io.Discard,行为与以前一致。
func newTestEnvFull(t *testing.T, u *auth.User, sessionID string, logw io.Writer) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
h := NewHub(db, slog.New(slog.NewTextHandler(logw, &slog.HandlerOptions{Level: slog.LevelDebug})))
handler := h.Handler()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := middleware.WithUser(r.Context(), u)
@@ -934,6 +944,40 @@ func TestSendCommandTargetsMixedOnlineOffline(t *testing.T) {
}
}
// targets 里带空白(管理端从表格/粘贴里带过来的 id)时,匹配与「当前页」写入必须用
// 同一个归一化后的值:以前匹配走 TrimSpace、写注册表用原值,结果是指令真的发到了那块屏,
// 注册表里的当前页却停在旧值上,管理端下一次轮询又把界面上刚切过去的页显示回来。
func TestSendCommandTargetsTrimsWhitespaceForPageRegistry(t *testing.T) {
env := newTestEnv(t)
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a", Page: "standings"})
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b", Page: "standings"})
a.recvEvent(EventSnapshot)
b.recvEvent(EventSnapshot)
cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"})
if err != nil {
t.Fatalf("构造 goto: %v", err)
}
matched, err := env.hub.SendCommandToInstances([]string{" screen-a \n"}, cmd)
if err != nil {
t.Fatalf("定向下发: %v", err)
}
if matched != 1 {
t.Fatalf("带空白的 id 应命中 1 个实例,得到 %d", matched)
}
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto {
t.Fatalf("被点名的实例应收到 goto,得到 %s", got.Action)
}
insts := env.hub.Instances()
if got := instancePage(insts, "screen-a"); got != "medals" {
t.Fatalf("被点名实例的当前页应更新为 medals,得到 %q", got)
}
if got := instancePage(insts, "screen-b"); got != "standings" {
t.Fatalf("未被点名实例的当前页不该变,得到 %q", got)
}
}
// 非法 action 走定向通道要报错,不能静默丢掉。
func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) {
env := newTestEnv(t)
@@ -1399,3 +1443,237 @@ func TestPendingSnapshotFlushIsPerConnection(t *testing.T) {
a.noSnapshotWithin(300 * time.Millisecond)
b.noSnapshotWithin(300 * time.Millisecond)
}
// ---------- 在线判定 / 握手期限 / 恢复窗口降级 ----------
// syncBuffer 是带锁的日志缓冲:日志由实时层的多条 goroutine 写、用例在主 goroutine 读。
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// tune 直接改内部参数:这些值只影响超时,用例需要的是毫秒级跑完,
// 而 Set* 那一族是留给现场调优的(且本包不该再为测试多开一个导出 API)。
func (e *testEnv) tune(fn func(h *Hub)) {
e.hub.mu.Lock()
defer e.hub.mu.Unlock()
fn(e.hub)
}
// waitForWithin 与 waitFor 相同,只是等待窗口由调用方给:有的用例要等一次完整的
// 服务端关闭握手(serve 里最多 2s)。
func waitForWithin(t *testing.T, what string, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等待条件超时: %s", what)
}
func instanceOnline(insts []Instance, id string) bool {
for _, st := range insts {
if st.ID == id {
return st.Online
}
}
return false
}
// trySend 发送一帧并在失败时返回错误:用例要靠它判断「服务端已经关掉这条连接」
// (普通 send 会直接 Fatal,那条路径在这里是预期结果而不是失败)。
func (c *wsClient) trySend(event string, data any) error {
msg, err := encode(event, data)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.ctx, 2*time.Second)
defer cancel()
return c.conn.Write(ctx, websocket.MessageText, msg)
}
// muteConn 是一条「连上就不再读」的客户端:它的 TCP 连接还在,但永远不会回应 pong——
// 这正是拔网线 / 断电 / 机器休眠之后,服务端那一侧看到的样子。
type muteConn struct {
t *testing.T
ctx context.Context
conn *websocket.Conn
}
func dialMute(t *testing.T, env *testEnv, hello helloPayload) *muteConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(env.srv.URL, "http")+"/ws", nil)
if err != nil {
cancel()
t.Fatalf("连接 /ws: %v", err)
}
t.Cleanup(func() {
_ = conn.CloseNow()
cancel()
})
c := &muteConn{t: t, ctx: ctx, conn: conn}
if err := c.send(EventHello, hello); err != nil {
t.Fatalf("发送 hello: %v", err)
}
return c
}
func (c *muteConn) send(event string, data any) error {
msg, err := encode(event, data)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
defer cancel()
return c.conn.Write(ctx, websocket.MessageText, msg)
}
// 「在线」必须反映真的还活着:连接对象还在、但对端已经不应答的屏不能一直算命中。
// 判据是真实的 ping/pong 往返,不是服务端自己刷的时间戳。
func TestInstanceGoesOfflineWhenPeerStopsAnswering(t *testing.T) {
env := newTestEnv(t)
env.tune(func(h *Hub) {
h.livenessInterval = 80 * time.Millisecond
h.pongTimeout = 60 * time.Millisecond
})
// 失联的屏:握手正常,之后一个字节都不回。
dialMute(t, env, helloPayload{Role: RoleDisplay, InstanceID: "mute-1", Page: "standings"})
waitFor(t, "失联实例登记在线", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && insts[0].Online
})
// 正常的大屏同时挂着:它一直在收(于是自动回 pong),绝不能被误判成离线。
alive := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "alive-1", Page: "standings"})
alive.recvEvent(EventSnapshot)
waitForWithin(t, "失联实例被判离线", 5*time.Second, func() bool {
return !instanceOnline(env.hub.Instances(), "mute-1")
})
if !instanceOnline(env.hub.Instances(), "alive-1") {
t.Fatal("正常应答的实例不该被判离线")
}
cmd, err := NewDisplayCommand(CmdReload, nil)
if err != nil {
t.Fatalf("构造 reload: %v", err)
}
if matched, err := env.hub.SendCommandToInstances([]string{"mute-1"}, cmd); err != nil || matched != 0 {
t.Fatalf("失联实例不该再计入命中,得到 %d err=%v", matched, err)
}
// 在线的屏不受影响:它的连接与服务端之间一切照旧。
if ev, _, ok := alive.recvWithin(200 * time.Millisecond); ok {
t.Fatalf("正常实例不该收到额外消息,得到 %q", ev)
}
}
// 握手超时是**绝对** deadline:以前每轮 Read 都重开一个 helloTimeout 窗口,
// 没握手的连接只要在窗口内随便发一帧就能无限期挂着(脚本重连能堆出一片空连接)。
func TestHelloDeadlineIsAbsolute(t *testing.T) {
env := newTestEnv(t)
env.tune(func(h *Hub) { h.helloTO = 300 * time.Millisecond })
// 不 hello,只反复喂合法但无关的帧(ack)。
c := env.dial(t, nil)
closed := false
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) && !closed {
select {
case msg, ok := <-c.msgs:
if !ok || msg.err != nil {
closed = true
}
case <-time.After(60 * time.Millisecond):
// 写失败说明服务端已经关了这条连接,同样算「已断开」。
closed = c.trySend(EventAck, ackPayload{}) != nil
}
}
if !closed {
t.Fatal("未握手的连接必须在绝对 deadline(300ms)后被断开,而不是每收一帧就重置计时")
}
}
// 心跳循环不能替客户端盖「最后活跃」的章:一块不发任何上行帧的大屏,它的
// LastHeartbeat 必须停在真实收到帧的那一刻,否则注册表里的「在线/最后活跃」
// 全是服务端自己说的,拔了网线的屏也会显示成刚刚活跃(这正是 A24 的根因)。
func TestHeartbeatDoesNotRefreshActivityByItself(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(30 * time.Millisecond)
// 关掉存活探测:这条用例只验证「时间戳的口径」,探测本身另有用例。
env.tune(func(h *Hub) { h.livenessInterval = 0 })
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
insts := env.hub.Instances()
if len(insts) != 1 {
t.Fatalf("实例数应为 1,得到 %d", len(insts))
}
before := insts[0].LastHeartbeat
// 连收 5 个心跳,期间客户端一个上行帧都不发(它只读)。
for i := 0; i < 5; i++ {
c.recvEvent(EventHeartbeat)
}
if got := env.hub.Instances()[0].LastHeartbeat; !got.Equal(before) {
t.Fatalf("服务端自己发心跳不能把「最后活跃」往前推:%v → %v", before, got)
}
}
// 恢复窗口(库已经关掉、文件还没换完)里实时层必须安静下来:不刷 WARN/ERROR,
// 也不拿 1011 把刚连上或正在重连的屏踢回去——现场会把那读成「恢复把系统搞坏了」。
// 降级口径与 httpapi/realtime.go 的 dbClosed 一致:只记 Debug,连接留着、心跳照发。
func TestRecoveryWindowDegradesQuietly(t *testing.T) {
logs := &syncBuffer{}
env := newTestEnvFull(t, &auth.User{Role: auth.RoleGuest}, "", logs)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
// 模拟恢复动作的中间状态:目标库被关掉(换文件前的一刻)。
if err := env.db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
// 大屏想补数据(resync):这条路径在恢复窗口里必然读不到库。
c.send(EventResync, struct{}{})
// 连接必须留着,心跳照常下发(被 1011 打回时 recvWithin 会直接 Fatal)。
if ev, _, ok := c.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat {
t.Fatalf("恢复窗口里已连接的屏应保持在线并继续收心跳,得到 %q ok=%v", ev, ok)
}
// 恢复窗口里新连上的屏同理:首包拿不到也不能被踢。
fresh := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d2"})
if ev, _, ok := fresh.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat {
t.Fatalf("恢复窗口里新连接应保持并收到心跳,得到 %q ok=%v", ev, ok)
}
got := logs.String()
if strings.Contains(got, "level=ERROR") || strings.Contains(got, "level=WARN") {
t.Fatalf("恢复窗口里不该出现 WARN/ERROR 日志:\n%s", got)
}
if !strings.Contains(got, "库已关闭") {
t.Fatalf("恢复窗口应留下 Debug 级的降级记录(否则真出问题时无从查起):\n%s", got)
}
}
+35 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"sort"
"strconv"
"strings"
"time"
)
@@ -111,7 +112,9 @@ func (h *Hub) releaseDisplayID(id string, c *client) {
st.slow = c.slow.Load()
}
// touchInstance 在收到客户端消息或下发心跳时刷新「最后心跳」。
// touchInstance 在收到客户端消息或探测到 pong 时刷新「最后活跃」。
// now 必须是**收到对端帧的时间**,不能是服务端自己想盖的章:这个字段既决定注册表
// 里的「最后心跳」,也是离线实例清理的计时起点(见 pruneInstancesLocked)。
func (h *Hub) touchInstance(c *client, now time.Time) {
c.mu.Lock()
id := c.instanceID
@@ -129,9 +132,32 @@ func (h *Hub) touchInstance(c *client, now time.Time) {
}
}
// syncInstance 只把连接的 ack / 慢标记同步到注册表,**不碰 lastHeartbeat**。
// 心跳循环用它:服务端自己发过心跳不能算「对端还活着」的证据,
// 否则一块拔了网线的屏会一直被刷成「刚刚活跃」。
func (h *Hub) syncInstance(c *client) {
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
if id == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if st := h.instances[id]; st != nil {
st.ackVersion = c.ackVersion.Load()
st.slow = c.slow.Load()
}
}
// setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。
// targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后
// 每块屏的「当前页」必须各算各的。
//
// targets 在这里再 TrimSpace 一次是防御性的:调用方(SendCommandToInstances)已经
// 归一化过,但只要有第二个调用方忘了,注册表里就会出现「id 带空格」这类永远匹配不上的
// 死条目,而症状是「指令发出去了、界面上的当前页不变」——很难查。
func (h *Hub) setInstancePage(page string, targets []string) {
if page == "" {
return
@@ -140,7 +166,9 @@ func (h *Hub) setInstancePage(page string, targets []string) {
if len(targets) > 0 {
picked = make(map[string]bool, len(targets))
for _, id := range targets {
picked[id] = true
if id = strings.TrimSpace(id); id != "" {
picked[id] = true
}
}
}
@@ -184,6 +212,11 @@ func (h *Hub) pruneInstancesLocked(now time.Time) {
}
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
//
// online = 「还有连接挂着」。这里刻意不叠加 TTL 判断:只要连接还在,客户端就是那条
// 连接上的证据——而连接本身已经被 livenessLoop 用 ping/pong 求证过(不回 pong 的连接
// 会先被断开、引用计数归零)。反过来,若在这里按 lastHeartbeat 再判一次,
// 一块正常但不发上行帧的大屏(数据没变时它只在收心跳)就会被误判成离线。
func (h *Hub) Instances() []Instance {
h.mu.Lock()
defer h.mu.Unlock()
+7
View File
@@ -23,12 +23,19 @@ const (
)
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
//
// admin:<userId> 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息):
// 保留它是因为 devdocs/05 §1 把「admin:<userId>(定向通知)」写成了协议的一部分,
// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。
const (
RoomDisplay = "display"
RoomAdmin = "admin"
)
// AdminRoom 返回某个管理端用户的定向房间名。
//
// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报;
// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
// 连接角色。
+1 -1
View File
@@ -215,7 +215,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
}
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、奖牌开关生效、
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error {
ledger, err := loadLedger(ctx, tx)
+2 -2
View File
@@ -300,7 +300,7 @@ func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) {
}
}
// 团体奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。
// 奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。
func TestSnapshotStandingsMedalSwitch(t *testing.T) {
env := newTestEnv(t)
db := env.db
@@ -327,7 +327,7 @@ func TestSnapshotStandingsMedalSwitch(t *testing.T) {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 {
t.Fatalf("关闭团体奖牌后应清零: %+v", snap.Standings)
t.Fatalf("关闭奖牌开关后应清零: %+v", snap.Standings)
}
}
+1 -1
View File
@@ -208,7 +208,7 @@ eventOverrides:
t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got)
}
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、团体奖牌并入照旧。
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、并入单位榜照旧。
wants := map[string][3]int{
"EVT_IND": {1, 0, 0},
"EVT_TEAM": {1, 0, 1},
+1 -1
View File
@@ -63,7 +63,7 @@ type Standings struct {
ShowIndividualMedals *bool `yaml:"showIndividualMedals,omitempty" json:"showIndividualMedals,omitempty"`
}
// MedalCountsTeamEventsEnabled 缺省为 true(团体奖牌榜计入团体项目奖牌)。
// MedalCountsTeamEventsEnabled 缺省为 true(单位奖牌榜计入团体项目奖牌)。
func (s Standings) MedalCountsTeamEventsEnabled() bool {
return boolOrDefault(s.MedalCountsTeamEvents, true)
}
+51 -7
View File
@@ -2,15 +2,20 @@ package store
import (
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
const createMigrationsTable = `CREATE TABLE IF NOT EXISTS schema_migrations (
// 迁移记账用的表名,出现在报错里给现场照抄,所以单独提成常量。
const migrationsTable = "schema_migrations"
const createMigrationsTable = `CREATE TABLE IF NOT EXISTS ` + migrationsTable + ` (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
@@ -21,7 +26,7 @@ func (s *Store) Migrate() error {
if err != nil {
return fmt.Errorf("定位系统库迁移目录: %w", err)
}
return migrate(s.db, fsys, "system")
return migrate(s.db, fsys, "系统库")
}
// MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开,
@@ -31,18 +36,20 @@ func MigrateEvent(db *sql.DB) error {
if err != nil {
return fmt.Errorf("定位赛事库迁移目录: %w", err)
}
return migrate(db, fsys, "event")
return migrate(db, fsys, "赛事库")
}
// migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。
// label 是给人看的库名(「系统库」/「赛事库」),会出现在每一条报错里:
// 现场拿到一句「迁移失败」时,第一件要知道的就是**哪个库、哪个文件**。
func migrate(db *sql.DB, fsys fs.FS, label string) error {
if _, err := db.Exec(createMigrationsTable); err != nil {
return fmt.Errorf("创建 schema_migrations: %w", err)
return fmt.Errorf("%s:创建 %s 表失败: %w", label, migrationsTable, err)
}
applied, err := appliedMigrations(db)
if err != nil {
return err
return fmt.Errorf("%s:%w", label, err)
}
names, err := fs.Glob(fsys, "*.sql")
@@ -57,16 +64,53 @@ func migrate(db *sql.DB, fsys fs.FS, label string) error {
}
body, err := fs.ReadFile(fsys, name)
if err != nil {
return fmt.Errorf("读取迁移 %s: %w", name, err)
return fmt.Errorf("%s:读取迁移文件 %s 失败: %w", label, name, err)
}
if err := applyMigration(db, name, string(body)); err != nil {
return err
return migrateFailure(label, name, err)
}
slog.Info("已应用迁移", "db", label, "name", name)
}
return nil
}
// migrateFailure 把一条迁移的失败翻译成现场能照着做的报错。
//
// 为什么要专门做这件事:migrate 只按文件名记账(schema_migrations.name),没有校验和,
// 而历史迁移里有一批 `ALTER TABLE ... ADD COLUMN` 这种**不可重放**的语句
// (0013/0014/0015/0018/0019/0020)。一旦 schema_migrations 少了一行
// (手工删行、从别处拷库、把老库文件换回来),下次启动就会把已经执行过的迁移再跑一遍,
// 于是启动直接失败在「duplicate column name: ...」上:现场只看到一句英文 SQL 错误,
// 不知道该改哪儿,也不知道该不该动库。
//
// 这里是**低成本护栏**,只做两件不需要动迁移机制的事:
// - 报错点名「哪个库 + 哪个文件」;
// - 失败长得像「重复执行」时,把成因和两种处置写清楚(补记一行 / 用备份恢复)。
//
// 真正的修法(记账加校验和 + 迁移本身写成可重放)要改迁移机制,见审查清单 A08,
// 留待专门一轮。
func migrateFailure(label, name string, err error) error {
msg := fmt.Sprintf("%s:迁移 %s 执行失败: %v", label, name, err)
if replayLike(err) {
msg += ";这条迁移很可能已经执行过,只是 " + migrationsTable + " 里没有它的记录" +
"(手工删过行 / 从别处拷来的库 / 换回了旧库文件)。确认库结构确实已经包含这次改动后," +
"补记一行再重启:" + fmt.Sprintf("INSERT INTO %s(name) VALUES('%s');", migrationsTable, name) +
"(若不确定,用备份恢复整库;不要修改已经跑过的迁移文件。)"
}
return errors.New(msg)
}
// replayLike 判断这次失败是否像「把不可重放的迁移又执行了一遍」。
// SQLite 的原文在这两种情况下是固定的:ALTER TABLE 重复加列报 duplicate column name,
// CREATE 重名报 already exists。只认这两句,别的失败(锁超时、SQL 写错、库损坏)照原样报。
func replayLike(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "duplicate column name") || strings.Contains(s, "already exists")
}
func appliedMigrations(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT name FROM schema_migrations`)
if err != nil {
+3 -1
View File
@@ -1,4 +1,6 @@
// Package store 负责数据库连接、迁移与仓储。
// Package store 只负责数据库连接(Open / Close / DB)与迁移(Migrate / MigrateEvent)。
//
// 这里没有仓储代码:真正的读写都在 internal/httpapi 的各 repo*.go 里。
package store
import (
+48
View File
@@ -2,9 +2,11 @@ package store
import (
"database/sql"
"errors"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
@@ -451,3 +453,49 @@ func queryString(t *testing.T, db *sql.DB, query string, args ...any) string {
}
return v
}
// migrate 只按文件名记账、没有校验和,而历史迁移里有一批 ADD COLUMN 是不可重放的。
// schema_migrations 一旦少一行(手工删行 / 从别处拷库 / 换回老库文件),下次启动会把
// 已经执行过的迁移再跑一遍并直接失败——这条用例守住低成本护栏:报错必须点名
// 「哪个库 + 哪个文件」,并给出可照做的处置,而不是只丢一句英文 SQL 错误。
func TestMigrateEventReportsReplayWithActionableError(t *testing.T) {
db := openEventDB(t)
if err := MigrateEvent(db); err != nil {
t.Fatalf("首次迁移: %v", err)
}
// 0013 是 `ALTER TABLE athletes ADD COLUMN id_card`:重放必然失败。
const lost = "0013_athletes_id_card.sql"
if _, err := db.Exec(`DELETE FROM schema_migrations WHERE name = ?`, lost); err != nil {
t.Fatalf("删掉迁移记录: %v", err)
}
err := MigrateEvent(db)
if err == nil {
t.Fatal("记账丢行后重放不可重放的迁移必须报错,不能静默跳过")
}
msg := err.Error()
for _, want := range []string{"赛事库", lost, "已经执行过", "schema_migrations", "INSERT INTO"} {
if !strings.Contains(msg, want) {
t.Fatalf("报错应包含 %q(现场要照着报错直接处置),实际是:\n%s", want, msg)
}
}
}
// 处置提示只跟着「重复执行」这一类失败走:别的失败(锁超时、SQL 写错、库损坏)
// 不能被误导成「补记一行就好」,那会把真故障盖掉。
func TestMigrateFailureHintOnlyForReplay(t *testing.T) {
plain := migrateFailure("赛事库", "0099_demo.sql", errors.New("database is locked"))
if !strings.Contains(plain.Error(), "赛事库") || !strings.Contains(plain.Error(), "0099_demo.sql") {
t.Fatalf("报错必须点名库与文件: %s", plain)
}
if strings.Contains(plain.Error(), "已经执行过") {
t.Fatalf("非重放失败不该给出「补记一行」的提示: %s", plain)
}
replay := migrateFailure("赛事库", "0013_athletes_id_card.sql",
errors.New("duplicate column name: id_card"))
if !strings.Contains(replay.Error(), "已经执行过") {
t.Fatalf("重放失败应给出处置提示: %s", replay)
}
}
+43
View File
@@ -4,6 +4,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -83,6 +84,9 @@ func main() {
if dataDirFromFlag {
logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir)
}
// 上一次恢复没走完就退出时留下的标记:必须在这里读一次,否则现场最需要线索的那次事故
// 只能靠人去数据目录里翻一个没人提过的文件。
warnPendingRestore(logger, cfg.DataDir)
projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project)
if err != nil {
@@ -210,6 +214,45 @@ func initialAdminBanner(username, password string) string {
"========================================\n\n", username, password)
}
// warnPendingRestore 启动时读一次「恢复进行中」标记(数据目录根部的 pending_restore.json)。
//
// 这个标记由「一键恢复」在替换库文件之前写下、正常收尾(无论成功还是失败)时立刻删掉,
// 所以它还留在数据目录里只有一种解释:上一次恢复没走到收尾(被强杀 / 掉电 / 蓝屏)。
// 在那之前全仓没有任何一行代码读它——恢复出事后现场最需要的线索就静静躺在数据目录根部,
// 它既不在数据目录清单里,日志里也没有半点影子。这里把它变成启动时的一条 WARN。
//
// 只读、不删:它是那一次未收尾恢复的唯一记录,删不删该由操作员决定(下一次成功的恢复会自己删掉它)。
// 内容解析不了同样不拦启动:它只是线索,不是数据;库能不能打开由后面各步各自把关。
func warnPendingRestore(logger *slog.Logger, dataDir string) {
if strings.TrimSpace(dataDir) == "" {
return
}
path := filepath.Join(dataDir, export.PendingRestoreFile)
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return
}
if err != nil {
logger.Warn("读取上次恢复的标记失败", "path", path, "err", err)
return
}
var pending export.PendingRestore
if err := json.Unmarshal(raw, &pending); err != nil {
logger.Warn("上次恢复的标记内容无法解析,请手工检查这个文件", "path", path, "err", err)
return
}
logger.Warn("检测到上一次「一键恢复」未收尾的标记:恢复中途进程退出了",
"path", path,
"kind", string(pending.Kind),
"backupName", pending.BackupName,
"target", pending.Target,
"safetyCopy", pending.SafetyCopy,
"operator", pending.Operator,
"requestedAt", pending.RequestedAt.Format(time.RFC3339),
"hint", "当前库可能停在替换后的状态;要重做就用备份再恢复一次,"+
"要退回替换前的现场就用上面那份安全副本恢复")
}
// displaySender 把 httpapi 的大屏命令接口适配到实时层。
type displaySender struct{ hub *realtime.Hub }
+100
View File
@@ -1,11 +1,15 @@
package main
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
@@ -253,3 +257,99 @@ func TestInitialAdminBannerAsciiAnchor(t *testing.T) {
t.Fatalf("ASCII 锚应在密码行之后:\n%s", banner)
}
}
// ---------- C35:恢复标记的启动告警 ----------
// writeRestoreMarker 用真实写入路径(export.WritePendingRestore)造一份恢复标记,
// 字段名与落盘格式都跟「一键恢复」写下的那份一模一样。
func writeRestoreMarker(t *testing.T, dataDir string, p export.PendingRestore) string {
t.Helper()
path, err := export.WritePendingRestore(dataDir, p)
if err != nil {
t.Fatalf("写恢复标记: %v", err)
}
return path
}
// sampleRestoreMarker 是一份典型的「恢复写到一半进程没了」的标记。
func sampleRestoreMarker() export.PendingRestore {
return export.PendingRestore{
Kind: export.RestoreKindEvent,
Backup: "/data/backups/event-manual-20260911-201500.db",
BackupName: "event-manual-20260911-201500.db",
Target: "/data/event_projs/autumn/event.db",
SafetyCopy: "/data/backups/pre-restore-event-20260911-201503.db",
Operator: "admin#1",
RequestedAt: time.Date(2026, 9, 11, 20, 15, 3, 0, time.UTC),
}
}
// 恢复标记留在数据目录里 = 上一次恢复没走完。启动时必须读到它并告警,
// 且告警要带上「盖的是哪个库、用哪份备份、退回用的安全副本是哪份、谁点的」——
// 只说一句「有残留文件」,现场还是不知道该怎么办。
func TestWarnPendingRestoreReportsMarker(t *testing.T) {
dataDir := t.TempDir()
marker := writeRestoreMarker(t, dataDir, sampleRestoreMarker())
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, nil))
warnPendingRestore(logger, dataDir)
logs := buf.String()
for _, want := range []string{
`"level":"WARN"`,
"未收尾",
sampleRestoreMarker().BackupName,
sampleRestoreMarker().Target,
sampleRestoreMarker().SafetyCopy,
sampleRestoreMarker().Operator,
"2026-09-11T20:15:03Z",
} {
if !strings.Contains(logs, want) {
t.Fatalf("启动告警应含 %q,得到:\n%s", want, logs)
}
}
// 只告警、不删:这份标记是那次未收尾恢复的唯一记录,删不删该由操作员决定。
if _, err := os.Stat(marker); err != nil {
t.Fatalf("告警不该动这份标记(它是唯一线索): %v", err)
}
}
// 没有标记时一个字都不许刷:正常启动的日志里出现「未收尾」会把人引向不存在的事故。
func TestWarnPendingRestoreSilentWithoutMarker(t *testing.T) {
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, nil))
warnPendingRestore(logger, t.TempDir())
if buf.Len() != 0 {
t.Fatalf("没有恢复标记时不该产生任何日志,得到:\n%s", buf.String())
}
}
// 标记内容坏了(写了一半 / 被人手工改坏)不能拦启动、也不能静默:要告警说明这个文件有问题。
func TestWarnPendingRestoreToleratesBrokenMarker(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join(dataDir, export.PendingRestoreFile)
if err := os.WriteFile(path, []byte("{不是 JSON"), 0o644); err != nil {
t.Fatalf("写坏标记: %v", err)
}
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, nil))
warnPendingRestore(logger, dataDir)
if !strings.Contains(buf.String(), "无法解析") || !strings.Contains(buf.String(), path) {
t.Fatalf("坏标记应告警并指出文件路径,得到:\n%s", buf.String())
}
}
// 光测上面那几个函数还不够:漏掉 main 里那一行调用,标记照样没人读(这正是 C35 的原状——
// 写入方齐全、读取方为零)。这里连同调用点一起钉住,参考网页侧「模板契约断言」的做法。
func TestMainReadsPendingRestoreMarkerAtStartup(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("读 main.go: %v", err)
}
if !strings.Contains(string(raw), "warnPendingRestore(logger, cfg.DataDir)") {
t.Fatalf("main.go 里找不到启动时读恢复标记的调用:标记又会变回没人读的孤儿文件")
}
}
@@ -1,4 +1,6 @@
-- 成绩版本图谱:parent_id 形成 DAG,撤销后从撤销点分支,仅用于可视化。
-- 成绩版本图谱:parent_id 指向上一条版本,仅用于可视化。
-- ⚠️ 不是 DAG:写入侧(httpapi.recordRevision)恒取「同小项当前 MAX(id)」,所以一个小项一条链、
-- 撤销后再发布也接在链尾,不会从撤销点分出分支;每个小项的第一条版本 parent_id 为 NULL。
CREATE TABLE IF NOT EXISTS result_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES result_revisions(id),
@@ -0,0 +1,291 @@
package event
import (
"database/sql"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// C12:0001–0011 的老库一次性升到最新。
//
// 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳;
// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0020 九条,
// 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是
// 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍:
// 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。
func TestMigration0001ToLatestKeepsData(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0012") // 只应用 0001–0011,回到「很久没升级过」的现场
// 老库数据:只用 0011 之前存在的列(id_card / bib / age / deleted_at / heat / avg_mode 等都还不存在)。
stmts := []string{
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
VALUES ('T1', '第一分公司', '一司', '#FF0000', 3, 0)`,
`INSERT INTO athletes(name, gender, team_id, employee_no, active) VALUES ('张三', '男', 1, 'E001', 1)`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 5, 1)`,
`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, 'published', 7, '田径场', '备注')`,
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (1, '男子', 3, '男', 18, 35, 9)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'athlete', 'result', 1, 0)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, is_tie, status, state)
VALUES (1, 1, 1, '11.58', 11.58, 0, 'ok', 'published')`,
`INSERT INTO records(event_id, level, result_value, holder_name, is_current, source)
VALUES (1, 'meet', 11.2, '李四', 1, 'history')`,
`INSERT INTO record_breaks(event_id, entry_id, level, result_value, bonus_points, state)
VALUES (1, 1, 'meet', 11.2, 5, 'approved')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, source_type, source_id, points, voided, void_reason)
VALUES (1, 'adjustment', 1, -3, 1, '人工扣分')`,
`INSERT INTO point_adjustments(team_id, delta, category, reason, event_id, voided)
VALUES (1, 5, 'bonus', '测试奖励', 1, 0)`,
`INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
`INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES ('team', 1, 'upsert', '{}')`,
`INSERT INTO audit_logs(user_id, action, entity_type, entity_id, ip, request_id)
VALUES (1, 'team.create', 'team', 1, '127.0.0.1', 'req-1')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:新列与新表都不该在。
for _, col := range []string{"id_card", "bib", "age"} {
var probe sql.NullString
if err := db.QueryRow(`SELECT ` + col + ` FROM athletes`).Scan(&probe); err == nil {
t.Fatalf("升级前不该有 athletes.%s 列", col)
}
}
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n); err != nil {
t.Fatalf("查表 %s: %v", table, err)
}
if n != 0 {
t.Fatalf("升级前不该有 %s 表", table)
}
}
// 走真实升级路径:一次补跑 0012~0020 全部九条。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("从 0001–0011 直接升到最新失败: %v", err)
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
// ① 数据一行不少、一行不多(迁移里没有隐式的删改)。
for table, want := range map[string]int{
"teams": 1, "athletes": 1, "sports": 1, "events": 1, "event_categories": 1,
"event_score_channels": 1, "entries": 1, "results": 1, "records": 1, "record_breaks": 1,
"score_ledger": 2, "point_adjustments": 1, "result_revisions": 2, "change_log": 1, "audit_logs": 1,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 项目里的积分模板由 0011 种下,升级不能把它冲掉(新库现场靠它算分)。
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Errorf("scoring_profiles 行数 = %d,期望 2(0011 种下的两个模板)", got)
}
if got := countRows(t, db, "scoring_profile_rules"); got != 12 {
t.Errorf("scoring_profile_rules 行数 = %d,期望 12", got)
}
// ② 老行上的关键取值原样保留。
var (
teamName, teamColor string
teamActive, teamSortOrder int
)
if err := db.QueryRow(`SELECT name, COALESCE(color, ''), active, sort_order FROM teams WHERE id = 1`).
Scan(&teamName, &teamColor, &teamActive, &teamSortOrder); err != nil {
t.Fatalf("读单位: %v", err)
}
if teamName != "第一分公司" || teamColor != "#FF0000" || teamActive != 0 || teamSortOrder != 3 {
t.Errorf("老单位被改动: name=%q color=%q active=%d sort=%d", teamName, teamColor, teamActive, teamSortOrder)
}
var (
eventName, eventStatus, venue, remark string
eventMultiplier float64
eventPublished string
)
if err := db.QueryRow(`SELECT name, status, COALESCE(venue, ''), COALESCE(remark, ''), multiplier
FROM events WHERE id = 1`).
Scan(&eventName, &eventStatus, &venue, &remark, &eventMultiplier); err != nil {
t.Fatalf("读小项: %v", err)
}
eventPublished = eventStatus
if eventName != "100米跑" || eventPublished != "published" || venue != "田径场" ||
remark != "备注" || eventMultiplier != 2 {
t.Errorf("老小项被改动: name=%q status=%q venue=%q remark=%q multiplier=%v",
eventName, eventPublished, venue, remark, eventMultiplier)
}
var rank, resultState string
var resultValue float64
if err := db.QueryRow(`SELECT rank, state, result_value FROM results WHERE entry_id = 1`).
Scan(&rank, &resultState, &resultValue); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != "1" || resultState != "published" || resultValue != 11.58 {
t.Errorf("老成绩被改动: rank=%s state=%q value=%v", rank, resultState, resultValue)
}
var ledgerPoints float64
var ledgerVoided int
if err := db.QueryRow(`SELECT points, voided FROM score_ledger WHERE source_type = 'adjustment'`).
Scan(&ledgerPoints, &ledgerVoided); err != nil {
t.Fatalf("读流水: %v", err)
}
if ledgerPoints != -3 || ledgerVoided != 1 {
t.Errorf("老流水被改动: points=%v voided=%d", ledgerPoints, ledgerVoided)
}
// 重建 score_ledger(0017)之后父子关系与自增序列都要正常。
var childParent sql.NullInt64
if err := db.QueryRow(`SELECT parent_id FROM result_revisions WHERE id = 2`).Scan(&childParent); err != nil {
t.Fatalf("读变更历史: %v", err)
}
if !childParent.Valid || childParent.Int64 != 1 {
t.Errorf("result_revisions 父子关系未保留: %+v", childParent)
}
// ③ 每个新列/新表都按文档取默认值:0013–0019 是可空列(老行必须为空),
// 0017 与 0020 的开关默认 1(与升级前「算分、算奖牌、计入总分」的行为一致)。
var (
idCard, bib sql.NullString
age sql.NullInt64
heat sql.NullInt64
)
if err := db.QueryRow(`SELECT id_card, bib, age FROM athletes WHERE id = 1`).
Scan(&idCard, &bib, &age); err != nil {
t.Fatalf("读人员新列: %v", err)
}
if idCard.Valid || bib.Valid || age.Valid {
t.Errorf("老人员的新列应全为空: idCard=%v bib=%v age=%v", idCard, bib, age)
}
var deletedAt sql.NullString
if err := db.QueryRow(`SELECT deleted_at FROM teams WHERE id = 1`).Scan(&deletedAt); err != nil {
t.Fatalf("读 deleted_at: %v", err)
}
if deletedAt.Valid {
t.Errorf("老单位的 deleted_at 应为空: %q", deletedAt.String)
}
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&heat); err != nil {
t.Fatalf("读 heat: %v", err)
}
if heat.Valid {
t.Errorf("老报名的 heat 应为空(未分批): %d", heat.Int64)
}
var (
avgMode string
eventAwardMedals, scored int
countToTeam int
)
if err := db.QueryRow(`SELECT avg_mode, award_medals, scored FROM events WHERE id = 1`).
Scan(&avgMode, &eventAwardMedals, &scored); err != nil {
t.Fatalf("读小项新列: %v", err)
}
if avgMode != "valid" || eventAwardMedals != 1 || scored != 1 {
t.Errorf("老小项的新开关不对: avg_mode=%q award_medals=%d scored=%d", avgMode, eventAwardMedals, scored)
}
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
t.Fatalf("读通道: %v", err)
}
if countToTeam != 1 {
t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam)
}
var ledgerCountToTeam int
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE source_type = 'result'`).Scan(&ledgerCountToTeam); err != nil {
t.Fatalf("读老流水: %v", err)
}
if ledgerCountToTeam != 1 {
t.Errorf("重建后的老流水 count_to_team = %d,期望 1", ledgerCountToTeam)
}
var countToMedals, countToTeamMedals int
if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`).
Scan(&countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("读通道奖牌开关: %v", err)
}
if countToMedals != 1 || countToTeamMedals != 1 {
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
}
// ④ 新表建出来了,且是空的(升级不该凭空造名次)。
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
if got := countRows(t, db, table); got != 0 {
t.Errorf("%s 升级后应为空表,得到 %d 行", table, got)
}
}
// 0012 补的 (entry_id, voided) 索引与 0013–0015 的唯一索引都必须建在。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
('idx_score_ledger_entry_voided', 'idx_athletes_id_card',
'idx_athletes_bib', 'idx_channel_ranks_unique')`).Scan(&indexCount); err != nil {
t.Fatalf("查索引: %v", err)
}
if indexCount != 4 {
t.Errorf("升级后索引缺失,命中 %d / 4", indexCount)
}
// ⑤ 外键与数据一致性没有悬挂(0016 改的是建表语句、0017 重建过表)。
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("跨九条迁移升级后存在外键悬挂")
}
var fkEnabled int
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil {
t.Fatalf("读外键开关: %v", err)
}
if fkEnabled != 1 {
t.Error("升级后外键开关应仍然打开")
}
// ⑥ 升级后的库要能真的用上这批新能力(列建了但写不进去,等于没升)。
if _, err := db.Exec(`UPDATE athletes SET id_card = '110101199001011234', bib = 'A001', age = 30 WHERE id = 1`); err != nil {
t.Fatalf("写人员新列: %v", err)
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode, award_medals)
VALUES (1, 'E2', '双计项', 'individual_team', 'all', 0)`); err != nil {
t.Fatalf("写 0016/0017/0020 的新枚举与新开关: %v", err)
}
if _, err := db.Exec(`UPDATE entries SET heat = 2 WHERE id = 1`); err != nil {
t.Fatalf("写 heat: %v", err)
}
if _, err := db.Exec(`UPDATE teams SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
t.Fatalf("写 deleted_at: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope,
subject_key, entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
t.Fatalf("写 0020 新建的名次表: %v", err)
}
// 新 CHECK 也在:合法的拒绝了非法值才算真的生效。
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil {
t.Error("升级后非法 event_type 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
t.Error("升级后非法 avg_mode 应被 CHECK 拒绝")
}
// ⑦ 再跑一次迁移:不报错,也不再改动任何数据。
countsBefore := map[string]int{}
for _, table := range []string{"teams", "athletes", "events", "score_ledger", "channel_ranks", "schema_migrations"} {
countsBefore[table] = countRows(t, db, table)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复迁移: %v", err)
}
for table, before := range countsBefore {
if got := countRows(t, db, table); got != before {
t.Errorf("重复迁移改动 %s 行数:%d → %d", table, before, got)
}
}
}