fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+22 -6
View File
@@ -31,6 +31,8 @@ const (
DefaultLoginPerMinute = 5
// DefaultWritePerMinute 写操作限流:30 次/分钟/用户。
DefaultWritePerMinute = 30
// MaxPasswordBytes 是 bcrypt 的输入上限;超过会被静默截断,必须显式拒绝。
MaxPasswordBytes = 72
)
// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较,
@@ -177,6 +179,9 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu
if password == "" {
return fmt.Errorf("密码不能为空")
}
if len(password) > MaxPasswordBytes {
return fmt.Errorf("密码不能超过 %d 字节", MaxPasswordBytes)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return fmt.Errorf("生成密码哈希: %w", err)
@@ -445,15 +450,26 @@ func randomToken(n int) (string, error) {
}
// randomPassword 生成去掉易混淆字符的随机密码。
// 用拒绝采样消除取模偏差,避免某些字符出现概率更高。
func randomPassword(n int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
// 只接受小于 256-(256%len) 的字节,落在尾部的字节直接丢弃重取。
const limit = 256 - (256 % len(alphabet))
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机密码: %w", err)
}
out := make([]byte, n)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
out := make([]byte, 0, n)
for len(out) < n {
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成随机密码: %w", err)
}
for _, b := range buf {
if int(b) >= limit {
continue
}
out = append(out, alphabet[int(b)%len(alphabet)])
if len(out) == n {
break
}
}
}
return string(out), nil
}
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"strings"
"testing"
)
// 随机密码只允许使用去混淆字符集,且长度精确可控。
func TestRandomPasswordCharsetAndLength(t *testing.T) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
for i := 0; i < 50; i++ {
pwd, err := RandomPassword(16)
if err != nil {
t.Fatalf("生成随机密码: %v", err)
}
if len(pwd) != 16 {
t.Fatalf("密码长度应为 16,得到 %d", len(pwd))
}
for _, r := range pwd {
if !strings.ContainsRune(alphabet, r) {
t.Fatalf("密码含字符集外字符 %q: %s", r, pwd)
}
}
}
}
// bcrypt 只取前 72 字节,超出必须显式拒绝而不是静默截断。
func TestMaxPasswordBytesMatchesBcrypt(t *testing.T) {
if MaxPasswordBytes != 72 {
t.Fatalf("MaxPasswordBytes 应为 bcrypt 的 72 字节上限,得到 %d", MaxPasswordBytes)
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func testCategory() EventCategory {
func individualEvent(multiplier float64) Event {
return Event{
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
Compare: CompareAsc, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
Compare: CompareAsc, RecordEligible: true, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
}
}
-228
View File
@@ -1,28 +1,5 @@
package domain
import (
"fmt"
"strings"
)
// LedgerOpKind 增量操作类型。
type LedgerOpKind string
const (
LedgerOpAppend LedgerOpKind = "append" // 追加一批流水
LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废
)
// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。
type LedgerOp struct {
Kind LedgerOpKind
Entries []LedgerEntry
SourceType SourceType
SourceID int64
By string
Reason string
}
// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。
type TeamPoints struct {
Total float64 // 单位总分
@@ -30,103 +7,6 @@ type TeamPoints struct {
Individual float64 // 记到个人的流水(原始分,未乘系数)
}
// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。
func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) {
out := make([]LedgerEntry, len(existing), len(existing)+len(adds))
copy(out, existing)
maxID := int64(0)
used := make(map[int64]bool, len(out)+len(adds))
for _, e := range out {
if e.ID > maxID {
maxID = e.ID
}
used[e.ID] = true
}
for _, a := range adds {
if a.ID == 0 {
continue
}
if used[a.ID] {
return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID)
}
used[a.ID] = true
if a.ID > maxID {
maxID = a.ID
}
}
for _, a := range adds {
if a.ID == 0 {
maxID++
a.ID = maxID
}
out = append(out, a)
}
return out, nil
}
// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。
func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), entries...)
for i := range out {
if out[i].ID != id {
continue
}
if out[i].Voided {
return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id)
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
return out, nil
}
return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id)
}
// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。
// 找不到时是空操作:撤销发布重复执行应当幂等。
func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。
func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].EventID != eventID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// ActiveLedger 返回未作废的流水。
func ActiveLedger(entries []LedgerEntry) []LedgerEntry {
out := make([]LedgerEntry, 0, len(entries))
for _, e := range entries {
if !e.Voided {
out = append(out, e)
}
}
return out
}
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
@@ -161,111 +41,3 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
}
return out
}
// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
active := make([]LedgerEntry, 0, len(existing))
for _, e := range existing {
if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
active = append(active, e)
}
}
if sameLedgerSet(active, fresh) {
return append([]LedgerEntry(nil), existing...), nil
}
out := append([]LedgerEntry(nil), existing...)
for i := range out {
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
}
return AppendLedger(out, fresh...)
}
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
// 人工调整由人负责,重算不得触碰。
func recomputableSource(t SourceType) bool {
return t == SourceResult || t == SourceRecordBonus
}
// ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), existing...)
for _, op := range ops {
switch op.Kind {
case LedgerOpAppend:
next, err := AppendLedger(out, op.Entries...)
if err != nil {
return nil, err
}
out = next
case LedgerOpVoidSource:
next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason)
out = next
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind)
}
}
return out, nil
}
// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。
func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) {
if !cfg.Enabled {
return nil, ErrManualAdjustmentDisabled
}
out := make([]LedgerEntry, 0, len(adjustments))
for _, a := range adjustments {
if a.Voided {
continue
}
if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" {
return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID)
}
out = append(out, LedgerEntry{
TeamID: a.TeamID,
EventID: a.EventID,
SourceType: SourceAdjustment,
SourceID: a.ID,
Points: a.Delta,
CreatedBy: a.CreatedBy,
CreatedAt: a.CreatedAt,
})
}
return out, nil
}
// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。
func sameLedgerSet(a, b []LedgerEntry) bool {
if len(a) != len(b) {
return false
}
am := ledgerSetMap(a)
bm := ledgerSetMap(b)
if len(am) != len(bm) {
return false
}
for k, v := range am {
w, ok := bm[k]
if !ok || !nearlyEqual(v, w) {
return false
}
}
return true
}
func ledgerSetMap(entries []LedgerEntry) map[string]float64 {
out := make(map[string]float64, len(entries))
for _, e := range entries {
key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID)
out[key] += e.Points
}
return out
}
+1 -242
View File
@@ -1,89 +1,6 @@
package domain
import (
"errors"
"testing"
)
func TestAppendLedger(t *testing.T) {
t.Run("自动续号", func(t *testing.T) {
out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3})
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 {
t.Fatalf("id 续号错误: %+v", out)
}
})
t.Run("保留已有行", func(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}}
out, _ := AppendLedger(base, LedgerEntry{Points: 2})
if base[0].Points != 5 || len(out) != 2 {
t.Fatalf("原流水被改动: base=%+v out=%+v", base, out)
}
})
t.Run("显式 id 冲突报错", func(t *testing.T) {
if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) {
t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err)
}
})
}
func TestVoidLedger(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}}
t.Run("作废指定流水", func(t *testing.T) {
out, err := VoidLedger(base, 1, "admin", "录入错误")
if err != nil {
t.Fatalf("作废失败: %v", err)
}
if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" {
t.Fatalf("作废信息不全: %+v", out[0])
}
if out[1].Voided {
t.Fatalf("误作废了其他流水: %+v", out[1])
}
if base[0].Voided {
t.Fatal("作废不应原地修改入参")
}
})
t.Run("重复作废报错", func(t *testing.T) {
out, _ := VoidLedger(base, 1, "admin", "x")
if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) {
t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err)
}
})
t.Run("流水不存在报错", func(t *testing.T) {
if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) {
t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err)
}
})
}
func TestVoidBySource(t *testing.T) {
base := []LedgerEntry{
{ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10},
{ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8},
{ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6},
}
out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销")
if count != 2 {
t.Fatalf("作废条数 = %d,期望 2", count)
}
if !out[0].Voided || !out[1].Voided || out[2].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
t.Run("重复作废是空操作", func(t *testing.T) {
again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销")
if count != 0 {
t.Fatalf("重复作废条数 = %d,期望 0", count)
}
if len(again) != len(out) {
t.Fatalf("流水条数变了: %d", len(again))
}
})
}
import "testing"
func TestSumByTeam(t *testing.T) {
ledger := []LedgerEntry{
@@ -116,161 +33,3 @@ func TestSumByAthlete(t *testing.T) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
func TestRecomputeEventLedger(t *testing.T) {
old := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) {
out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != 3 {
t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out))
}
if !out[0].Voided || out[1].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) {
t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total)
}
})
t.Run("无变化时原样返回", func(t *testing.T) {
same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(old) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old))
}
})
t.Run("只影响目标小项", func(t *testing.T) {
out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if out[1].Voided {
t.Fatalf("其他小项的流水被误作废: %+v", out[1])
}
})
}
func TestApplyLedgerOps(t *testing.T) {
a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}}
t.Run("追加与作废可乱序收敛", func(t *testing.T) {
ops := []LedgerOp{
{Kind: LedgerOpAppend, Entries: b},
{Kind: LedgerOpAppend, Entries: a},
{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"},
}
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("应用失败: %v", err)
}
got := SumByTeam(out, 1)
if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) {
t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got)
}
})
t.Run("未知操作报错", func(t *testing.T) {
if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) {
t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err)
}
})
}
func TestAdjustmentsToLedger(t *testing.T) {
adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"}
t.Run("未启用时报错", func(t *testing.T) {
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) {
t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err)
}
})
t.Run("启用后转成流水", func(t *testing.T) {
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj})
if err != nil {
t.Fatalf("转换失败: %v", err)
}
if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) {
t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out)
}
})
t.Run("理由必填", func(t *testing.T) {
bad := adj
bad.Reason = " "
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) {
t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err)
}
})
t.Run("已作废的调整被跳过", func(t *testing.T) {
voided := adj
voided.Voided = true
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided})
if err != nil || len(out) != 0 {
t.Fatalf("流水 = %+v err=%v,期望空", out, err)
}
})
}
func TestActiveLedger(t *testing.T) {
ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}}
got := ActiveLedger(ledger)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
}
}
// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus,
// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。
func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2},
{ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
for _, e := range out {
if e.SourceType == SourceAdjustment {
if e.Voided {
t.Fatalf("人工调整被误作废: %+v", e)
}
}
}
if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) {
t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got)
}
}
// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。
func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6},
{ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(existing) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing))
}
for _, e := range out {
if e.Voided {
t.Fatalf("幂等重算不应作废任何流水: %+v", e)
}
}
}
+4 -148
View File
@@ -131,66 +131,6 @@ func TestMatrix07_TiedFirstMedalCount(t *testing.T) {
}
}
// case 8:发布后撤销 → 流水作废,总分回退。
func TestMatrix08_UnpublishRevertsTotal(t *testing.T) {
entries, results := fixture(lineup(3))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
published := SumByTeam(es.Ledger, 1)
if !nearlyEqual(published[1001].Total, 10) {
t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total)
}
voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布")
if count != len(es.Ledger) {
t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger))
}
after := SumByTeam(voided, 1)
for id, p := range after {
if p.Total != 0 {
t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total)
}
}
}
// case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。
func TestMatrix09_EditPublishedResult(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
// 改判:entry1 变成最后一名,entry2、entry3 各前进一位。
entries[0].ID = 1
results[0].ResultValue = ptr(10)
results[1].ResultValue = ptr(1)
results[2].ResultValue = ptr(2)
fresh := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) <= len(first.Ledger) {
t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger))
}
for _, e := range rebuilt {
if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") {
t.Errorf("作废流水缺少审计信息: %+v", e)
}
}
assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8})
}
// case 10:弃权者不占名次 → 名次顺延。
func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) {
ps := lineup(5)
@@ -230,14 +170,10 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID)
}
adjustments, err := AdjustmentsToLedger(
ManualAdjustmentConfig{Enabled: true, ReasonRequired: true},
[]PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}},
)
if err != nil {
t.Fatalf("人工调整转换失败: %v", err)
}
in.Ledger = append(in.Ledger, adjustments...)
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
in.Ledger = append(in.Ledger, LedgerEntry{
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
})
after := BuildTeamStandings(in)
if after[0].TeamID != 2 {
@@ -375,83 +311,3 @@ func TestMatrix16_UnionEntry(t *testing.T) {
t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam)
}
}
// case 17:重复发布同一组 → 幂等,不产生重复流水。
func TestMatrix17_RepublishIdempotent(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
second := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) != len(first.Ledger) {
t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger))
}
before := SumByTeam(first.Ledger, 1)
after := SumByTeam(rebuilt, 1)
for id, p := range before {
if !nearlyEqual(after[id].Total, p.Total) {
t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total)
}
}
}
// case 18:乱序应用增量 → 最终状态一致。
func TestMatrix18_OutOfOrderIncrements(t *testing.T) {
makeScore := func(eventID int64) []LedgerEntry {
entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}})
entries[0].EventID = eventID
results[0].EventID = eventID
return mustScore(t, EventScoreInput{
Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1},
Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}).Ledger
}
a := makeScore(11)
b := makeScore(22)
appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a}
appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b}
voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"}
orders := map[string][]LedgerOp{
"A→B→撤销A": {appendA, appendB, voidA},
"B→A→撤销A": {appendB, appendA, voidA},
"A→撤销A→B": {appendA, voidA, appendB},
}
var baseline map[int64]TeamPoints
for name, ops := range orders {
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
got := SumByTeam(out, 1)
if baseline == nil {
baseline = got
continue
}
if len(got) != len(baseline) {
t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline))
}
for id, p := range baseline {
if !nearlyEqual(got[id].Total, p.Total) {
t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total)
}
}
}
if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) {
t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline)
}
}
+53 -2
View File
@@ -16,6 +16,12 @@ func (c RecordBonusConfig) levelPoints(code string) (float64, bool) {
// 同一人同时破多级时,max 取最高档、sum 累加,口径由配置决定。
// 未配置的档位视为无加分,避免脏 level 凭空加分。
func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonus {
return recordBonusPoints(cfg, breaks, nil)
}
// recordBonusPoints 是聚合实现;modeByEntry 给出每个报名项命中通道所属模板的口径,
// 缺失时退回全局 cfg.Mode,保证直接调用 RecordBonusPoints 的旧口径不变。
func recordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak, modeByEntry map[int64]RecordBonusMode) []RecordBonus {
if !cfg.Enabled || len(breaks) == 0 {
return nil
}
@@ -65,7 +71,11 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
TeamID: g.first.TeamID,
Levels: g.levels,
}
if cfg.Mode == RecordBonusSum {
mode := cfg.Mode
if m, ok := modeByEntry[entryID]; ok {
mode = m
}
if mode == RecordBonusSum {
for _, p := range g.points {
bonus.Points += p
}
@@ -89,6 +99,10 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil
}
// 只对「可破纪录」的项目加分;登记接口另有一层校验,这里是引擎侧的最后防线。
if !in.Event.RecordEligible {
return nil, nil
}
breaks := make([]RecordBreak, 0, len(in.RecordBreaks))
for _, b := range in.RecordBreaks {
@@ -97,7 +111,16 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
breaks = append(breaks, b)
}
bonuses := RecordBonusPoints(*in.RecordBonus, breaks)
modes := make(map[int64]RecordBonusMode, len(in.Entries))
for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
continue
}
if m, ok := recordBonusModeFor(in, e); ok {
modes[e.ID] = m
}
}
bonuses := recordBonusPoints(*in.RecordBonus, breaks, modes)
if len(bonuses) == 0 {
return nil, nil
}
@@ -138,3 +161,31 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
return out, nil
}
// recordBonusModeFor 取报名项「命中通道」所属模板的破纪录口径。
// 命中通道按 channelBefore 的优先级挑选(athlete → team → 通道 id 升序),
// 与奖牌去重同一口径;没有可命中通道或模板缺失时返回 false,由调用方退回全局口径。
func recordBonusModeFor(in EventScoreInput, e Entry) (RecordBonusMode, bool) {
var (
picked Scope
pickedID int64
profileID int64
found bool
)
for _, ch := range in.Channels {
if ch.Scope == ScopeAthlete && e.AthleteID == 0 {
continue
}
if !found || channelBefore(ch.Scope, ch.ID, picked, pickedID) {
picked, pickedID, profileID, found = ch.Scope, ch.ID, ch.ProfileID, true
}
}
if !found {
return "", false
}
p, ok := in.Profiles[profileID]
if !ok {
return "", false
}
return p.bonusMode(), true
}
+117
View File
@@ -194,6 +194,123 @@ func TestRecordBonusLedger(t *testing.T) {
})
}
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
t.Helper()
var got float64
for _, e := range mustScore(t, in).Ledger {
if e.SourceType == SourceRecordBonus {
got += e.Points
}
}
return got
}
// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
entries, results := fixture(lineup(1))
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
in.Event.RecordEligible = false
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
}
in.Event.RecordEligible = true
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
}
}
// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
// 而不是全局 cfg.Mode(P1-3)。
func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
entries, results := fixture(lineup(1))
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusSum
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
// 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
Channels: []ScoreChannel{
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
}
}
// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
entries, results := fixture(lineup(1))
profile := individualTop6()
profile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusSum)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: profile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
}
}
// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusMax
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusSum
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
}
}
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
+68 -5
View File
@@ -204,14 +204,77 @@ func buildTeamRow(t Team, p TeamPoints, m medalTally) StandingsRow {
}
}
// channelBefore 是计分通道的优先级比较(口径固定,必须确定性):
// 个人通道优先于团体通道;同为个人/团体时取通道 id 小者。
// 奖牌去重与破纪录口径共用这套优先级,保证同一份输入每次选出同一个通道。
func channelBefore(scopeA Scope, idA int64, scopeB Scope, idB int64) bool {
if scopeA != scopeB {
return scopeA == ScopeAthlete
}
return idA < idB
}
// medalKey 是奖牌去重键:同一报名项在同一小项、同一组别只记一枚奖牌。
// 聚合通道不对应单条报名项(EntryID=0),改用名次主体(个人/单位)去重。
type medalKey struct {
eventID int64
categoryID int64
entryID int64
scope Scope
subject int64
unique int // 退化数据(EntryID 与主体都为空)没有可识别身份,按输入顺序各占一个键
}
// medalKeyOf 计算一条名次明细的去重键;index 用于给无法识别主体的退化数据兜底。
func medalKeyOf(rr RankedResult, index int) medalKey {
k := medalKey{eventID: rr.EventID, categoryID: rr.CategoryID, entryID: rr.EntryID}
if k.entryID != 0 {
return k
}
k.scope = rr.Scope
if rr.Scope == ScopeAthlete {
k.subject = rr.AthleteID
} else {
k.subject = rr.TeamID
}
if k.subject == 0 {
k.unique = index + 1
}
return k
}
// pickMedalChannels 把按通道展开的名次明细去重成「每个报名项一条」:
// 只保留优先级最高的通道名次,否则双通道项目一条成绩会拿两枚奖牌。
func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
best := make(map[medalKey]RankedResult, len(ranked))
order := make([]medalKey, 0, len(ranked))
for i, rr := range ranked {
if excludeTeam && rr.Scope == ScopeTeam {
continue
}
k := medalKeyOf(rr, i)
cur, ok := best[k]
if !ok {
best[k] = rr
order = append(order, k)
continue
}
if channelBefore(rr.Scope, rr.ChannelID, cur.Scope, cur.ChannelID) {
best[k] = rr
}
}
out := make([]RankedResult, 0, len(order))
for _, k := range order {
out = append(out, best[k])
}
return out
}
// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚:
// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
if rr.Scope == ScopeTeam && in.ExcludeTeamEventMedals {
continue
}
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
@@ -235,7 +298,7 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
for _, rr := range pickMedalChannels(in.Ranked, false) {
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
continue
}
+75
View File
@@ -267,6 +267,81 @@ func TestTeamStandingsTieBreakConfig(t *testing.T) {
}
}
// TestMedalDedupAcrossChannels 验证多通道项目一条成绩只记一枚奖牌(P0-2):
// buildRanked 会按通道把一条成绩展开成多条名次明细,去重后只保留优先级最高的通道名次。
func TestMedalDedupAcrossChannels(t *testing.T) {
ranked := func(scope Scope, channelID int64, rank int) RankedResult {
return RankedResult{
EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
TeamID: 1, AthleteID: 101, Rank: rank,
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
}
}
teams := testTeams(1)
athletes := []Athlete{{ID: 101, Name: "甲", TeamID: 1}}
t.Run("双通道一条成绩只记一枚", func(t *testing.T) {
in := StandingsInput{Teams: teams, Athletes: athletes, Ranked: []RankedResult{
ranked(ScopeTeam, 2, 1), ranked(ScopeAthlete, 1, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Medals != 1 {
t.Fatalf("单位奖牌 = %+v,期望只记 1 金", rows[0])
}
arows := BuildAthleteStandings(in)
if len(arows) != 1 || arows[0].Gold != 1 {
t.Fatalf("个人奖牌 = %+v,期望只记 1 金", arows)
}
})
t.Run("个人通道优先于团体通道", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeTeam, 1, 1), // 团体通道名次更靠前,但优先级更低
ranked(ScopeAthlete, 9, 2), // 个人通道优先,取银牌
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 0 || rows[0].Silver != 1 {
t.Fatalf("单位奖牌 = %+v,期望取个人通道的 1 银", rows[0])
}
})
t.Run("同 scope 取通道 id 小者", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 5, 3),
ranked(ScopeAthlete, 2, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Bronze != 0 {
t.Fatalf("单位奖牌 = %+v,期望取通道 id=2 的 1 金", rows[0])
}
})
t.Run("不同报名项各记一枚", func(t *testing.T) {
other := ranked(ScopeAthlete, 1, 1)
other.EntryID = 8
other.AthleteID = 102
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 1, 1), other,
}})
if rows[0].Gold != 2 {
t.Fatalf("单位金牌 = %d,期望 2(两条成绩各一枚)", rows[0].Gold)
}
})
t.Run("聚合通道按主体去重", func(t *testing.T) {
a := RankedResult{
EventID: 1, CategoryID: 1, ChannelID: 1, Scope: ScopeAthlete, AthleteID: 101,
TeamID: 1, Rank: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}},
}
b := a
b.ChannelID = 2
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{a, b}})
if rows[0].Gold != 1 {
t.Fatalf("单位金牌 = %d,期望 1(同一主体只记一枚)", rows[0].Gold)
}
})
}
// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。
func TestAthleteStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)}
+48
View File
@@ -0,0 +1,48 @@
package dto
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// Event 是 GET /api/events 与 snapshot.events 的线上结构(列表口径,不含组别与通道)。
//
// 与 REST 的 eventDTO 逐字段对齐:scheduledAt/venue/remark 可空、sortOrder 必填,
// 不能沿用 domain.Event 的 omitempty,否则前端会同时遇到「字段缺失」和「null」。
type Event struct {
ID int64 `json:"id"`
SportID int64 `json:"sportId"`
Code string `json:"code"`
Name string `json:"name"`
EventType string `json:"eventType"`
ResultMode string `json:"resultMode"`
ResultType string `json:"resultType"`
Compare string `json:"compare"`
RecordEligible bool `json:"recordEligible"`
Scored bool `json:"scored"`
Multiplier float64 `json:"multiplier"`
Status string `json:"status"`
ScheduledAt *string `json:"scheduledAt"`
Venue *string `json:"venue"`
SortOrder int `json:"sortOrder"`
Remark *string `json:"remark"`
}
// EventFromDomain 把领域小项转换成线上结构。
func EventFromDomain(e domain.Event) Event {
return Event{
ID: e.ID,
SportID: e.SportID,
Code: e.Code,
Name: e.Name,
EventType: string(e.EventType),
ResultMode: string(e.ResultMode),
ResultType: string(e.ResultType),
Compare: string(e.Compare),
RecordEligible: e.RecordEligible,
Scored: e.Scored,
Multiplier: e.Multiplier,
Status: string(e.Status),
ScheduledAt: nilIfEmpty(e.ScheduledAt),
Venue: nilIfEmpty(e.Venue),
SortOrder: e.SortOrder,
Remark: nilIfEmpty(e.Remark),
}
}
+17
View File
@@ -46,6 +46,23 @@ type EventSettingsStandings struct {
ShowIndividualMedals bool `json:"showIndividualMedals"`
}
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
//
// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
// 系数属于系统配置,必须置空/清零后再上线。
func (s EventSettings) WithoutAdminFields() EventSettings {
return EventSettings{
Event: s.Event,
Display: s.Display,
Standings: EventSettingsStandings{
RankingMode: s.Standings.RankingMode,
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
},
}
}
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
type RuleSwitches struct {
+94
View File
@@ -0,0 +1,94 @@
package dto
import (
"encoding/json"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 非 admin 会话只能看到大屏渲染所需字段:admin-only 字段必须清零,
// 且序列化后仍是显式的 null / 0,不能变成字段缺失。
func TestEventSettingsWithoutAdminFields(t *testing.T) {
profileID := int64(3)
full := EventSettings{
Event: EventSettingsEvent{Name: "运动会"},
Display: EventSettingsDisplay{Title: "大屏", RotateSecond: 15},
Standings: EventSettingsStandings{IndividualToTeamWeight: 0.5, RankingMode: "points", ShowIndividualBoard: true, ShowIndividualMedals: false},
DefaultProfileID: &profileID,
RequireApproval: true,
}
got := full.WithoutAdminFields()
if got.DefaultProfileID != nil {
t.Fatalf("defaultProfileId 应置空,得到 %v", got.DefaultProfileID)
}
if got.RequireApproval {
t.Fatalf("requireApproval 应清零")
}
if got.Standings.IndividualToTeamWeight != 0 {
t.Fatalf("individualToTeamWeight 应清零,得到 %v", got.Standings.IndividualToTeamWeight)
}
if got.Event.Name != "运动会" || got.Display.Title != "大屏" || got.Display.RotateSecond != 15 {
t.Fatalf("event/display 不应被裁剪: %+v", got)
}
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || got.Standings.ShowIndividualMedals {
t.Fatalf("榜单开关不应被裁剪: %+v", got.Standings)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("序列化: %v", err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析: %v", err)
}
if string(top["defaultProfileId"]) != "null" {
t.Fatalf("defaultProfileId 应显式序列化为 null: %s", raw)
}
if string(top["requireApproval"]) != "false" {
t.Fatalf("requireApproval 应显式序列化为 false: %s", raw)
}
var standings map[string]json.RawMessage
if err := json.Unmarshal(top["standings"], &standings); err != nil {
t.Fatalf("解析 standings: %v", err)
}
if string(standings["individualToTeamWeight"]) != "0" {
t.Fatalf("individualToTeamWeight 应显式序列化为 0: %s", raw)
}
}
// 单位/小项的 DTO 必须把可空列写成显式 null,不能沿用 domain 的 omitempty。
func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
teamRaw, err := json.Marshal(TeamFromDomain(domain.Team{ID: 1, Code: "T1", Name: "第一单位"}))
if err != nil {
t.Fatalf("序列化单位: %v", err)
}
var team map[string]json.RawMessage
if err := json.Unmarshal(teamRaw, &team); err != nil {
t.Fatalf("解析单位: %v", err)
}
for _, key := range []string{"shortName", "logoPath", "color"} {
if got, ok := team[key]; !ok || string(got) != "null" {
t.Fatalf("单位字段 %s 应为显式 null: %s", key, teamRaw)
}
}
eventRaw, err := json.Marshal(EventFromDomain(domain.Event{ID: 1, Code: "E1", Name: "100米"}))
if err != nil {
t.Fatalf("序列化小项: %v", err)
}
var event map[string]json.RawMessage
if err := json.Unmarshal(eventRaw, &event); err != nil {
t.Fatalf("解析小项: %v", err)
}
for _, key := range []string{"scheduledAt", "venue", "remark"} {
if got, ok := event[key]; !ok || string(got) != "null" {
t.Fatalf("小项字段 %s 应为显式 null: %s", key, eventRaw)
}
}
if string(event["sortOrder"]) != "0" {
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
}
}
+41
View File
@@ -0,0 +1,41 @@
package dto
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// Team 是 GET /api/teams 与 snapshot.teams 的线上结构。
//
// 与 REST 的 teamDTO 逐字段对齐:可空列必须是显式 null,不能靠 domain 的
// omitempty 把空串省成字段缺失,否则前端解析同一份契约会拿到两种形状。
type Team struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
LogoPath *string `json:"logoPath"`
Color *string `json:"color"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
}
// TeamFromDomain 把领域单位转换成线上结构。
func TeamFromDomain(t domain.Team) Team {
return Team{
ID: t.ID,
Code: t.Code,
Name: t.Name,
ShortName: nilIfEmpty(t.ShortName),
LogoPath: nilIfEmpty(t.LogoPath),
Color: nilIfEmpty(t.Color),
SortOrder: t.SortOrder,
Active: t.Active,
}
}
// TeamsFromDomain 批量转换单位;空输入返回空切片,序列化为 [] 而不是 null。
func TeamsFromDomain(ts []domain.Team) []Team {
out := make([]Team, 0, len(ts))
for _, t := range ts {
out = append(out, TeamFromDomain(t))
}
return out
}
+25 -5
View File
@@ -67,7 +67,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
}
now := nowOr(opts.Now)
path := uniqueBackupPath(dir, prefix, reason, now)
path, err := uniqueBackupPath(dir, prefix, reason, now)
if err != nil {
return BackupInfo{}, err
}
if err := vacuumInto(db, path); err != nil {
return BackupInfo{}, err
}
@@ -286,23 +289,40 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) {
return removed, errors.Join(errs...)
}
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
// uniqueBackupPath 为备份选一个不重名的路径。
// 重试次数有上限:目录不可读等非 NotExist 错误不能让备份线程死循环(它持锁会阻塞全部备份)。
func uniqueBackupPath(dir, prefix, reason string, now time.Time) (string, error) {
base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405"))
p := filepath.Join(dir, base+".db")
for i := 2; ; i++ {
if _, err := os.Stat(p); os.IsNotExist(err) {
return p
for i := 2; i <= 100; i++ {
_, err := os.Stat(p)
if os.IsNotExist(err) {
return p, nil
}
if err != nil {
return "", fmt.Errorf("export: 检查备份路径 %s: %w", p, err)
}
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
}
return "", fmt.Errorf("export: 备份文件名 %s 冲突过多,放弃重试", base)
}
// knownBackupPrefixes 是系统内置的备份前缀;prefix 为空(列出全部备份)时用它识别并剥掉前缀。
var knownBackupPrefixes = []string{"event", "system", "db"}
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].db 里取回原因;
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
func reasonFromName(name, prefix string) string {
base := strings.TrimSuffix(name, filepath.Ext(name))
if prefix != "" {
base = strings.TrimPrefix(base, prefix+"-")
} else {
for _, p := range knownBackupPrefixes {
if strings.HasPrefix(base, p+"-") {
base = strings.TrimPrefix(base, p+"-")
break
}
}
}
parts := strings.Split(base, "-")
for i := len(parts) - 2; i >= 0; i-- {
+83
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -236,6 +237,88 @@ func TestServiceRunMutualExclusion(t *testing.T) {
}
}
// prefix 为空(列出全部备份)时要剥掉已识别的前缀,不能把 event-/system- 拼进原因。
func TestReasonFromNameWithoutPrefix(t *testing.T) {
cases := []struct{ name, want string }{
{"event-auto-20260909-213614.db", "auto"},
{"event-publish-20260909-213614-2.db", "publish"},
{"system-manual-20260909-213614.db", "manual"},
{"db-manual-20260909-213614.db", "manual"},
}
for _, c := range cases {
if got := reasonFromName(c.name, ""); got != c.want {
t.Errorf("reasonFromName(%q, \"\") = %q,期望 %q", c.name, got, c.want)
}
}
}
// ListBackups 不传 prefix 时也要解析出可读原因(管理端「备份」面板按原因分列)。
func TestListBackupsParsesReasonWithoutPrefix(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local)
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Now: base, SkipPrune: true}); err != nil {
t.Fatalf("赛事库备份: %v", err)
}
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "system", Reason: "manual", Now: base.Add(time.Minute), SkipPrune: true}); err != nil {
t.Fatalf("系统库备份: %v", err)
}
list, err := ListBackups(dir, "")
if err != nil {
t.Fatalf("列出全部备份: %v", err)
}
if len(list) != 2 {
t.Fatalf("备份数 = %d,期望 2", len(list))
}
want := map[string]string{"event": "auto", "system": "manual"}
for _, b := range list {
var prefix string
for p := range want {
if strings.HasPrefix(b.Name, p+"-") {
prefix = p
}
}
if prefix == "" {
t.Fatalf("无法识别备份前缀: %s", b.Name)
}
if b.Reason != want[prefix] {
t.Errorf("%s 的原因 = %q,期望 %q", b.Name, b.Reason, want[prefix])
}
}
}
// 非 NotExist 错误不能让选名函数死循环(它持锁会阻塞全部备份)。
func TestUniqueBackupPathStopsOnError(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 不受目录权限限制")
}
dir := filepath.Join(t.TempDir(), "locked")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("创建目录: %v", err)
}
if err := os.Chmod(dir, 0o000); err != nil {
t.Fatalf("改权限: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
done := make(chan error, 1)
go func() {
_, err := uniqueBackupPath(dir, "event", "auto", time.Now())
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("目录不可读应返回错误")
}
case <-time.After(5 * time.Second):
t.Fatal("uniqueBackupPath 在非 NotExist 错误上死循环")
}
}
// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。
func TestReasonFromNameWithSequence(t *testing.T) {
cases := []struct{ name, prefix, want string }{
+120 -86
View File
@@ -85,7 +85,13 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
return StandingsWorkbook{}, err
}
teams, err := loadTeams(db)
// 排名输入只含启用单位,与 REST 一致。
teams, err := loadTeams(db, true)
if err != nil {
return StandingsWorkbook{}, err
}
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
allTeams, err := loadTeams(db, false)
if err != nil {
return StandingsWorkbook{}, err
}
@@ -107,24 +113,29 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitSettingList(settings["standings.tie_break"]),
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
}
return StandingsWorkbook{
Meta: meta,
Team: domain.BuildTeamStandings(in),
Medal: domain.BuildMedalStandings(in),
Athlete: domain.BuildAthleteStandings(in),
}, nil
team := domain.BuildTeamStandings(in)
medal := domain.BuildMedalStandings(in)
athlete := domain.BuildAthleteStandings(in)
fillTeamDisplay(team, medal, athlete, allTeams)
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
func loadTeams(db *sql.DB) ([]domain.Team, error) {
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
rows, err := db.Query(`
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
query := `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE active = 1
ORDER BY sort_order, code`)
FROM teams`
if activeOnly {
query += ` WHERE active = 1`
}
query += ` ORDER BY sort_order, code`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
@@ -146,6 +157,66 @@ func loadTeams(db *sql.DB) ([]domain.Team, error) {
return out, nil
}
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
byID := make(map[int64]domain.Team, len(teams))
for _, t := range teams {
byID[t.ID] = t
}
for i := range team {
if t, ok := byID[team[i].TeamID]; ok {
if team[i].Code == "" {
team[i].Code = t.Code
}
if team[i].Name == "" {
team[i].Name = t.Name
}
if team[i].ShortName == "" {
team[i].ShortName = t.ShortName
}
}
}
for i := range medal {
if t, ok := byID[medal[i].TeamID]; ok {
if medal[i].Code == "" {
medal[i].Code = t.Code
}
if medal[i].Name == "" {
medal[i].Name = t.Name
}
if medal[i].ShortName == "" {
medal[i].ShortName = t.ShortName
}
}
}
for i := range athlete {
if t, ok := byID[athlete[i].TeamID]; ok {
if athlete[i].TeamCode == "" {
athlete[i].TeamCode = t.Code
}
if athlete[i].TeamName == "" {
athlete[i].TeamName = t.Name
}
}
}
}
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
func splitSettingList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
rows, err := db.Query(`
@@ -201,24 +272,22 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
}
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
// 重算分值,也就能保证与正式计分结果完全一致。
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
scopes, err := loadChannelScopes(db)
if err != nil {
return nil, err
}
rows, err := db.Query(`
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN events e ON e.id = r.event_id
LEFT JOIN athletes a ON a.id = en.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
ORDER BY r.event_id, r.rank, r.id`)
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = en.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND ev.scored = 1
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
ORDER BY r.id, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取名次: %w", err)
}
@@ -227,32 +296,23 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
var out []domain.RankedResult
for rows.Next() {
var (
rr domain.RankedResult
resultValue sql.NullFloat64
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, fmt.Errorf("export: 扫描名次: %w", err)
}
if resultValue.Valid {
v := resultValue.Float64
rr.Rank = rank
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
if rr.TeamID == 0 {
continue
}
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
// 因此按 scope 展开成多条明细。
for _, sc := range scopes[rr.EventID] {
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
continue
}
item := rr
item.Scope = sc
item.Basis = domain.BasisResult
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, item)
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
@@ -260,39 +320,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
return out, nil
}
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
if err != nil {
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.Scope)
for rows.Next() {
var eventID int64
var scope domain.Scope
if err := rows.Scan(&eventID, &scope); err != nil {
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
}
if !containsScope(out[eventID], scope) {
out[eventID] = append(out[eventID], scope)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
}
return out, nil
}
func containsScope(list []domain.Scope, scope domain.Scope) bool {
for _, s := range list {
if s == scope {
return true
}
}
return false
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
@@ -471,7 +498,7 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if db == nil {
return TeamDetailWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return TeamDetailWorkbook{}, err
}
@@ -483,7 +510,12 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if err != nil {
return TeamDetailWorkbook{}, err
}
return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
return TeamDetailWorkbook{
Meta: meta,
Team: team,
Rows: rows,
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
}, nil
}
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
@@ -508,7 +540,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
l.source_type, l.points, COALESCE(l.void_reason, '')
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
LEFT JOIN sports s ON s.id = e.sport_id
@@ -527,11 +559,13 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
for rows.Next() {
var r TeamDetailRow
var source string
var athleteID int64
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
r.Source = sourceLabel(source)
r.individual = athleteID != 0
out = append(out, r)
}
if err := rows.Err(); err != nil {
+193
View File
@@ -0,0 +1,193 @@
package export
import (
"context"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// 导出与 WS 快照必须同源:同一赛事库上,总积分榜的总分、奖牌与名次不能对不上。
func TestExportStandingsMatchesSnapshot(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
hub := realtime.NewHub(db, testLogger())
defer func() { _ = hub.Close() }()
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("读取 WS 快照: %v", err)
}
if len(wb.Team) != len(snap.Standings) {
t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings))
}
for _, s := range snap.Standings {
idx := -1
for i := range wb.Team {
if wb.Team[i].TeamID == s.TeamID {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("快照中的单位 %d 不在导出结果里", s.TeamID)
}
row := wb.Team[idx]
if row.Points != s.TotalPoints || row.Gold != s.Gold || row.Silver != s.Silver ||
row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank {
t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s)
}
}
}
// 只排名不计分(scored=0)的项目不进奖牌榜,即使它配了计分通道。
func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
stmts := []string{
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, status)
VALUES (1, 'E2', '飞行人员战斗体能', 'individual', 'auto', 'score', 'desc', 0, 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '201')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (2, 3, 1, '100', 100, 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
ranked, err := loadRanked(db)
if err != nil {
t.Fatalf("读取名次: %v", err)
}
for _, rr := range ranked {
if rr.EventID == 2 {
t.Fatalf("scored=0 的项目不应产出奖牌名次: %+v", rr)
}
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID == 1 && row.Gold != 1 {
t.Errorf("T1 金牌 = %d,期望 1(只排名项目不得计牌)", row.Gold)
}
}
}
// 排序口径取自 settings.standings.tie_break,不再是硬编码默认序。
func TestLoadStandingsHonoursTieBreak(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// T1 金牌 1 但总分 5,T2 无牌但总分 10。
for _, s := range []string{
`UPDATE score_ledger SET points = 5 WHERE team_id = 1`,
`UPDATE score_ledger SET points = 10 WHERE team_id = 2`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("默认口径导出: %v", err)
}
if wb.Team[0].Code != "T2" {
t.Fatalf("默认按总分排序,第一名 = %q,期望 T2", wb.Team[0].Code)
}
if _, err := db.Exec(`INSERT INTO settings(key, value) VALUES ('standings.tie_break', 'gold,points')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
t.Fatalf("写入 tie_break: %v", err)
}
wb, err = LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("金牌优先导出: %v", err)
}
if wb.Team[0].Code != "T1" {
t.Errorf("tie_break=gold,points 时第一名 = %q,期望 T1", wb.Team[0].Code)
}
}
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
func TestLoadStandingsShowsInactiveTeam(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
for _, s := range []string{
`INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T3', '第三分公司', '三公司', 3, 0)`,
`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (3, 'adjustment', 99, 7)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
idx := -1
for i := range wb.Team {
if wb.Team[i].TeamID == 3 {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("停用单位未出现在榜单:%+v", wb.Team)
}
row := wb.Team[idx]
if row.Code != "T3" || row.Name != "第三分公司" || row.ShortName != "三公司" {
t.Errorf("停用单位展示字段不完整: %+v", row)
}
if row.Points != 7 {
t.Errorf("停用单位总分 = %v,期望 7", row.Points)
}
}
// 单位积分明细的「合计」与总积分榜同口径:个人分要乘 individual_to_team_weight。
func TestTeamDetailTotalAppliesWeight(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
t.Fatalf("修改折算系数: %v", err)
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
if wb.IndividualToTeamWeight != 2 {
t.Fatalf("折算系数 = %v,期望 2", wb.IndividualToTeamWeight)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if len(rows) < 2 {
t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
}
// 与 REST 一致:个人分逐行折算,合计等于各行之和。
if got, ok := rows[0][8].(float64); !ok || got != 20 {
t.Errorf("明细行积分 = %v,期望 20(10 × 系数 2)", rows[0][8])
}
total, ok := rows[len(rows)-1][8].(float64)
if !ok {
t.Fatalf("合计列类型异常: %T", rows[len(rows)-1][8])
}
if total != 20 {
t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
}
}
+51 -18
View File
@@ -26,10 +26,12 @@ const (
projectFormatVersion = 1
// eventDBName 是包内赛事库的固定文件名。
eventDBName = "event.db"
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
maxExtractBytes = 4 << 30
)
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
// 定义为变量便于测试用较小的上限验证限流逻辑。
var maxExtractBytes int64 = 4 << 30
// ProjectFile 描述包内一个文件的校验信息。
type ProjectFile struct {
Path string `yaml:"path"`
@@ -253,18 +255,8 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
}
target := filepath.Join(projRoot, name)
if _, err := os.Stat(target); err == nil {
if !opts.Overwrite {
return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale := target + ".replaced-" + time.Now().Format("20060102-150405")
if err := os.Rename(target, stale); err != nil {
return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
defer func() { _ = os.RemoveAll(stale) }()
}
if err := os.Rename(tmp, target); err != nil {
return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
return ImportResult{}, err
}
return ImportResult{
Name: name,
@@ -274,6 +266,38 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
}, nil
}
// swapProjectDir 用 src 目录替换 target 目录。
// 覆盖时先把旧目录改名暂存,src 落盘成功后才删除暂存目录;src 落盘失败则把旧目录改回来,
// 绝不出现「旧项目已删、新项目没成」的空档。rename 注入仅为测试模拟第二次改名失败。
func swapProjectDir(src, target string, overwrite bool, rename func(old, new string) error) error {
name := filepath.Base(target)
stale := ""
if _, err := os.Stat(target); err == nil {
if !overwrite {
return fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale = target + ".replaced-" + time.Now().Format("20060102-150405")
if err := rename(target, stale); err != nil {
return fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("export: 检查项目目录 %s: %w", target, err)
}
if err := rename(src, target); err != nil {
if stale != "" {
if rbErr := rename(stale, target); rbErr != nil {
return fmt.Errorf("export: 落盘赛事项目 %s: %w(旧项目回滚失败,暂存在 %s: %v)", name, err, stale, rbErr)
}
}
return fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
}
if stale != "" {
// 新项目已就位,清理旧目录失败不影响本次导入,留待下次人工处理。
_ = os.RemoveAll(stale)
}
return nil
}
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
func SafeProjectName(name string) (string, error) {
name = strings.TrimSpace(name)
@@ -289,6 +313,10 @@ func SafeProjectName(name string) (string, error) {
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
// Windows 会静默剥掉目录名末尾的点与空格,"赛事." 与 "赛事" 会撞成同一个目录。
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return "", fmt.Errorf("%w: %q 不能以点或空格结尾", ErrUnsafeName, name)
}
for _, r := range name {
if r < 0x20 {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
@@ -511,7 +539,7 @@ func extractZip(src, dst string) error {
}
defer func() { _ = r.Close() }()
var total uint64
var total int64
for _, f := range r.File {
target, err := secureJoin(dst, f.Name)
if err != nil {
@@ -523,8 +551,8 @@ func extractZip(src, dst string) error {
}
continue
}
total += f.UncompressedSize64
if total > maxExtractBytes {
// 声明值先卡一道,避免一上来就落盘;注意先判上界再转 int64,防止溢出。
if f.UncompressedSize64 > uint64(maxExtractBytes) || total+int64(f.UncompressedSize64) > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
@@ -539,12 +567,17 @@ func extractZip(src, dst string) error {
_ = rc.Close()
return fmt.Errorf("export: 写入 %s: %w", target, err)
}
_, copyErr := io.Copy(out, rc)
// header 里的声明值可以伪造,按实际写出的字节数再卡一次上限。
n, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractBytes-total+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
total += n
if total > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if closeOutErr != nil {
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
}
+110 -1
View File
@@ -147,8 +147,98 @@ func TestImportRejectsBadPackages(t *testing.T) {
})
}
// 覆盖导入时第二次改名失败,旧项目必须回滚回来,不能出现「旧项目已删、新项目没成」。
func TestSwapProjectDirRollback(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "赛事")
src := filepath.Join(root, ".import-1")
writeFile(t, filepath.Join(target, "old.txt"), "旧")
writeFile(t, filepath.Join(src, "new.txt"), "新")
calls := 0
rename := func(old, new string) error {
calls++
if calls == 2 {
return errors.New("模拟第二次改名失败")
}
return os.Rename(old, new)
}
if err := swapProjectDir(src, target, true, rename); err == nil {
t.Fatal("第二次改名失败应返回错误")
}
if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
t.Errorf("回滚后旧项目内容 = %q,期望 旧", got)
}
if got := readFile(t, filepath.Join(src, "new.txt")); got != "新" {
t.Errorf("新项目内容 = %q,期望 新", got)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("读取项目根目录: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".replaced-") {
t.Errorf("回滚后残留暂存目录 %s", e.Name())
}
}
}
// 不允许覆盖时必须原地报错,不能动旧项目。
func TestSwapProjectDirNoOverwrite(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "赛事")
src := filepath.Join(root, ".import-1")
writeFile(t, filepath.Join(target, "old.txt"), "旧")
writeFile(t, filepath.Join(src, "new.txt"), "新")
if err := swapProjectDir(src, target, false, os.Rename); !errors.Is(err, ErrProjectExists) {
t.Fatalf("期望 ErrProjectExists,实际 %v", err)
}
if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
t.Errorf("拒绝覆盖后旧项目内容 = %q,期望 旧", got)
}
}
// 覆盖成功后旧目录必须清理干净,不留 .replaced- 残留。
func TestSwapProjectDirOverwriteCleansStale(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "赛事")
src := filepath.Join(root, ".import-1")
writeFile(t, filepath.Join(target, "old.txt"), "旧")
writeFile(t, filepath.Join(src, "new.txt"), "新")
if err := swapProjectDir(src, target, true, os.Rename); err != nil {
t.Fatalf("覆盖导入: %v", err)
}
if got := readFile(t, filepath.Join(target, "new.txt")); got != "新" {
t.Errorf("覆盖后新项目内容 = %q,期望 新", got)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("读取项目根目录: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".replaced-") {
t.Errorf("覆盖成功后残留暂存目录 %s", e.Name())
}
}
}
// 解压总体积上限:超过上限的包必须拒绝,不能把磁盘写满。
func TestExtractZipRejectsOversize(t *testing.T) {
old := maxExtractBytes
maxExtractBytes = 64
t.Cleanup(func() { maxExtractBytes = old })
p := filepath.Join(t.TempDir(), "big.zip")
writeZip(t, p, map[string]string{"event.db": strings.Repeat("x", 200)})
if err := extractZip(p, t.TempDir()); !errors.Is(err, ErrInvalidPackage) {
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
}
}
func TestSafeProjectName(t *testing.T) {
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"}
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y", "赛事.", "a.."}
for _, name := range bad {
if _, err := SafeProjectName(name); err == nil {
t.Errorf("SafeProjectName(%q) 应报错", name)
@@ -161,6 +251,25 @@ func TestSafeProjectName(t *testing.T) {
}
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("创建目录 %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("写入 %s: %v", path, err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取 %s: %v", path, err)
}
return string(raw)
}
func assertTeams(t *testing.T, path string, want int) {
t.Helper()
db := openSQLite(t, path)
+17 -4
View File
@@ -21,6 +21,8 @@ type TeamDetailRow struct {
Source string
Points float64
Remark string
// individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
individual bool
}
// TeamDetailWorkbook 是「单位积分明细」的内容。
@@ -28,6 +30,8 @@ type TeamDetailWorkbook struct {
Meta Meta
Team domain.Team
Rows []TeamDetailRow
// IndividualToTeamWeight 与总积分榜同源,用于把个人分折算进「合计」。
IndividualToTeamWeight float64
}
var teamDetailColumns = []column{
@@ -73,22 +77,31 @@ func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) {
return err
}
title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细")
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows))
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows, wb.IndividualToTeamWeight))
})
}
func teamDetailRows(rows []TeamDetailRow) [][]any {
// teamDetailRows 把明细行排成表格。与 REST 的单位积分明细同口径:
// 记到个人的流水按 individual_to_team_weight 折算后展示,合计即各行之和。
func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any {
if weight <= 0 {
weight = 1
}
out := make([][]any, 0, len(rows)+1)
var total float64
for i, r := range rows {
total += r.Points
points := r.Points
if r.individual {
points *= weight
}
total += points
rank := ""
if r.Rank > 0 {
rank = strconv.Itoa(r.Rank)
}
out = append(out, []any{
i + 1, r.SportName, r.EventName, r.Category, r.AthleteName,
rank, r.ResultText, r.Source, r.Points, r.Remark,
rank, r.ResultText, r.Source, points, r.Remark,
})
}
if len(rows) > 0 {
+46 -1
View File
@@ -2,11 +2,14 @@ package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -16,6 +19,9 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
@@ -115,7 +121,10 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
defer cleanup()
opts.RulesPath = rulesPath
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
dst, err := projectExportPath(dir, time.Now())
if err != nil {
return nil, internal(err)
}
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
return nil, internal(err)
}
@@ -127,6 +136,16 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, errHandled
}
// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
// 后写的会截断先写的文件,导致先返回的响应内容损坏。
func projectExportPath(dir string, now time.Time) (string, error) {
suffix := make([]byte, 4)
if _, err := rand.Read(suffix); err != nil {
return "", err
}
return filepath.Join(dir, "project-"+now.Format("20060102-150405")+"-"+hex.EncodeToString(suffix)+".zip"), nil
}
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
@@ -155,6 +174,7 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
}
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 {
return nil, badRequest("解析上传失败", nil)
}
@@ -183,6 +203,22 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, internal(err)
}
// 覆盖正在运行的赛事项目会让运行中的 event.db 被 unlink(Linux)或 rename 失败(Windows),
// 现场继续写进已删除的 inode,重启即丢数据,必须在落盘前直接拒绝。
if cur := currentProjectName(s.projectDir); cur != "" {
meta, err := export.InspectProjectZip(tmp)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
name := meta.Name
if v := strings.TrimSpace(r.FormValue("rename")); v != "" {
name = v
}
if safe, err := export.SafeProjectName(name); err == nil && strings.EqualFold(safe, cur) {
return nil, badRequest("不能覆盖正在运行的赛事项目 "+cur, nil)
}
}
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
@@ -194,6 +230,15 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return res, nil
}
// currentProjectName 从项目目录反推当前运行的赛事项目名;未配置目录时返回空串。
func currentProjectName(projectDir string) string {
cleaned := filepath.Clean(strings.TrimSpace(projectDir))
if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) {
return ""
}
return filepath.Base(cleaned)
}
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
@@ -0,0 +1,22 @@
package httpapi
import (
"testing"
"time"
)
// 同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
first, err := projectExportPath("/tmp/exports", now)
if err != nil {
t.Fatalf("生成导出路径: %v", err)
}
second, err := projectExportPath("/tmp/exports", now)
if err != nil {
t.Fatalf("生成导出路径: %v", err)
}
if first == second {
t.Fatalf("同一秒内两次导出不应撞名,均为 %q", first)
}
}
@@ -0,0 +1,148 @@
package httpapi_test
import (
"archive/zip"
"bytes"
"io"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// exportProjectZip 通过导出接口取一份合法赛事包,供导入测试使用。
func (e *testEnv) exportProjectZip(t *testing.T, client *http.Client) []byte {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
return body
}
// postProjectImport 以 multipart 上传赛事包,附带 overwrite / rename 表单字段。
func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []byte, fields map[string]string) (int, []byte) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatalf("写表单字段: %v", err)
}
}
fw, err := w.CreateFormFile("file", "project.zip")
if err != nil {
t.Fatalf("创建上传字段: %v", err)
}
if _, err := fw.Write(pack); err != nil {
t.Fatalf("写赛事包: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/project/import", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。
func TestImportProjectZipRejectsRunningProject(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "未命名赛事")
})
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
pack := env.exportProjectZip(t, client)
status, raw := env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1"})
if status != http.StatusBadRequest {
t.Fatalf("覆盖运行中项目应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "正在运行") {
t.Fatalf("错误信息应说明不能覆盖运行中项目,得到 %s", raw)
}
// 用 rename 指定成当前项目名同样要拒绝。
status, raw = env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1", "rename": "未命名赛事"})
if status != http.StatusBadRequest {
t.Fatalf("rename 成运行中项目应 400,得到 %d %s", status, raw)
}
// 换成别的项目名仍应允许导入。
status, raw = env.postProjectImport(t, client, pack, map[string]string{"rename": "备份赛事"})
if status != http.StatusOK {
t.Fatalf("换名导入应 200,得到 %d %s", status, raw)
}
}
// 并发导出不能撞同名文件,否则先返回的响应会被后写的截断(P2-6)。
func TestExportProjectZipConcurrentUnique(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
const n = 6
var wg sync.WaitGroup
errs := make(chan string, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
errs <- "请求失败: " + err.Error()
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
errs <- "导出失败 status=" + resp.Status
return
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
errs <- "响应不是完整 zip: " + err.Error()
return
}
found := false
for _, f := range zr.File {
if f.Name == "meta.yaml" {
found = true
}
}
if !found {
errs <- "zip 缺少 meta.yaml"
}
}()
}
wg.Wait()
close(errs)
for msg := range errs {
t.Errorf("并发导出: %s", msg)
}
}
+12 -1
View File
@@ -43,7 +43,11 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
return nil, internal(err)
}
s.auth.SetCSRFCookie(w, csrf)
s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username})
// 登录时请求 ctx 里还是 guest,Auditor 取不到用户,需在审计条目里显式带上 actorID。
middleware.Record(r.Context(), middleware.AuditEntry{
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
After: map[string]any{"username": u.Username},
})
return userDTO(u), nil
}
@@ -82,6 +86,9 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if len(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
if len(in.NewPassword) > auth.MaxPasswordBytes {
return nil, badRequest(fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
if in.NewPassword == in.CurrentPassword {
return nil, badRequest("新密码不能与原密码相同", nil)
}
@@ -525,8 +532,12 @@ type entryImportResult struct {
Errors []string `json:"errors"`
}
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
const maxEntryImportBytes = 32 << 20
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
@@ -0,0 +1,87 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
func TestLoginAuditRecordsUserID(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var auditUser sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
t.Fatalf("查询登录审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
}
// 按 userId 过滤必须能查到这条登录记录。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
_, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
page := decodeData[map[string]any](t, env2)
items, _ := page["items"].([]any)
found := false
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if item["action"] == "auth.login" && item["userId"] == float64(userID) {
found = true
}
}
if !found {
t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
}
}
// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
func TestLoginRejectsCrossOrigin(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
body := map[string]any{"username": "recorder", "password": "recorder-pass"}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": "http://evil.example"})
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
// 同源(Origin 与 Host 一致)仍可登录。
status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": env.server.URL})
if status != http.StatusOK || !env2.OK {
t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
}
}
// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
func TestChangePasswordRejectsTooLong(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": tooLong,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
}
}
+13 -3
View File
@@ -85,6 +85,11 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
if err != nil {
return nil, err
}
// 展示字段以全量单位表为准(含停用单位),否则停用单位的运动员会显示空白单位名。
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
genders := make(map[int64]*string, len(in.Athletes))
for _, a := range in.Athletes {
if a.Gender != "" {
@@ -95,7 +100,7 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
rows := domain.BuildAthleteStandings(in)
out := make([]athleteStandingDTO, 0, len(rows))
for _, row := range rows {
out = append(out, athleteStandingDTO{
item := athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
@@ -103,12 +108,17 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
TeamID: row.TeamID,
TeamCode: row.TeamCode,
TeamName: row.TeamName,
ShortName: nilIfEmpty(row.TeamName),
TotalPoints: row.Points,
Gold: row.Gold,
Silver: row.Silver,
Bronze: row.Bronze,
})
}
if t, ok := teams[row.TeamID]; ok {
item.TeamCode = t.Code
item.TeamName = t.Name
item.ShortName = t.ShortName
}
out = append(out, item)
}
return out, nil
}
@@ -0,0 +1,48 @@
package httpapi_test
import (
"net/http"
"testing"
)
// 个人榜的单位展示字段必须取全量单位表:停用但有积分的单位也要显示名称与简称。
func TestAthleteStandingsUsesFullTeamTable(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
env.exec(t, `UPDATE teams SET active = 0 WHERE id = ?`, ids.teamB)
_, env2 := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
rows := decodeData[[]map[string]any](t, env2)
seen := map[int64]map[string]any{}
for _, row := range rows {
seen[int64(row["athleteId"].(float64))] = row
}
active := seen[ids.athleteA]
if active == nil || active["teamName"] != "第一分公司" || active["shortName"] != "一司" {
t.Fatalf("在职单位的个人行应带单位名与简称: %+v", active)
}
stopped := seen[ids.athB]
if stopped == nil {
t.Fatalf("停用单位的运动员仍应在个人榜上: %+v", rows)
}
if stopped["teamName"] != "第二分公司" || stopped["shortName"] != "二司" {
t.Fatalf("停用单位的个人行应补齐单位名与简称,得到 %+v", stopped)
}
}
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
@@ -140,6 +141,9 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt
if len(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
if len(in.Password) > auth.MaxPasswordBytes {
return userListItemDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
displayName := strings.TrimSpace(in.DisplayName)
if displayName == "" {
displayName = username
@@ -226,6 +230,8 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa
generated = true
} else if len(password) < 8 {
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
} else if len(password) > auth.MaxPasswordBytes {
return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
// SetPassword 会作废该用户全部会话,被重置的人必须重新登录。
if err := s.auth.SetPassword(ctx, u.Username, password, true); err != nil {
@@ -0,0 +1,44 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// 建号与重置密码都必须拒绝超过 bcrypt 上限的密码,避免静默截断(P2-6)。
func TestUserPasswordLengthLimits(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u1", "password": tooLong, "role": "recorder",
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长密码建号应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "u1", "password": "u1-pass-123", "role": "recorder",
}, true)
if status != http.StatusOK {
t.Fatalf("建号应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
id, _ := created["id"].(float64)
if id == 0 {
t.Fatalf("建号响应缺少 id: %+v", created)
}
status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", int64(id)),
map[string]any{"password": tooLong}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error)
}
}
+44 -4
View File
@@ -19,9 +19,10 @@ var errHandled = errors.New("httpapi: response already written")
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
// 进程重启后客户端重试会重新执行,属于可接受的降级。
type idempotencyStore struct {
mu sync.Mutex
entries map[string]idempotentResponse
ttl time.Duration
mu sync.Mutex
entries map[string]idempotentResponse
inflight map[string]chan struct{}
ttl time.Duration
}
type idempotentResponse struct {
@@ -33,7 +34,34 @@ func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
return &idempotencyStore{
entries: make(map[string]idempotentResponse),
inflight: make(map[string]chan struct{}),
ttl: ttl,
}
}
// begin 声明某个 key 的执行权:leader=true 表示由本请求执行,
// 否则返回等待通道——等首次执行结束后回放其结果,避免同一 requestId 并发双执行。
func (s *idempotencyStore) begin(key string) (leader bool, wait <-chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.inflight[key]; ok {
return false, ch
}
ch := make(chan struct{})
s.inflight[key] = ch
return true, ch
}
// end 释放执行权并唤醒等待者。
func (s *idempotencyStore) end(key string) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.inflight[key]; ok {
delete(s.inflight, key)
close(ch)
}
}
func (s *idempotencyStore) get(key string) ([]byte, bool) {
@@ -106,6 +134,18 @@ func (s *server) cached(inner http.Handler) http.HandlerFunc {
writeCached(w, body)
return
}
// 占位锁只放在最外层:内层 idempotent 与这里共用同一把 key,
// 若两处都抢占会自我等待。并发同一 requestId 由这里串行化并回放首次结果。
leader, wait := s.idem.begin(key)
for !leader {
<-wait
if body, ok := s.idem.get(key); ok {
writeCached(w, body)
return
}
leader, wait = s.idem.begin(key)
}
defer s.idem.end(key)
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(cw, r)
@@ -0,0 +1,47 @@
package httpapi
import (
"testing"
"time"
)
// 同一 requestId 并发时只能有一个执行者,其余请求等首次结束后回放其结果。
func TestIdempotencyStoreSerializesSameKey(t *testing.T) {
store := newIdempotencyStore(time.Minute)
leader, _ := store.begin("k")
if !leader {
t.Fatal("首个请求应拿到执行权")
}
done := make(chan struct{})
go func() {
leader, wait := store.begin("k")
if leader {
t.Error("并发同 key 不应拿到执行权")
}
<-wait
close(done)
}()
// 等 goroutine 进入等待后再检查它不会提前返回。
time.Sleep(20 * time.Millisecond)
select {
case <-done:
t.Fatal("未释放执行权前等待者不应返回")
default:
}
store.end("k")
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("释放执行权后应唤醒等待者")
}
// 释放后应能重新拿到执行权。
if leader, _ := store.begin("k"); !leader {
t.Fatal("执行权释放后应可再次获取")
}
store.end("k")
}
+59 -7
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -176,7 +178,12 @@ func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
}
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
t, err := scanTeam(s.ev.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
return teamByIDQ(ctx, s.ev, id)
}
// teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。
func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return teamDTO{}, notFound("单位不存在")
}
@@ -190,7 +197,13 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
@@ -204,11 +217,28 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if err != nil {
return teamDTO{}, internal(err)
}
return s.getTeam(ctx, id)
team, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamDTO{}, err
}
// 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
return teamDTO{}, err
}
if err := tx.Commit(); err != nil {
return teamDTO{}, internal(err)
}
return team, nil
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
res, err := s.ev.ExecContext(ctx,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
@@ -221,16 +251,38 @@ func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO,
if err := requireChanges(res, "单位不存在"); err != nil {
return teamDTO{}, err
}
return s.getTeam(ctx, id)
team, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamDTO{}, err
}
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
return teamDTO{}, err
}
if err := tx.Commit(); err != nil {
return teamDTO{}, internal(err)
}
return team, nil
}
func (s *server) deleteTeam(ctx context.Context, id int64) error {
// 业务数据软删除:置 active=0,保留历史成绩与流水。
res, err := s.ev.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
return requireChanges(res, "单位不存在")
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
if err != nil {
return internal(err)
}
if err := requireChanges(res, "单位不存在"); err != nil {
return err
}
if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
return tx.Commit()
}
// ---------- athletes ----------
+61 -7
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
@@ -53,12 +55,9 @@ func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, err
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
e, err := scanEvent(s.ev.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
e, err := eventByIDQ(ctx, s.ev, id)
if err != nil {
return eventDTO{}, internal(err)
return eventDTO{}, err
}
cats, err := s.listCategories(ctx, id)
if err != nil {
@@ -73,11 +72,29 @@ func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
return e, nil
}
// eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。
func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
e, err := scanEvent(q.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
if err != nil {
return eventDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
res, err := s.ev.ExecContext(ctx,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -99,11 +116,24 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
if err != nil {
return eventDTO{}, internal(err)
}
// 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
res, err := s.ev.ExecContext(ctx,
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
@@ -125,9 +155,24 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
// bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。
func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error {
ev, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return err
}
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
}
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
@@ -150,6 +195,9 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
@@ -228,6 +276,9 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
@@ -291,6 +342,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
+103 -3
View File
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- settings ----------
@@ -34,7 +35,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
}
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, s.ev)
return s.settingsSnapshotQ(ctx, s.ev)
}
// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
values, err := loadSettingsMap(ctx, q)
if err != nil {
return dto.EventSettings{}, err
}
@@ -43,7 +50,7 @@ func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
if code := values["scoring.default_profile"]; code != "" {
var id int64
err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
@@ -114,6 +121,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err
}
if p.RotateSeconds != nil {
// 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
}
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
}
@@ -121,6 +132,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
// 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
if *p.IndividualToTeamWeight <= 0 {
return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
}
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
@@ -160,12 +175,26 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
}
// 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
snapshot, err := s.settingsSnapshotQ(ctx, tx)
if err != nil {
return dto.EventSettings{}, err
}
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
return dto.EventSettings{}, err
}
if err := tx.Commit(); err != nil {
return dto.EventSettings{}, internal(err)
}
return s.getSettings(ctx)
return snapshot, nil
}
// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
const (
minRotateSeconds = 5
maxRotateSeconds = 120
)
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
@@ -216,11 +245,28 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitSettingList(values["standings.tie_break"]),
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
func splitSettingList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
@@ -363,6 +409,15 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if err != nil {
return teamBreakdownDTO{}, err
}
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return teamBreakdownDTO{}, err
}
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
if weight <= 0 {
weight = 1
}
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
@@ -401,6 +456,9 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
if item.AthleteID != nil && *item.AthleteID != 0 {
item.Points *= weight
}
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
@@ -455,6 +513,9 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
if err := s.validateRecordBreakEntry(ctx, in); err != nil {
return recordBreakDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
@@ -472,6 +533,45 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
return s.getRecordBreak(ctx, id)
}
// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
var (
entryEvent int64
recordEligible bool
)
err := s.ev.QueryRowContext(ctx,
`SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
in.EntryID).Scan(&entryEvent, &recordEligible)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("entryId 不存在", in.EntryID)
}
if err != nil {
return internal(err)
}
if entryEvent != in.EventID {
return badRequest("entryId 不属于该小项", in.EntryID)
}
if !recordEligible {
return badRequest("该小项未开启破纪录登记", in.EventID)
}
var (
state string
status string
)
err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
if errors.Is(err, sql.ErrNoRows) {
return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
}
if err != nil {
return internal(err)
}
if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
}
return nil
}
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var (
b recordBreakDTO
+208
View File
@@ -0,0 +1,208 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// changeLogCount 统计某实体某动作的变更记录数。
func changeLogCount(t *testing.T, env *testEnv, entity, action string) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ? AND action = ?`,
entity, action).Scan(&n); err != nil {
t.Fatalf("统计 change_log: %v", err)
}
return n
}
// 配置写操作必须写 change_log,否则 broadcastChanges 推空、大屏不刷新;
// 同时校验数值范围,浏览器 min/max 只是提示,不能当服务端校验。
func TestSettingsPatchValidationAndChangeLog(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
for _, tc := range []struct {
name string
body map[string]any
}{
{"轮播间隔过小", map[string]any{"display": map[string]any{"rotateSeconds": 1}}},
{"轮播间隔过大", map[string]any{"display": map[string]any{"rotateSeconds": 121}}},
{"折算系数为 0", map[string]any{"standings": map[string]any{"individualToTeamWeight": 0}}},
{"折算系数为负", map[string]any{"standings": map[string]any{"individualToTeamWeight": -1}}},
} {
status, env2 := env.do(client, http.MethodPut, "/api/settings", tc.body, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, env2.Error)
}
}
before := changeLogCount(t, env, "settings", "upsert")
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"name": "秋季运动会", "subtitle": ""},
"display": map[string]any{"rotateSeconds": 30},
"standings": map[string]any{"individualToTeamWeight": 0.5},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("合法配置应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
t.Fatalf("配置写操作应新增 1 条 settings change_log,得到 %d → %d", before, got)
}
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
settings := decodeData[map[string]any](t, env2)
event := settings["event"].(map[string]any)
if event["name"] != "秋季运动会" || event["subtitle"] != nil {
t.Fatalf("空串应归一为 null、名称应落库: %+v", event)
}
display := settings["display"].(map[string]any)
if display["rotateSeconds"].(float64) != 30 {
t.Fatalf("轮播间隔应落库为 30: %+v", display)
}
}
// 破纪录登记必须基于该项目一条已发布且状态正常的成绩,否则 recorder 可凭空加分。
func TestRecordBreakRequiresPublishedResult(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")
body := map[string]any{"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50"}
// 没有成绩。
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("无成绩时登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 只有草稿成绩。
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state)
VALUES (?, ?, 1, 10.5, 'ok', 'draft')`, ids.event, ids.entryA)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("草稿成绩登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 已发布但小项不可破纪录。
env.exec(t, `UPDATE results SET state = 'published' WHERE entry_id = ?`, ids.entryA)
env.exec(t, `UPDATE events SET record_eligible = 0 WHERE id = ?`, ids.event)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusBadRequest {
t.Fatalf("不可破纪录项目应 400,得到 %d %+v", status, env2.Error)
}
// 报名项不属于该项目。
env.exec(t, `UPDATE events SET record_eligible = 1 WHERE id = ?`, ids.event)
otherEvent := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks",
map[string]any{"eventId": otherEvent, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50"}, true)
if status != http.StatusBadRequest {
t.Fatalf("跨项目登记破纪录应 400,得到 %d %+v", status, env2.Error)
}
// 合法:已发布成绩 + 可破纪录项目。
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("合法登记应 200,得到 %d %+v", status, env2.Error)
}
}
// 单位积分明细的合计必须与总积分榜同口径:个人流水乘折算系数。
func TestTeamBreakdownAppliesIndividualWeight(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
env.exec(t, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
breakdown := decodeData[map[string]any](t, env2)
if got := breakdown["totalPoints"].(float64); got != 5 {
t.Fatalf("合计应按系数折算为 5,得到 %v", got)
}
items := breakdown["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["points"].(float64) != 5 {
t.Fatalf("明细行也应按系数折算: %+v", items)
}
// 与总积分榜一致。
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
for _, row := range decodeData[[]map[string]any](t, env2) {
if row["teamCode"] == "A" && row["totalPoints"].(float64) != 5 {
t.Fatalf("总积分榜与明细合计应一致: %+v", row)
}
}
}
// standings.tie_break 必须真正参与排序,否则换规则包后名次仍走硬编码默认序。
func TestStandingsTieBreakFromSettings(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, `UPDATE teams SET name = 'zzz' WHERE id = ?`, ids.teamA)
env.exec(t, `UPDATE teams SET name = 'aaa' WHERE id = ?`, ids.teamB)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.58, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
first := func() string {
t.Helper()
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
rows := decodeData[[]map[string]any](t, env2)
if len(rows) != 2 {
t.Fatalf("应返回 2 个单位,得到 %d", len(rows))
}
return rows[0]["teamCode"].(string)
}
if got := first(); got != "A" {
t.Fatalf("默认按单位编码排序应 A 在前,得到 %s", got)
}
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.tie_break', 'points,name', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
if got := first(); got != "B" {
t.Fatalf("配置 standings.tie_break=points,name 后应按名称排序(aaa 在前),得到 %s", got)
}
}
+15 -2
View File
@@ -178,7 +178,8 @@ func (s *server) listResultsPage(ctx context.Context, eventID int64, state strin
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。
// upsertDraft 批量保存草稿;已发布的成绩直接拒绝(STATE_INVALID),
// 必须先撤销发布再改,避免 rank 被就地清空而流水不动。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -200,6 +201,18 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
// 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
// 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
// 尚无成绩,正常新建草稿。
case err != nil:
return nil, internal(err)
case state == string(domain.ResultStatePublished):
return nil, stateInvalid("该成绩已发布,请先撤销发布再修改")
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
@@ -208,7 +221,7 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = excluded.unit,
unit = COALESCE(excluded.unit, results.unit),
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
@@ -0,0 +1,113 @@
package httpapi_test
import (
"database/sql"
"net/http"
"testing"
)
// 已发布成绩不能被「存草稿」就地覆盖:ON CONFLICT 不改 state,
// 放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
func TestSaveDraftRejectsPublishedResult(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "status": "ok"},
},
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "STATE_INVALID" {
t.Fatalf("已发布成绩存草稿应 409 STATE_INVALID,得到 %d %+v", status, env2.Error)
}
var (
rank int
value float64
state string
)
if err := env.ev.QueryRow(`SELECT rank, result_value, state FROM results WHERE entry_id = ?`, ids.entryA).
Scan(&rank, &value, &state); err != nil {
t.Fatalf("读取成绩: %v", err)
}
if rank != 1 || value != 11.58 || state != "published" {
t.Fatalf("被拒的存草稿不得改动已发布行: rank=%d value=%v state=%s", rank, value, state)
}
// 正确流程:先撤销发布,再存草稿。
if status, env2 := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("撤销发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("撤销发布后存草稿应 200,得到 %d %+v", status, env2.Error)
}
}
// 草稿未传 unit 时应保留原值,否则 100 米成绩会从 1:06.33 退化成 66.33。
func TestSaveDraftKeepsUnitWhenOmitted(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 66.33, "unit": "s", "status": "ok"},
},
}, true)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 66.33, "resultText": "1:06.33", "status": "ok"},
},
}, true)
var unit sql.NullString
if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
t.Fatalf("读取单位: %v", err)
}
if !unit.Valid || unit.String != "s" {
t.Fatalf("未传 unit 时应保留原值 s,得到 %+v", unit)
}
// 显式传 unit 仍可覆盖。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 1.1, "unit": "min", "status": "ok"},
},
}, true)
if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
t.Fatalf("读取单位: %v", err)
}
if !unit.Valid || unit.String != "min" {
t.Fatalf("显式传 unit 应覆盖为 min,得到 %+v", unit)
}
}
+87
View File
@@ -0,0 +1,87 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 单位增删改必须写 change_log,否则 broadcastChanges 推空、大屏单位名不刷新。
func TestTeamWritesWriteChangeLogAndBroadcast(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "upsert"); got != 1 {
t.Fatalf("建单位应写 1 条 teams change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
teamID := int64(created["id"].(float64))
found := false
for _, ch := range fake.firstBatch(t) {
if ch.Entity == "teams" && ch.Action == "upsert" && ch.ID == teamID {
found = true
}
}
if !found {
t.Fatalf("建单位后应广播 teams 变更")
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil, true); status != http.StatusOK {
t.Fatalf("删单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "void"); got != 1 {
t.Fatalf("删单位应写 1 条 teams void 变更,得到 %d", got)
}
}
// 项目、组别、通道的写操作同样要推 change_log,大屏才会刷新。
func TestEventWritesWriteChangeLog(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")
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "M400", "name": "400米跑",
"eventType": "individual", "resultType": "time", "compare": "asc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 1 {
t.Fatalf("建项目应写 1 条 events change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
eventID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", eventID), map[string]any{
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换组别应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 2 {
t.Fatalf("替换组别应再写 1 条 events change_log,得到 %d", got)
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true); status != http.StatusOK {
t.Fatalf("删项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "void"); got != 1 {
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
}
}
@@ -402,6 +402,10 @@ func TestApproveRecordBreakUpsertsRecords(t *testing.T) {
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 破纪录登记要求该报名项已有已发布成绩,先落一条。
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, result_text, unit, status, state)
VALUES (?, ?, 1, 10.5, '10.50', 's', 'ok', 'published')`, ids.event, ids.entryA)
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50",
+104 -30
View File
@@ -52,6 +52,52 @@ func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error
return nil, errHandled
}
// rulesPackCompatible 判断规则包格式版本是否与当前程序一致。
func rulesPackCompatible(pack *rules.RulePack) bool {
return pack.Format == rules.FormatName && pack.FormatVersion == rules.FormatVersion
}
// rulesImportPreview 是规则预览响应:在 ChangeSet 之上补齐前端契约里的兼容性与影响面。
type rulesImportPreview struct {
*rules.ChangeSet
// Compatible 由 formatVersion 判断;能解析到这里说明版本已兼容。
Compatible bool `json:"compatible"`
AffectedEventCount int `json:"affectedEventCount"`
}
// rulesImportResult 是规则落地响应:在 ApplyResult 之上补齐预览字段与重算条数。
// 同时带上 ChangeSet,前端落地后仍能显示这次导入的逐条差异(否则会被覆盖成「无差异」)。
type rulesImportResult struct {
*rules.ApplyResult
*rules.ChangeSet
Compatible bool `json:"compatible"`
AffectedEventCount int `json:"affectedEventCount"`
RecalculatedLedgerRows int `json:"recalculatedLedgerRows"`
}
// affectedEventCount 统计本次导入影响的小项数:计分通道变化的项目,
// 加上引用了被改动模板的项目——模板分值一变,这些项目的积分就会跟着变。
func affectedEventCount(cs *rules.ChangeSet, cur *rules.Snapshot) int {
affected := make(map[string]bool, len(cs.Channels))
for _, ch := range cs.Channels {
affected[ch.EventCode] = true
}
if len(cs.Profiles) > 0 {
changed := make(map[string]bool, len(cs.Profiles))
for _, pc := range cs.Profiles {
changed[pc.Code] = true
}
for _, ev := range cur.Events {
for _, ch := range ev.Channels {
if changed[ch.Profile] {
affected[ev.Code] = true
}
}
}
}
return len(affected)
}
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
@@ -76,60 +122,88 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
return cs, nil
return rulesImportPreview{
ChangeSet: cs,
Compatible: rulesPackCompatible(pack),
AffectedEventCount: affectedEventCount(cs, cur),
}, nil
}
res, err := rules.Apply(s.ev, cs)
if err != nil {
return nil, internal(err)
}
// 分值一变,历史成绩的积分必须全量重算。
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
return nil, internal(err)
// 分值一变,历史成绩的积分与名次必须全量重算。
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
if err != nil {
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, &apiError{
status: http.StatusInternalServerError,
code: "INTERNAL",
message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
detail: err.Error(),
}
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return res, nil
return rulesImportResult{
ApplyResult: res,
ChangeSet: cs,
Compatible: rulesPackCompatible(pack),
AffectedEventCount: affectedEventCount(cs, cur),
RecalculatedLedgerRows: rows,
}, nil
}
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
// 返回写入的积分流水条数,供导入结果回报。
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
if err != nil {
return err
return 0, err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
return 0, err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return err
return 0, err
}
if err := rows.Err(); err != nil {
return err
return 0, err
}
for _, eventID := range ids {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
_ = tx.Rollback()
return err
}
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
return nil
defer func() { _ = tx.Rollback() }()
total := 0
for _, eventID := range ids {
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
if err != nil {
return 0, err
}
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return 0, err
}
total += len(score.Ledger)
}
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return total, nil
}
@@ -0,0 +1,243 @@
package httpapi_test
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// decodeRaw 解析 doRaw 返回的完整响应体({ok,data} 信封)。
func decodeRaw[T any](t *testing.T, raw []byte) T {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
return decodeData[T](t, env)
}
// exportRulesYAML 取当前规则包(导出与导入共用同一序列化,保证往返可解析)。
func (e *testEnv) exportRulesYAML(t *testing.T, client *http.Client) string {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
return string(body)
}
// publishTwoResults 录入两条成绩并发布,返回两条流水的分值(按单位)。
func (e *testEnv) publishTwoResults(t *testing.T, client *http.Client, ids seedIDs) {
t.Helper()
status, env2 := e.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-rules-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = e.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-rules-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// ledgerPointsByTeam 读取当前有效流水的「单位 → 分值」。
func (e *testEnv) ledgerPointsByTeam(t *testing.T) map[int64]float64 {
t.Helper()
rows, err := e.ev.Query(`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result'`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := map[int64]float64{}
for rows.Next() {
var team int64
var points float64
if err := rows.Scan(&team, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[team] = points
}
return out
}
// 规则导入后必须回写名次:分值一变,results.rank 也要跟着新规则重算(P0-5)。
func TestRulesImportRewritesRanks(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")
env.publishTwoResults(t, client, ids)
// 模拟名次过期/写坏,导入规则包后应被重算覆盖。
env.exec(t, `UPDATE results SET rank = 99 WHERE event_id = ?`, ids.event)
pack := env.exportRulesYAML(t, client)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
rows, err := env.ev.Query(`SELECT entry_id, rank FROM results WHERE event_id = ? ORDER BY entry_id`, ids.event)
if err != nil {
t.Fatalf("查询名次: %v", err)
}
defer func() { _ = rows.Close() }()
ranks := map[int64]int64{}
for rows.Next() {
var entryID, rank int64
if err := rows.Scan(&entryID, &rank); err != nil {
t.Fatalf("扫描名次: %v", err)
}
ranks[entryID] = rank
}
if ranks[ids.entryA] != 1 || ranks[ids.entryB] != 2 {
t.Fatalf("导入后名次应重算为 A=1 B=2,得到 %+v", ranks)
}
}
// 预览与落地响应必须带上 compatible / affectedEventCount / recalculatedLedgerRows(P1-11)。
func TestRulesImportReturnsContractFields(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")
env.publishTwoResults(t, client, ids)
// 把 M100 的计分通道换成团队模板:制造一个「影响 1 个小项」的差异。
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道,导出内容:\n%s", pack)
}
// 只改模板分值:引用了该模板的小项同样算「被影响」。
pointsChanged := strings.Replace(pack, "points: 10", "points: 11", 1)
if pointsChanged == pack {
t.Fatalf("规则包里没有可替换的分值,导出内容:\n%s", pack)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(pointsChanged), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
pointsPreview := decodeRaw[map[string]any](t, raw)
if count, _ := pointsPreview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("只改模板分值时 affectedEventCount 应为 1,得到 %+v", pointsPreview["affectedEventCount"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
preview := decodeRaw[map[string]any](t, raw)
if preview["compatible"] != true {
t.Fatalf("预览 compatible 应为 true,得到 %+v", preview["compatible"])
}
if count, _ := preview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("预览 affectedEventCount 应为 1,得到 %+v", preview["affectedEventCount"])
}
if channels, _ := preview["channels"].([]any); len(channels) != 1 {
t.Fatalf("预览 channels 应含 1 个小项,得到 %+v", preview["channels"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
applied := decodeRaw[map[string]any](t, raw)
if applied["compatible"] != true {
t.Fatalf("导入结果 compatible 应为 true,得到 %+v", applied["compatible"])
}
if count, _ := applied["affectedEventCount"].(float64); count != 1 {
t.Fatalf("导入结果 affectedEventCount 应为 1,得到 %+v", applied["affectedEventCount"])
}
if rows, _ := applied["recalculatedLedgerRows"].(float64); rows != 2 {
t.Fatalf("导入结果 recalculatedLedgerRows 应为 2,得到 %+v", applied["recalculatedLedgerRows"])
}
// 落地响应也要带差异明细,否则前端会把预览覆盖成「无差异」。
if channels, _ := applied["channels"].([]any); len(channels) != 1 {
t.Fatalf("导入结果 channels 应含 1 个小项,得到 %+v", applied["channels"])
}
// 换成团队模板后分值应变成 15/10,证明重算确实落库。
points := env.ledgerPointsByTeam(t)
if points[ids.teamA] != 15 || points[ids.teamB] != 10 {
t.Fatalf("导入后流水分值应为 A=15 B=10,得到 %+v", points)
}
}
// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
func TestRulesImportRecomputeFailureRollsBackAndReports(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")
env.publishTwoResults(t, client, ids)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
// 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
// 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
brokenEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, broken, brokenCat, ids.teamA, ids.athleteA)
env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
before := env.ledgerPointsByTeam(t)
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道")
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "积分重算失败") {
t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
}
// 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
var code string
if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
JOIN scoring_profiles p ON p.id = c.profile_id
WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
t.Fatalf("查询导入后的计分通道: %v", err)
}
if code != "team_top6" {
t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
}
after := env.ledgerPointsByTeam(t)
if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
}
}
+56 -4
View File
@@ -328,12 +328,17 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
// 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项),
// 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活,
// 重算再插一份导致该通道分数翻倍。这类流水改按「本小项的聚合通道」定位。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, categoryID)
scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'
AND source_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)))`
args = append(args, categoryID, eventID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
@@ -346,7 +351,33 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
if err != nil {
return domain.EventScore{}, err
}
for _, l := range score.Ledger {
ledger := score.Ledger
if categoryID > 0 {
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
if err != nil {
return domain.EventScore{}, err
}
if hasAggregate {
all, err := computeEventScore(ctx, tx, eventID, 0)
if err != nil {
return domain.EventScore{}, err
}
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
for _, l := range score.Ledger {
if !isAggregateLedger(l) {
ledger = append(ledger, l)
}
}
for _, l := range all.Ledger {
if isAggregateLedger(l) {
ledger = append(ledger, l)
}
}
}
}
for _, l := range ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -358,6 +389,22 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
return score, nil
}
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
// 落库时 entry_id 与 result_id 都为空。
func isAggregateLedger(l domain.LedgerEntry) bool {
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
}
// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
return false, internal(err)
}
return n > 0, nil
}
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
@@ -575,7 +622,12 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
if err != nil {
return resultDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
+120
View File
@@ -0,0 +1,120 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// resultLedger 统计成绩分流水(未作废)的条数与合计。
func (e *testEnv) resultLedger(t *testing.T) (int, float64) {
t.Helper()
var (
count int
total float64
)
if err := e.ev.QueryRow(`SELECT COUNT(*), COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND source_type = 'result'`).Scan(&count, &total); err != nil {
t.Fatalf("统计成绩流水: %v", err)
}
return count, total
}
// 聚合通道的流水 entry_id 为空,按组别重算若只靠 entry_id 定位就作废不掉旧流水,
// 重算再插一份导致分数翻倍。整项发布后反复按组别发布,分数必须保持不变。
func TestRecomputeAggregateLedgerIsIdempotent(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")
// 换成聚合通道:按单位求和后排名,流水不对应单条报名项。
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
publish := func(name string, categoryID *int64) {
t.Helper()
body := map[string]any{"eventId": ids.event}
if categoryID != nil {
body["categoryId"] = *categoryID
}
status, env2 := env.do(client, http.MethodPost, "/api/results/publish", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("%s应 200,得到 %d %+v", name, status, env2.Error)
}
// 聚合通道两单位各一条流水:15 + 10。
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("%s后成绩流水应仍为 2 条 25 分(不得翻倍),得到 %d 条 %v 分", name, count, total)
}
}
catID := ids.category
publish("整项发布", nil)
publish("按组别发布", &catID)
publish("再次按组别发布", &catID)
}
// 作废一条成绩会改变同组别名次,重算结果必须回写 results.rank,
// 否则奖牌榜(读 rank)与积分榜(读流水)互相矛盾。
func TestVoidResultRewritesRemainingRanks(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
var winnerID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&winnerID); err != nil {
t.Fatalf("读取成绩 id: %v", err)
}
status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/void", winnerID),
map[string]any{"reason": "抢跑"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
}
var rank int
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
t.Fatalf("读取名次: %v", err)
}
if rank != 1 {
t.Fatalf("作废冠军后剩余成绩名次应回写为 1,得到 %d", rank)
}
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
standings := decodeData[[]map[string]any](t, env2)
for _, row := range standings {
if row["teamCode"] == "B" {
if row["totalPoints"].(float64) != 10 || row["gold"].(float64) != 1 {
t.Fatalf("作废后 B 应升为第一(10 分 1 金): %+v", row)
}
}
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ func New(opts Options) http.Handler {
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
h = middleware.Authenticate(svc)(h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
@@ -236,6 +236,9 @@ func TestRevisionsListAndDetail(t *testing.T) {
}
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
// 已发布成绩必须先撤销发布才能改(服务端拒绝直接覆盖 published 行),
// 这里直接把状态置回 draft,等价于撤销发布但不额外留一条 unpublish 版本。
env.exec(t, `UPDATE results SET state = 'draft' WHERE event_id = ?`, ids.event)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
+10 -3
View File
@@ -16,8 +16,10 @@ type AuditEntry struct {
Action string
EntityType string
EntityID int64
Before any
After any
// ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。
ActorID int64
Before any
After any
}
type auditCollector struct {
@@ -73,10 +75,15 @@ func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEnt
if e.EntityID != 0 {
entityID = e.EntityID
}
// 登录等动作在请求 ctx 里还没有登录身份,改由条目自带的 ActorID 补上。
actor := nullableID(u)
if e.ActorID != 0 {
actor = e.ActorID
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nullableID(u), e.Action, e.EntityType, entityID,
actor, e.Action, e.EntityType, entityID,
marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID)
if err != nil {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
+48 -3
View File
@@ -6,10 +6,13 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"runtime/debug"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -18,10 +21,16 @@ import (
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
const HeaderRequestID = "X-Request-Id"
// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
// maxRequestIDLen 限制客户端 request_id 长度:它会被下游当作幂等缓存键,超长会撑内存。
const maxRequestIDLen = 128
// RequestID 复用客户端传入的 request_id,没有或过长则生成一个并写回响应头。
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(HeaderRequestID)
if len(id) > maxRequestIDLen {
id = ""
}
if id == "" {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err == nil {
@@ -30,6 +39,8 @@ func RequestID(next http.Handler) http.Handler {
id = time.Now().UTC().Format("20060102150405.000000")
}
}
// 回写请求头,让下游(幂等缓存)读到同一个受控 id。
r.Header.Set(HeaderRequestID, id)
w.Header().Set(HeaderRequestID, id)
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
})
@@ -85,13 +96,24 @@ func Log(logger *slog.Logger) func(http.Handler) http.Handler {
}
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
// 会话查询失败必须留痕:否则系统库故障会静默把所有人降级成 guest。
func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &auth.User{Role: auth.RoleGuest}
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
found, _, err := svc.Lookup(r.Context(), c.Value)
switch {
case err == nil:
u = found
case errors.Is(err, auth.ErrSessionNotFound):
// 会话缺失/过期是正常情况,静默按 guest 放行。
default:
logger.Error("会话查询失败",
"err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context()))
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
@@ -162,6 +184,12 @@ func CSRF(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
// 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
// 因此对带 Origin/Referer 的登录请求单独做同源校验。
if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败", nil)
return
}
if User(r.Context()).Role == auth.RoleGuest {
// 未登录请求由 RequireWrite 处理,无需 CSRF。
next.ServeHTTP(w, r)
@@ -179,6 +207,23 @@ func CSRF(next http.Handler) http.Handler {
})
}
// sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer),
// 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。
func sameOrigin(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
raw = r.Header.Get("Referer")
}
if raw == "" {
return true
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@@ -0,0 +1,105 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 客户端超长 request_id 会被下游当作幂等键缓存,必须作废并换成服务端生成的 id(P2-6)。
func TestRequestIDClampsOversizedHeader(t *testing.T) {
var (
ctxID string
handlerID string
)
h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxID = GetRequestID(r.Context())
handlerID = r.Header.Get(HeaderRequestID)
}))
req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
req.Header.Set(HeaderRequestID, strings.Repeat("x", maxRequestIDLen+1))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ctxID == "" || len(ctxID) > maxRequestIDLen {
t.Fatalf("超长 id 应换成服务端生成的短 id,得到 %q", ctxID)
}
if handlerID != ctxID {
t.Fatalf("下游读到的 request_id 应与 ctx 一致,ctx=%q header=%q", ctxID, handlerID)
}
if got := rec.Header().Get(HeaderRequestID); got != ctxID {
t.Fatalf("响应头 request_id 应为 %q,得到 %q", ctxID, got)
}
}
// 合理长度的客户端 id 仍应原样复用,保证幂等回放。
func TestRequestIDKeepsNormalHeader(t *testing.T) {
var ctxID string
h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxID = GetRequestID(r.Context())
}))
req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
req.Header.Set(HeaderRequestID, "abc-123")
h.ServeHTTP(httptest.NewRecorder(), req)
if ctxID != "abc-123" {
t.Fatalf("正常 id 应原样复用,得到 %q", ctxID)
}
}
// 登录是 guest 请求,CSRF Token 校验被跳过,靠同源校验挡住 login CSRF(P2-6)。
func TestCSRFBlocksCrossOriginLogin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := CSRF(next)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "scoreboard.local"
req.Header.Set("Origin", "http://evil.example")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("跨站登录应 403,得到 %d", rec.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "scoreboard.local"
req.Header.Set("Origin", "http://scoreboard.local")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("同源登录应放行,得到 %d", rec.Code)
}
}
// 同源判断:两个头都没有时放行脚本调用,Origin: null 与解析失败一律拒绝。
func TestSameOrigin(t *testing.T) {
cases := []struct {
name string
origin string
referer string
want bool
}{
{"无来源头", "", "", true},
{"同源 Origin", "http://scoreboard.local", "", true},
{"跨站 Origin", "http://evil.example", "", false},
{"Origin 为 null", "null", "", false},
{"同源 Referer", "", "http://scoreboard.local/admin", true},
{"跨站 Referer", "", "http://evil.example/x", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "scoreboard.local"
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
if tc.referer != "" {
req.Header.Set("Referer", tc.referer)
}
if got := sameOrigin(req); got != tc.want {
t.Fatalf("sameOrigin = %v,期望 %v", got, tc.want)
}
})
}
}
+24 -1
View File
@@ -33,13 +33,15 @@ type client struct {
sessionRole string
sessionUserID int64
instanceID string
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
return &client{
hub: h,
conn: conn,
@@ -48,6 +50,7 @@ func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, ses
done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
settingsAdmin: settingsAdmin,
}
}
@@ -63,6 +66,15 @@ func (c *client) stop(code websocket.StatusCode, reason string) {
})
}
// guardLoop 兜住读写 goroutine 里的 panic:HTTP 中间件的 recover 只覆盖 handler 栈,
// 这两条 goroutine 不兜底会把整个进程带走。
func (c *client) guardLoop(name string) {
if r := recover(); r != nil {
c.hub.log.Error("实时循环 panic,已断开连接", "loop", name, "remote", c.remoteAddr, "panic", r)
c.stop(websocket.StatusInternalError, name+"循环异常")
}
}
func (c *client) sendEvent(event string, data any) bool {
msg, err := encode(event, data)
if err != nil {
@@ -88,8 +100,10 @@ func (c *client) sendRaw(msg []byte) bool {
}
// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。
// 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
defer c.guardLoop("读")
for {
c.mu.Lock()
@@ -140,6 +154,14 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
c.hub.touchInstance(c, time.Now())
case EventResync:
// hello 之前的 resync 既没有房间归属,又会提前清掉 hello 超时,直接忽略。
c.mu.Lock()
greeted := c.greeted
c.mu.Unlock()
if !greeted {
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
return
}
c.hub.sendInitial(ctx, c, nil)
default:
@@ -149,6 +171,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
func (c *client) writeLoop(ctx context.Context) {
defer c.guardLoop("写")
for {
select {
case <-ctx.Done():
+34 -10
View File
@@ -21,6 +21,7 @@ const (
defaultSlowLag = 500
defaultHelloTimeout = 15 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
)
@@ -221,7 +222,7 @@ func (h *Hub) Handler() http.Handler {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
_ = conn.CloseNow()
_ = conn.Close(websocket.StatusGoingAway, "服务关闭")
return
}
h.connWG.Add(1)
@@ -229,25 +230,26 @@ func (h *Hub) Handler() http.Handler {
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
role, userID := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID)
role, userID, settingsAdmin := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
})
}
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
func wsIdentity(ctx context.Context) (string, int64) {
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentity(ctx context.Context) (string, int64, bool) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
return RoleDisplay, 0
return RoleDisplay, 0, false
}
return RoleAdmin, u.ID
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
@@ -259,11 +261,29 @@ func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessio
go func() { defer loops.Done(); c.writeLoop(ctx) }()
<-c.done
// 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接
// 关闭底层连接,那样对端只能看到 EOF,收不到策略性关闭码(如 1008)。
// 写循环已因 c.done 退出,读循环会随对端回帧自然结束。
c.mu.Lock()
code, reason := c.closeCode, c.closeReason
c.mu.Unlock()
if code == 0 {
code = websocket.StatusNormalClosure
}
closed := make(chan struct{})
go func() {
defer close(closed)
_ = conn.Close(code, reason)
}()
select {
case <-closed:
case <-time.After(defaultCloseTimeout):
// 对端不回应:cancel 会强制关闭底层连接,读循环随之退出。
}
cancel()
loops.Wait()
h.removeClient(c)
// 客户端会自动重连并走 catchup,这里直接断开,避免关闭握手阻塞。
_ = conn.CloseNow()
}
func (h *Hub) addClient(c *client) {
@@ -429,6 +449,10 @@ func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
c.stop(websocket.StatusInternalError, "快照不可用")
return
}
// 快照对所有角色下发,但系统配置只有 admin 能看:非 admin 只保留大屏渲染所需字段。
if !c.settingsAdmin {
snap.Settings = snap.Settings.WithoutAdminFields()
}
if !c.sendEvent(EventSnapshot, snap) {
return
}
+152 -5
View File
@@ -94,9 +94,10 @@ func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
}
type wsMessage struct {
event string
data json.RawMessage
err error
event string
data json.RawMessage
err error
closeCode websocket.StatusCode
}
type wsClient struct {
@@ -114,7 +115,7 @@ func (c *wsClient) readLoop() {
for {
_, data, err := c.conn.Read(c.ctx)
if err != nil {
c.msgs <- wsMessage{err: err}
c.msgs <- wsMessage{err: err, closeCode: websocket.CloseStatus(err)}
return
}
var env envelope
@@ -196,6 +197,30 @@ func (c *wsClient) expectClosed(d time.Duration) {
}
}
// expectClosedWithCode 断言连接被服务端以指定关闭码关闭,且关闭前没有下发任何事件。
func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCode) {
c.t.Helper()
timer := time.NewTimer(d)
defer timer.Stop()
for {
select {
case msg, ok := <-c.msgs:
if !ok {
c.t.Fatalf("连接已关闭但没有收到关闭帧")
}
if msg.err != nil {
if msg.closeCode != want {
c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
}
return
}
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
case <-timer.C:
c.t.Fatalf("等待服务端关闭连接超时")
}
}
}
func decode[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
@@ -511,11 +536,12 @@ func TestAdminRoomTargeting(t *testing.T) {
}
// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。
// 断开必须带上策略性关闭码,否则客户端会当成网络异常无限重连。
func TestGuestCannotSubscribeAdminRoom(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.expectClosed(3 * time.Second)
c.expectClosedWithCode(3*time.Second, websocket.StatusPolicyViolation)
}
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
@@ -576,3 +602,124 @@ func TestSlowClientDetected(t *testing.T) {
return len(insts) == 1 && !insts[0].Online
})
}
// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
// 所需的 event/display/榜单开关,默认积分模板、审核开关与折算系数必须置空/清零。
func TestSnapshotSettingsRedactedForNonAdmin(t *testing.T) {
for _, tc := range []struct {
name string
user *auth.User
}{
{"guest", &auth.User{Role: auth.RoleGuest}},
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}},
} {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnvAs(t, tc.user)
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: tc.name + "-1"})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
if snap.Settings.DefaultProfileID != nil {
t.Fatalf("非 admin 不应看到 defaultProfileId: %+v", snap.Settings.DefaultProfileID)
}
if snap.Settings.RequireApproval {
t.Fatalf("非 admin 不应看到 requireApproval")
}
if snap.Settings.Standings.IndividualToTeamWeight != 0 {
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %v", snap.Settings.Standings.IndividualToTeamWeight)
}
// 大屏要用的字段必须保留,裁剪不能误伤标题/主题/榜单开关。
if snap.Settings.Event.Name == "" || snap.Settings.Display.RotateSecond == 0 {
t.Fatalf("event/display 不应被裁剪: %+v", snap.Settings)
}
if snap.Settings.Standings.RankingMode != "points" ||
!snap.Settings.Standings.ShowIndividualBoard || !snap.Settings.Standings.ShowIndividualMedals {
t.Fatalf("standings 展示开关不应被裁剪: %+v", snap.Settings.Standings)
}
})
}
}
// admin 仍拿到完整配置,管理端才能在 WS 首包里读到默认积分模板与审核开关。
func TestSnapshotSettingsFullForAdmin(t *testing.T) {
env := newTestEnvAs(t, &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true})
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
c := env.dial(t, &helloPayload{Role: RoleAdmin})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
if snap.Settings.DefaultProfileID == nil {
t.Fatalf("admin 应看到 defaultProfileId")
}
if !snap.Settings.RequireApproval {
t.Fatalf("admin 应看到 requireApproval")
}
if snap.Settings.Standings.IndividualToTeamWeight != 0.5 {
t.Fatalf("admin 应看到 individualToTeamWeight=0.5,得到 %v", snap.Settings.Standings.IndividualToTeamWeight)
}
}
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
func TestResyncBeforeHelloIgnored(t *testing.T) {
env := newTestEnv(t)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, nil)
c.send(EventResync, struct{}{})
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("hello 之前的 resync 不应下发快照,收到事件 %s", ev)
}
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
}
c.send(EventResync, struct{}{})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
}
}
// 离线实例不能无限堆积:超过 TTL 后在下一次读取注册表时被清理。
func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "stale-1"})
c.recvEvent(EventSnapshot)
c.close()
waitFor(t, "实例离线", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && !insts[0].Online
})
env.hub.mu.Lock()
env.hub.instances["stale-1"].lastHeartbeat = time.Now().Add(-instanceTTL - time.Minute)
env.hub.mu.Unlock()
if insts := env.hub.Instances(); len(insts) != 0 {
t.Fatalf("过期离线实例应被清理,得到 %+v", insts)
}
}
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
func TestGuardLoopRecoversPanic(t *testing.T) {
env := newTestEnv(t)
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
func() {
defer c.guardLoop("读")
panic("boom")
}()
select {
case <-c.done:
default:
t.Fatal("panic 后应关闭该连接")
}
if c.closeCode != websocket.StatusInternalError {
t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode)
}
}
+39 -3
View File
@@ -8,6 +8,13 @@ import (
"time"
)
// 注册表容量控制:离线实例保留一段时间便于排查,过期即清理;
// 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。
const (
instanceTTL = 10 * time.Minute
maxInstances = 200
)
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
type Instance struct {
ID string `json:"id"`
@@ -52,6 +59,7 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
h.mu.Lock()
defer h.mu.Unlock()
h.pruneInstancesLocked(now)
st := h.instances[id]
if st == nil {
st = &instanceState{id: id, connectedAt: now}
@@ -68,7 +76,8 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
return id
}
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查。
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查;
// 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。
func (h *Hub) releaseDisplay(c *client) {
c.mu.Lock()
id := c.instanceID
@@ -126,10 +135,37 @@ func (h *Hub) setInstancePage(page string) {
}
}
// pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances;
// 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。
func (h *Hub) pruneInstancesLocked(now time.Time) {
for id, st := range h.instances {
if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL {
delete(h.instances, id)
}
}
if len(h.instances) <= maxInstances {
return
}
offline := make([]*instanceState, 0, len(h.instances))
for _, st := range h.instances {
if st.conns == 0 {
offline = append(offline, st)
}
}
sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) })
for _, st := range offline {
if len(h.instances) <= maxInstances {
break
}
delete(h.instances, st.id)
}
}
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
func (h *Hub) Instances() []Instance {
h.mu.RLock()
defer h.mu.RUnlock()
h.mu.Lock()
defer h.mu.Unlock()
h.pruneInstancesLocked(time.Now())
out := make([]Instance, 0, len(h.instances))
for _, st := range h.instances {
+44 -18
View File
@@ -24,8 +24,8 @@ const latestResultsLimit = 20
type Snapshot struct {
Version int64 `json:"version"`
Settings dto.EventSettings `json:"settings"`
Teams []domain.Team `json:"teams"`
Events []domain.Event `json:"events"`
Teams []dto.Team `json:"teams"`
Events []dto.Event `json:"events"`
Standings []dto.StandingRow `json:"standings"`
Records []Record `json:"records"`
LatestResults []dto.EventResultGroup `json:"latestResults"`
@@ -61,8 +61,8 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
defer func() { _ = tx.Rollback() }()
snap := &Snapshot{
Teams: []domain.Team{},
Events: []domain.Event{},
Teams: []dto.Team{},
Events: []dto.Event{},
Standings: []dto.StandingRow{},
Records: []Record{},
LatestResults: []dto.EventResultGroup{},
@@ -78,13 +78,15 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if err := fillSettings(ctx, tx, values, snap); err != nil {
return nil, err
}
if err := loadTeams(ctx, tx, snap); err != nil {
teams, err := loadTeams(ctx, tx)
if err != nil {
return nil, err
}
snap.Teams = dto.TeamsFromDomain(teams)
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadStandings(ctx, tx, snap, values); err != nil {
if err := loadStandings(ctx, tx, snap, teams, values); err != nil {
return nil, err
}
if err := loadRecords(ctx, tx, snap); err != nil {
@@ -142,29 +144,32 @@ func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, sna
return nil
}
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE active = 1
FROM teams
ORDER BY sort_order, code`)
if err != nil {
return fmt.Errorf("读取单位: %w", err)
return nil, fmt.Errorf("读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Team, 0)
for rows.Next() {
var (
t domain.Team
active int
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
return fmt.Errorf("解析单位: %w", err)
return nil, fmt.Errorf("解析单位: %w", err)
}
t.Active = active != 0
snap.Teams = append(snap.Teams, t)
out = append(out, t)
}
return rows.Err()
return out, rows.Err()
}
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
@@ -204,14 +209,15 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
e.Status = domain.EventStatus(status)
e.RecordEligible = recordEligible != 0
e.Scored = scored != 0
snap.Events = append(snap.Events, e)
snap.Events = append(snap.Events, dto.EventFromDomain(e))
}
return rows.Err()
}
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 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)
if err != nil {
return err
@@ -220,17 +226,36 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[s
if err != nil {
return err
}
activeTeams := make([]domain.Team, 0, len(teams))
for _, t := range teams {
if t.Active {
activeTeams = append(activeTeams, t)
}
}
rows := domain.BuildTeamStandings(domain.StandingsInput{
Teams: snap.Teams,
Teams: activeTeams,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitTieBreak(values["standings.tie_break"]),
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
})
snap.Standings = dto.StandingRows(rows, snap.Teams)
snap.Standings = dto.StandingRows(rows, teams)
return nil
}
// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
func splitTieBreak(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
@@ -331,6 +356,7 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
}
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
@@ -347,7 +373,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published'
WHERE r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
LIMIT ?`, latestResultsLimit)
if err != nil {
+162
View File
@@ -471,3 +471,165 @@ func lastID(t *testing.T, res sql.Result) int64 {
}
return id
}
// 快照榜单排序必须读规则包落库的 standings.tie_break,不能只走硬编码默认序。
func TestSnapshotTieBreakFromSettings(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
event1 := insertEvent(t, db, "E1", "100米")
event2 := insertEvent(t, db, "E2", "200米")
cat1 := insertCategory(t, db, event1, "男子")
cat2 := insertCategory(t, db, event2, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
athlete2 := insertAthlete(t, db, "李四", team2)
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
insertChannel(t, db, event2, "athlete")
insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T1" {
t.Fatalf("未配置 tieBreak 时应按总分排序,T1 在前: %+v", snap.Standings)
}
mustExec(t, db, `INSERT INTO settings(key, value) VALUES('standings.tie_break', 'gold')
ON CONFLICT(key) DO UPDATE SET value = 'gold'`)
snap, err = env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T2" {
t.Fatalf("tieBreak=gold 时应按金牌排序,T2 在前: %+v", snap.Standings)
}
}
// 停用但有积分的单位必须像 REST 一样补出编码/名称;排名输入仍只含启用单位。
func TestSnapshotStandingsIncludesInactiveTeamDisplayFields(t *testing.T) {
env := newTestEnv(t)
db := env.db
insertTeam(t, db, "T1", "第一单位", "")
inactive := insertTeam(t, db, "T2", "第二单位", "")
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, inactive)
// 停用且无流水的单位不应出现在榜单里。
idle := insertTeam(t, db, "T3", "第三单位", "")
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, idle)
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete2 := insertAthlete(t, db, "李四", inactive)
entry2 := insertEntry(t, db, event1, cat1, inactive, athlete2)
insertChannel(t, db, event1, "athlete")
result2 := insertResult(t, db, event1, entry2, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, inactive, athlete2, event1, entry2, result2, 5)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
// teams 是公开单位表,与 GET /api/teams 一样含停用单位。
if len(snap.Teams) != 3 {
t.Fatalf("快照单位表应含停用单位,得到 %+v", snap.Teams)
}
byCode := make(map[string]dto.StandingRow, len(snap.Standings))
for _, row := range snap.Standings {
byCode[row.TeamCode] = row
}
row, ok := byCode["T2"]
if !ok {
t.Fatalf("停用但有积分的单位应上榜: %+v", snap.Standings)
}
if row.TeamName != "第二单位" || row.TotalPoints != 5 {
t.Fatalf("停用单位应补出名称与积分: %+v", row)
}
if _, ok := byCode["T3"]; ok {
t.Fatalf("停用且无流水的单位不应上榜: %+v", snap.Standings)
}
if _, ok := byCode["T1"]; !ok {
t.Fatalf("启用单位应上榜: %+v", snap.Standings)
}
}
// 已作废(status=withdrawn)的成绩不得出现在大屏「最新成绩」。
func TestSnapshotLatestResultsExcludesVoided(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
athlete2 := insertAthlete(t, db, "李四", team1)
keep := insertEntry(t, db, event1, cat1, team1, athlete1)
insertResult(t, db, event1, keep, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
// 同一报名项只能有一条成绩,作废行用另一个报名项。
voided := insertEntry(t, db, event1, cat1, team1, athlete2)
voidedResult := insertResult(t, db, event1, voided, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
mustExec(t, db, `UPDATE results SET status = 'withdrawn' WHERE id = ?`, voidedResult)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
for _, group := range snap.LatestResults {
for _, row := range group.Rows {
if row.EntryID == voided {
t.Fatalf("已作废成绩不应出现在最新成绩: %+v", row)
}
}
}
if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 1 ||
snap.LatestResults[0].Rows[0].EntryID != keep {
t.Fatalf("最新成绩应只保留未作废的一条: %+v", snap.LatestResults)
}
}
// 快照的 teams/events 必须用 DTO 序列化:可空字段显式 null、必填字段不因零值消失。
func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) {
env := newTestEnv(t)
db := env.db
insertTeam(t, db, "T1", "第一单位", "")
insertEvent(t, db, "E1", "100米")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
var top struct {
Teams []map[string]json.RawMessage `json:"teams"`
Events []map[string]json.RawMessage `json:"events"`
}
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析快照: %v", err)
}
for _, key := range []string{"id", "code", "name", "shortName", "logoPath", "color", "sortOrder", "active"} {
if _, ok := top.Teams[0][key]; !ok {
t.Fatalf("teams 缺少契约字段 %s: %s", key, top.Teams[0])
}
}
for _, key := range []string{"id", "sportId", "code", "name", "eventType", "resultMode", "resultType",
"compare", "recordEligible", "scored", "multiplier", "status", "scheduledAt", "venue", "sortOrder", "remark"} {
if _, ok := top.Events[0][key]; !ok {
t.Fatalf("events 缺少契约字段 %s: %s", key, top.Events[0])
}
}
}
+42
View File
@@ -278,6 +278,48 @@ func TestExportRoundTripThroughDB(t *testing.T) {
}
}
// TestApplyClearsChannelsForUnscoredEvent 验证「只排名不计分」的项目导入后固定 0 通道(口径 2):
// 否则它会继承模板通道,把名次带进奖牌榜。
func TestApplyClearsChannelsForUnscoredEvent(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
var indID, profileID int64
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_IND'`).Scan(&indID); err != nil {
t.Fatalf("查询个人项目: %v", err)
}
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("查询个人模板: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, indID, profileID); err != nil {
t.Fatalf("插入个人通道: %v", err)
}
if _, err := db.Exec(`UPDATE events SET scored = 0 WHERE id = ?`, indID); err != nil {
t.Fatalf("关闭计分开关: %v", err)
}
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); err != nil {
t.Fatalf("Apply: %v", err)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 0 {
t.Fatalf("不计分项目的通道数 = %d,期望 0", got)
}
}
func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
-49
View File
@@ -31,23 +31,6 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
}
// ProfileFromDomain 把 domain 模板转回规则包类型。
func ProfileFromDomain(p domain.ScoringProfile) Profile {
rules := make([]Rule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, Rule{Rank: r.Rank, Points: r.Points})
}
return Profile{
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: p.TieMode,
RecordBonusMode: p.RecordBonusMode,
Rules: rules,
}
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
@@ -59,15 +42,6 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
}
}
// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。
func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel {
return Channel{
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
}
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
func (p *RulePack) ProfileIndex() map[string]Profile {
out := make(map[string]Profile, len(p.Profiles))
@@ -77,15 +51,6 @@ func (p *RulePack) ProfileIndex() map[string]Profile {
return out
}
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
TieBreak: append([]string(nil), s.TieBreak...),
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
}
// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。
func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
mode := r.Mode
@@ -99,21 +64,7 @@ func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels}
}
// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。
func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus {
levels := make([]RecordLevel, 0, len(c.Levels))
for _, lv := range c.Levels {
levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels}
}
// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。
func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig {
return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired}
}
// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。
func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment {
return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired}
}
+11 -1
View File
@@ -160,7 +160,7 @@ func rulesEqual(a, b []Rule) bool {
// resolveTargets 把规则包展开成「项目编码 → 目标通道」。
// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。
// 没被规则包提到的项目不出现在结果里,表示「保持原样」。
// 「只排名不计分」的项目固定解析成 0 通道;没被规则包提到的计分项目不出现在结果里,表示「保持原样」。
func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) {
overrideByCode := make(map[string]EventOverride, len(next.EventOverrides))
for _, o := range next.EventOverrides {
@@ -169,6 +169,16 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
targets := make(map[string][]Channel)
for _, ev := range cur.Events {
// 只排名不计分的项目固定 0 通道(devdocs 02 §2.4):否则会继承模板通道,
// 把名次带进奖牌榜。override 显式写了 scored:false 时同样清空。
scored := ev.Scored
if ov, ok := overrideByCode[ev.Code]; ok && ov.Scored != nil {
scored = *ov.Scored
}
if !scored {
targets[ev.Code] = nil
continue
}
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
+56
View File
@@ -201,3 +201,59 @@ func TestResolveTargetsPrecedence(t *testing.T) {
t.Errorf("未知项目识别错误: %v", unknown)
}
}
// TestResolveTargetsClearsChannelsForUnscored 验证「只排名不计分」的项目固定解析成 0 通道(口径 2):
// 否则它会继承模板通道,把名次带进奖牌榜。
func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
cur := currentSnapshot(t)
cur.Events = append(cur.Events, EventInfo{
ID: 4, Code: "EVT_NOSCORE", Name: "只排名示例",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
Scored: false, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}},
})
t.Run("继承模板的项目也被清空", func(t *testing.T) {
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
targets, _, err := resolveTargets(cur, next)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
removed := false
for _, c := range cs.Channels {
if c.EventCode == "EVT_NOSCORE" && c.Kind == ChangeRemoved && len(c.After) == 0 {
removed = true
}
}
if !removed {
t.Errorf("差异预览应显示不计分项目的通道被清空: %+v", cs.Channels)
}
})
t.Run("override 写了通道也清空", func(t *testing.T) {
pack := *cur.Pack
pack.EventOverrides = append(pack.EventOverrides, EventOverride{
EventCode: "EVT_NOSCORE",
Scored: BoolPtr(false),
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
})
targets, _, err := resolveTargets(cur, &pack)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
}
})
}
-19
View File
@@ -270,34 +270,15 @@ func TestProfileDomainConversion(t *testing.T) {
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
}
back := ProfileFromDomain(dp)
if !reflect.DeepEqual(back, pack.Profiles[0]) {
t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
}
if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
t.Errorf("通道往返不一致: %+v", got)
}
in := pack.Standings.ToStandingsInput()
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
t.Errorf("榜单口径转换错误: %+v", in)
}
if !reflect.DeepEqual(in.TieBreak, pack.Standings.TieBreak) {
t.Errorf("tie-break 未传入聚合输入: %+v", in.TieBreak)
}
rb := pack.RecordBonus.ToDomain()
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
t.Errorf("破纪录配置转换错误: %+v", rb)
}
if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
+157
View File
@@ -2,8 +2,12 @@ package store
import (
"database/sql"
"io/fs"
"path/filepath"
"sort"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// openEventDB 打开一个临时赛事库;DSN 与 store.Open 保持一致,便于验证外键约束。
@@ -127,6 +131,159 @@ func TestEventSchemaAcceptsCoreFlow(t *testing.T) {
}
}
// 0012 迁移:score_ledger 补 (entry_id, voided) 索引,events 子表外键改为可级联/置空,
// 删除没有任何报名的小项不再被历史流水与纪录挡住。
func TestMigrateEventDeleteWithHistory(t *testing.T) {
db := openEventDB(t)
if err := MigrateEvent(db); err != nil {
t.Fatalf("迁移: %v", err)
}
assertIndex(t, db, "score_ledger", "idx_score_ledger_entry_voided")
mustExec(t, db, `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`)
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`)
mustExec(t, db, `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`)
mustExec(t, db, `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 1, 'adjustment', 1, 5)`)
mustExec(t, db, `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
VALUES (1, 5, 'bonus', '测试', 1)`)
mustExec(t, db, `INSERT INTO records(event_id, level) VALUES (1, 'meet')`)
// 与 repo_events.go 的 deleteEvent 同序:先清通道/组别,再删小项。
mustExec(t, db, `DELETE FROM event_score_channels WHERE event_id = 1`)
mustExec(t, db, `DELETE FROM event_categories WHERE event_id = 1`)
mustExec(t, db, `DELETE FROM events WHERE id = 1`)
// 积分流水与人工调整保留,只把 event_id 置空;纪录随小项删除。
if got := countRows(t, db, "score_ledger"); got != 1 {
t.Errorf("score_ledger 行数 = %d,期望 1", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE event_id IS NOT NULL`); got != 0 {
t.Errorf("score_ledger.event_id 应置空,实际仍有 %d 行", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM point_adjustments WHERE event_id IS NOT NULL`); got != 0 {
t.Errorf("point_adjustments.event_id 应置空,实际仍有 %d 行", got)
}
if got := countRows(t, db, "records"); got != 0 {
t.Errorf("records 应随小项级联删除,实际 %d 行", got)
}
rows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = rows.Close() }()
if rows.Next() {
t.Error("重建后存在外键悬挂")
}
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (999, 'adjustment', 1, 1)`); err == nil {
t.Error("重建后 score_ledger.team_id 外键应仍然生效")
}
}
// 老库升级到 0012 时数据必须原样保留:重建表是「建新表 → 拷数据 → 换名」,不能吞行。
func TestMigration0012PreservesExistingRows(t *testing.T) {
db := openEventDB(t)
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
sort.Strings(names)
for _, name := range names {
if name >= "0012" {
continue
}
body, err := fs.ReadFile(fsys, name)
if err != nil {
t.Fatalf("读取迁移 %s: %v", name, err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("应用迁移 %s: %v", name, err)
}
}
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
VALUES (1, 5, 'bonus', '测试', 1)`,
`INSERT INTO records(event_id, level, holder_name) VALUES (1, 'meet', '张三')`,
`INSERT INTO record_breaks(event_id, entry_id, level) VALUES (1, 1, 'meet')`,
`INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
body, err := fs.ReadFile(fsys, "0012_ledger_entry_index_and_fk.sql")
if err != nil {
t.Fatalf("读取 0012: %v", err)
}
tx, err := db.Begin()
if err != nil {
t.Fatalf("开启事务: %v", err)
}
if _, err := tx.Exec(string(body)); err != nil {
_ = tx.Rollback()
t.Fatalf("应用 0012: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("提交 0012: %v", err)
}
for table, want := range map[string]int{
"score_ledger": 1, "point_adjustments": 1, "records": 1, "record_breaks": 1, "result_revisions": 2,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
if got := queryInt(t, db, `SELECT count(*) FROM result_revisions WHERE parent_id = 1`); got != 1 {
t.Errorf("result_revisions 的 parent_id 未保留,子行数 = %d", got)
}
if got := queryInt(t, db, `SELECT CAST(points AS INTEGER) FROM score_ledger WHERE id = 1`); got != 10 {
t.Errorf("score_ledger 分值 = %d,期望 10", got)
}
}
func assertIndex(t *testing.T, db *sql.DB, table, index string) {
t.Helper()
var n int
if err := db.QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = ? AND tbl_name = ?`,
index, table,
).Scan(&n); err != nil {
t.Fatalf("查询索引 %s: %v", index, err)
}
if n != 1 {
t.Errorf("索引 %s(%s) 不存在", index, table)
}
}
func queryInt(t *testing.T, db *sql.DB, query string, args ...any) int {
t.Helper()
var n int
if err := db.QueryRow(query, args...).Scan(&n); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return n
}
func checkProfile(t *testing.T, db *sql.DB, code, name string, want []float64) {
t.Helper()
+4 -2
View File
@@ -105,10 +105,11 @@ func main() {
os.Exit(1)
} else if created {
// 初始密码只打印这一次;数据库里只有 bcrypt 哈希。
// 结构化日志会被采集/落盘,绝不写入密码,只提示密码已在上方一次性输出。
fmt.Printf("\n========================================\n"+
" 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+
"========================================\n\n", username, password)
logger.Warn("已创建默认管理员", "username", username, "password", password)
logger.Warn("已创建默认管理员,初始密码仅在上方一次性输出,不写入日志", "username", username)
}
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
@@ -202,7 +203,8 @@ func runBackup(args []string) error {
return err
}
defer func() { _ = evDB.Close() }()
if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: *project, Reason: *reason}); err != nil {
// 前缀必须与 Service 一致:PruneBackups 只清理 <prefix>-*,用项目名会让 CLI 备份只增不减。
if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: "event", Reason: *reason}); err != nil {
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 <prefix>-* 清理不到,
// 备份目录会只增不减。
func TestRunBackupUsesEventPrefix(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
projDir := filepath.Join(dataDir, "event_projs", "default")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
// runBackup 只在 event.db 存在时备份赛事库;空库也走同一条 VACUUM INTO 路径。
if err := os.WriteFile(filepath.Join(projDir, "event.db"), nil, 0o644); err != nil {
t.Fatalf("创建赛事库: %v", err)
}
cfgPath := filepath.Join(root, "config.yaml")
cfg := "dataDir: " + dataDir + "\nserver:\n port: 19540\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("写配置: %v", err)
}
if err := runBackup([]string{"-config", cfgPath, "-project", "default", "-reason", "manual"}); err != nil {
t.Fatalf("runBackup: %v", err)
}
entries, err := os.ReadDir(filepath.Join(dataDir, "backups"))
if err != nil {
t.Fatalf("读取备份目录: %v", err)
}
eventBackup := false
for _, e := range entries {
if strings.HasPrefix(e.Name(), "default-") {
t.Errorf("CLI 备份不应使用项目名前缀: %s", e.Name())
}
if strings.HasPrefix(e.Name(), "event-manual-") {
eventBackup = true
}
}
if !eventBackup {
t.Errorf("未生成 event- 前缀的赛事库备份,实际文件: %v", entries)
}
}
@@ -0,0 +1,142 @@
-- P2-8:score_ledger 缺 (entry_id, voided) 索引;events 的子表外键没有 ON DELETE,
-- 导致删除小项时被历史流水/纪录挡住(500)。SQLite 改不了外键,只能重建表。
-- defer_foreign_keys 让重建期间的外键校验推迟到事务提交,避免删旧表时误报。
PRAGMA defer_foreign_keys = ON;
-- score_ledger:指向 events/entries/results 的引用改为「删除时置空」,历史分值不因删项目丢失。
CREATE TABLE score_ledger_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL REFERENCES teams(id),
athlete_id INTEGER REFERENCES athletes(id),
event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')),
source_id INTEGER NOT NULL,
points REAL NOT NULL,
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
voided_by INTEGER,
voided_at TEXT,
void_reason TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO score_ledger_new (id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, voided_by, voided_at,
void_reason, created_by, created_at)
SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, voided_by, voided_at,
void_reason, created_by, created_at
FROM score_ledger;
DROP TABLE score_ledger;
ALTER TABLE score_ledger_new RENAME TO score_ledger;
CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided);
CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided);
CREATE INDEX IF NOT EXISTS idx_score_ledger_entry_voided ON score_ledger(entry_id, voided);
-- point_adjustments:event_id 可空,删除小项时置空,人工调整记录仍保留。
CREATE TABLE point_adjustments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL REFERENCES teams(id),
delta REAL NOT NULL,
category TEXT NOT NULL CHECK (category IN ('penalty', 'bonus', 'correction')),
reason TEXT NOT NULL,
event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
voided_by INTEGER,
voided_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO point_adjustments_new (id, team_id, delta, category, reason, event_id,
voided, voided_by, voided_at, created_by, created_at)
SELECT id, team_id, delta, category, reason, event_id,
voided, voided_by, voided_at, created_by, created_at
FROM point_adjustments;
DROP TABLE point_adjustments;
ALTER TABLE point_adjustments_new RENAME TO point_adjustments;
CREATE INDEX IF NOT EXISTS idx_point_adjustments_team_voided ON point_adjustments(team_id, voided);
-- records:event_id 非空,小项删除后纪录随之删除。
CREATE TABLE records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
result_value REAL,
result_text TEXT,
holder_name TEXT,
holder_team_id INTEGER REFERENCES teams(id),
achieved_at TEXT,
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
source TEXT NOT NULL DEFAULT 'history' CHECK (source IN ('history', 'current')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO records_new (id, event_id, level, result_value, result_text, holder_name,
holder_team_id, achieved_at, is_current, source, created_at)
SELECT id, event_id, level, result_value, result_text, holder_name,
holder_team_id, achieved_at, is_current, source, created_at
FROM records;
DROP TABLE records;
ALTER TABLE records_new RENAME TO records;
CREATE INDEX IF NOT EXISTS idx_records_event_level ON records(event_id, level, is_current);
-- record_breaks:event_id/entry_id 非空,删除小项时级联删除。
CREATE TABLE record_breaks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
result_value REAL,
result_text TEXT,
bonus_points REAL NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'approved', 'rejected')),
approved_by INTEGER,
approved_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO record_breaks_new (id, event_id, entry_id, level, result_value, result_text,
bonus_points, state, approved_by, approved_at, created_at)
SELECT id, event_id, entry_id, level, result_value, result_text,
bonus_points, state, approved_by, approved_at, created_at
FROM record_breaks;
DROP TABLE record_breaks;
ALTER TABLE record_breaks_new RENAME TO record_breaks;
CREATE INDEX IF NOT EXISTS idx_record_breaks_event_id ON record_breaks(event_id, state);
-- result_revisions:event_id 非空,小项删除后版本图谱随之删除。
CREATE TABLE result_revisions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES result_revisions_new(id),
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
action TEXT NOT NULL CHECK (action IN ('publish', 'unpublish', 'edit')),
snapshot_json TEXT NOT NULL,
ledger_ids TEXT NOT NULL DEFAULT '[]',
actor_id INTEGER,
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO result_revisions_new (id, parent_id, event_id, action, snapshot_json,
ledger_ids, actor_id, message, created_at)
SELECT id, parent_id, event_id, action, snapshot_json,
ledger_ids, actor_id, message, created_at
FROM result_revisions;
DROP TABLE result_revisions;
ALTER TABLE result_revisions_new RENAME TO result_revisions;
CREATE INDEX IF NOT EXISTS idx_result_revisions_event_id ON result_revisions(event_id, created_at);