package httpapi_test import ( "database/sql" "fmt" "net/http" "reflect" "testing" ) // setLiveOnPublish 直接改赛事库设置,等价于管理端 PUT /api/settings。 // 走库而不是接口是为了让用例只关心结算行为,设置写入由 TestLiveOnPublishSwitchPersists 单独覆盖。 func setLiveOnPublish(t *testing.T, env *testEnv, value bool) { t.Helper() env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.live_on_publish', ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, map[bool]string{true: "true", false: "false"}[value]) } type settlementStatus struct { LiveOnPublish bool `json:"liveOnPublish"` Groups []struct { EventID int64 `json:"eventId"` CategoryID int64 `json:"categoryId"` CategoryName string `json:"categoryName"` Required int `json:"required"` Published int `json:"published"` } `json:"groups"` } func readSettlement(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) settlementStatus { t.Helper() status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/results/settlement?eventId=%d&categoryId=%d", eventID, categoryID), nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读结算状态应 200,得到 %d %+v", status, resp.Error) } return decodeData[settlementStatus](t, resp) } func activeLedgerRows(t *testing.T, env *testEnv, eventID int64) int { t.Helper() var n int if err := env.ev.QueryRow( `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID).Scan(&n); err != nil { t.Fatalf("统计流水: %v", err) } return n } func standingsTotal(t *testing.T, env *testEnv, client *http.Client) float64 { t.Helper() _, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) rows := decodeData[[]map[string]any](t, resp) var total float64 for _, row := range rows { total += row["totalPoints"].(float64) } return total } // rankOrZero 读名次:未发布的成绩没有名次(NULL),这里折算成 0,方便分批快照比较。 func rankOrZero(t *testing.T, env *testEnv, entryID int64) int { t.Helper() var rank sql.NullInt64 if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil { t.Fatalf("读取名次: %v", err) } if !rank.Valid { return 0 } return int(rank.Int64) } func changeLogCountOf(t *testing.T, env *testEnv, entityType string) int { t.Helper() var n int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ?`, entityType).Scan(&n); err != nil { t.Fatalf("统计 change_log: %v", err) } return n } // 默认(设置键不存在)必须是历史行为:分批发一批,流水与榜当场就变。 func TestLiveOnPublishDefaultIsRealTime(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") times := make([]float64, 8) for i := range times { times[i] = 12.0 + float64(i)*0.1 } env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times), }, true) status, resp := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) } // 4 条已发布 = 4 条流水;前四名 10+8+6+3 = 27 分上榜。 assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event) if got := standingsTotal(t, env, client); got != 27 { t.Fatalf("默认设置下发布一批后榜单总分 = %v,期望 27", got) } if got := changeLogCountOf(t, env, "standings"); got != 1 { t.Fatalf("默认设置下发布应推 1 次榜单,得到 %d", got) } if got := rankOfEntry(t, env, entries[0]); got != 1 { t.Fatalf("第一名应第 1 名,得到 %d", got) } } // 开关默认(键不存在)与显式 true 必须逐字节一致: // 这是「加开关不能悄悄改变现场既有行为」的证明。 func TestLiveOnPublishDefaultMatchesExplicitTrue(t *testing.T) { // snapshot 是一次发布之后的可比状态:名次、流水、榜单原始字节、榜单推送次数。 type snapshot struct { ranks []int ledger []string standings string pushes int } run := func(t *testing.T, value *bool) (afterFirst, afterSecond snapshot) { t.Helper() env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) if value != nil { setLiveOnPublish(t, env, *value) } env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") read := func() snapshot { t.Helper() var snap snapshot for _, id := range entries { snap.ranks = append(snap.ranks, rankOrZero(t, env, id)) } rows, err := env.ev.Query( `SELECT entry_id, team_id, points FROM score_ledger WHERE event_id = ? AND voided = 0 ORDER BY entry_id, team_id`, ids.event) if err != nil { t.Fatalf("读流水: %v", err) } defer func() { _ = rows.Close() }() for rows.Next() { var entryID, teamID int64 var points float64 if err := rows.Scan(&entryID, &teamID, &points); err != nil { t.Fatalf("扫描流水: %v", err) } snap.ledger = append(snap.ledger, fmt.Sprintf("%d/%d/%v", entryID, teamID, points)) } if err := rows.Err(); err != nil { t.Fatalf("遍历流水: %v", err) } snap.standings = rawData(t, env, client, "/api/standings") snap.pushes = changeLogCountOf(t, env, "standings") return snap } slow := make([]float64, 8) fast := make([]float64, 8) for i := range slow { slow[i] = 12.0 + float64(i)*0.1 fast[i] = 10.0 + float64(i)*0.1 } env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow), }, true) env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) afterFirst = read() env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast), }, true) env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) afterSecond = read() return afterFirst, afterSecond } assertSame := func(t *testing.T, label string, absent, explicit snapshot) { t.Helper() if !reflect.DeepEqual(absent.ranks, explicit.ranks) { t.Fatalf("%s:名次不一致:默认 %v vs 显式 true %v", label, absent.ranks, explicit.ranks) } if !reflect.DeepEqual(absent.ledger, explicit.ledger) { t.Fatalf("%s:流水不一致:默认 %v vs 显式 true %v", label, absent.ledger, explicit.ledger) } if absent.standings != explicit.standings { t.Fatalf("%s:榜单字节不一致:\n默认 %s\n显式 %s", label, absent.standings, explicit.standings) } if absent.pushes != explicit.pushes { t.Fatalf("%s:榜单推送次数不一致:默认 %d vs 显式 true %d", label, absent.pushes, explicit.pushes) } } absentFirst, absentSecond := run(t, nil) explicit := true trueFirst, trueSecond := run(t, &explicit) assertSame(t, "第一批", absentFirst, trueFirst) assertSame(t, "第二批", absentSecond, trueSecond) // 反向证明这份快照确实受开关控制,否则上面的「一致」可能只是两条路径都坏了。 // 关掉开关后第一批只发 4/8:名次照写(延迟结算只省流水与榜单),流水必须为空、榜单必须不动。 off := false offFirst, _ := run(t, &off) if len(offFirst.ledger) != 0 { t.Fatalf("关闭开关后第一批不该写流水,得到 %v", offFirst.ledger) } if offFirst.pushes != 0 { t.Fatalf("关闭开关后第一批不该推榜单,得到 %d 次", offFirst.pushes) } if offFirst.standings == absentFirst.standings { t.Fatalf("关闭开关后榜单应与默认不同,得到同一份:%s", offFirst.standings) } } // 延迟结算:第一批只落成绩,名次 / 流水 / 榜单都不动; // 第二批录完自动结算,整组重算并把名次与榜单补上。 func TestDeferredSettlementWaitsForWholeGroup(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) setLiveOnPublish(t, env, false) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") slow := make([]float64, 8) fast := make([]float64, 8) for i := range slow { slow[i] = 12.0 + float64(i)*0.1 fast[i] = 10.0 + float64(i)*0.1 } // 发布之前的榜单原始字节:延迟结算下这批发布不允许改动它(含奖牌列)。 beforeStandings := rawData(t, env, client, "/api/standings") env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow), }, true) if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK || !e.OK { t.Fatalf("第一批发布应 200,得到 %d %+v", status, e.Error) } // 成绩要上屏(可以看到时间),但名次 / 流水 / 榜单都要等结算。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 4, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`, 0) // 名次不写:奖牌榜统计读的就是 results.rank,分批写名次会让奖牌列跟总分对不上。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event) if got := rawData(t, env, client, "/api/standings"); got != beforeStandings { t.Fatalf("延迟结算下榜单不该动:\n发布前 %s\n发布后 %s", beforeStandings, got) } // 未结算清单要能查到这一组。 first := readSettlement(t, env, client, ids.event, ids.category) if first.LiveOnPublish { t.Fatalf("设置已关闭,接口应回报 liveOnPublish=false: %+v", first) } if len(first.Groups) != 1 || first.Groups[0].Required != 8 || first.Groups[0].Published != 4 { t.Fatalf("未结算清单应含 4/8 的该组,得到 %+v", first.Groups) } // 第二批录完 → 自动结算:整组重算,流水齐全,榜单正确。 env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast), }, true) if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK || !e.OK { t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event) // 前六名得分 10+8+6+3+2+1 = 30,共 6 条流水。 assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) if got := standingsTotal(t, env, client); got != 30 { t.Fatalf("结算后榜单总分 = %v,期望 30", got) } if got := changeLogCountOf(t, env, "standings"); got != 1 { t.Fatalf("结算应推 1 次榜单,得到 %d", got) } // 整组重算的证明:第二批更快,第一批第一名退到第 5 名。 if got := rankOfEntry(t, env, entries[4]); got != 1 { t.Fatalf("第二批最快者应第 1 名,得到 %d", got) } if got := rankOfEntry(t, env, entries[0]); got != 5 { t.Fatalf("第一批第一名应退到第 5 名,得到 %d", got) } if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 { t.Fatalf("结算后未结算清单应为空,得到 %+v", got.Groups) } } // 有人弃权(scratched)时不该再等他:7 人录完 + 1 人退赛也算全组录完,自动结算。 func TestDeferredSettlementTreatsScratchedAsFinished(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) setLiveOnPublish(t, env, false) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") // 第 8 人退赛(与前端「批量退赛」同一条接口)。 if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ "action": "scratch", "ids": []int64{entries[7]}, }, true); status != http.StatusOK || !e.OK { t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error) } times := make([]float64, 8) for i := range times { times[i] = 12.0 + float64(i)*0.1 } env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times), }, true) if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK || !e.OK { t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) if got := standingsTotal(t, env, client); got != 30 { t.Fatalf("退赛者不该阻塞结算,榜单总分 = %v,期望 30", got) } if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 { t.Fatalf("全组(含退赛)录完后清单应为空,得到 %+v", got.Groups) } } // 发布之后才标退赛:整组因此录完,退赛动作本身也要把这组结算掉,不能停在空流水上。 func TestScratchAfterDeferredPublishSettlesGroup(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) setLiveOnPublish(t, env, false) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") times := make([]float64, 8) for i := range times { times[i] = 12.0 + float64(i)*0.1 } env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times), }, true) env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ "action": "scratch", "ids": []int64{entries[7]}, }, true); status != http.StatusOK || !e.OK { t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) if got := standingsTotal(t, env, client); got != 30 { t.Fatalf("退赛补全整组后应结算,榜单总分 = %v,期望 30", got) } } // 立即结算是延迟结算的人工兜底:按当前已发布成绩结算,并写审计。 func TestManualSettleUsesPublishedResults(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 8) setLiveOnPublish(t, env, false) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") times := make([]float64, 8) for i := range times { times[i] = 12.0 + float64(i)*0.1 } env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times), }, true) env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "requestId": "settle-1", }, true); status != http.StatusOK || !e.OK { t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error) } // 只有 4 条已发布 → 前四名 10+8+6+3 = 27 分。 assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event) if got := standingsTotal(t, env, client); got != 27 { t.Fatalf("立即结算后榜单总分 = %v,期望 27", got) } waitAudit(t, env, "result.settle", ids.event) // 未登录不能结算。 guest, _ := env.client() if status, _ := env.do(guest, http.MethodPost, "/api/results/settle", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, false); status != http.StatusUnauthorized { t.Fatalf("未登录结算应 401,得到 %d", status) } } // 开关键写入 / 读出:PUT /api/settings 落库后 GET 要能读回,且缺键默认 true。 func TestLiveOnPublishSwitchPersists(t *testing.T) { env := newTestEnv(t) env.seed(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") read := func() bool { t.Helper() _, resp := env.do(client, http.MethodGet, "/api/settings", nil, false) settings := decodeData[map[string]any](t, resp) return settings["standings"].(map[string]any)["liveOnPublish"].(bool) } if !read() { t.Fatalf("缺键时 liveOnPublish 应默认 true") } if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "standings": map[string]any{"liveOnPublish": false}, }, true); status != http.StatusOK || !e.OK { t.Fatalf("关闭开关应 200,得到 %d %+v", status, e.Error) } if read() { t.Fatalf("关闭后 liveOnPublish 应读回 false") } // 只改一个字段不能把榜单口径的其余取值抹掉。 _, resp := env.do(client, http.MethodGet, "/api/settings", nil, false) standings := decodeData[map[string]any](t, resp)["standings"].(map[string]any) if standings["individualToTeamWeight"].(float64) != 1 || standings["rankingMode"] != "points" { t.Fatalf("其余榜单口径不应被改动: %+v", standings) } if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "standings": map[string]any{"liveOnPublish": true}, }, true); status != http.StatusOK || !e.OK { t.Fatalf("重新打开应 200,得到 %d %+v", status, e.Error) } if !read() { t.Fatalf("重新打开后 liveOnPublish 应读回 true") } }