Files
Scoreboard/server/internal/httpapi/scoring_test.go
T
AskaEth 27bddefaad fix: 上线前快修(大屏自愈 / 奖牌口径 / 聚合作废 / 密码可发现性)
- P0 慢客户端关闭码 1008 → 4000:前端把所有 1008 当「登录失效」并永久停止重连,
  大屏被踢后再也不自愈(服务端 markSlow 改用自定义码,前端逻辑不动)
- P1 REST 与 WS 的奖牌榜补 events.scored = 1 过滤,与导出成绩册口径一致
  (否则「只排名不计分」项目只要挂着通道就会在大屏凭空出奖牌)
- P1 聚合流水作废去掉 source_id 子查询:改通道会重建通道 id,旧聚合流水
  永远匹配不上 → 再插一份 → 分数翻倍
- P1 审核页/历史页的折算系数兜底由 ?? 1 改为 >0 ? : 1:/ws 快照对非 admin
  会把 individualToTeamWeight 置 0,recorder 看到的个人折算分全是 0
- P2 删掉 signature.local.txt 后清理残留的 signature.build.txt,避免继续注入旧署名
- 初始管理员密码:banner 加纯 ASCII 锚 INITIAL ADMIN CREATED,文档改为搜它
  (原来教的 findstr "password=" 已经失效,且中文 findstr 本身不可靠)

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 24/24;console 语法通过。
2026-09-09 23:13:20 +08:00

174 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
// 整体替换通道会 DELETE + INSERT 生成新的通道 id,旧聚合流水的 source_id 随之失效。
// 若作废范围仍按 source_id IN (本小项通道) 定位,旧流水作废不掉、重算再插一份 → 分数翻倍。
func TestRecomputeAggregateLedgerSurvivesChannelRebuild(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)
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)
}
// 聚合通道两单位各一条流水:15 + 10。
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("整项发布后成绩流水应为 2 条 25 分,得到 %d 条 %v 分", count, total)
}
// 走真实接口整体替换通道,模拟运维改配置(通道 id 会被重建)。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 0},
}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error)
}
catID := ids.category
status, env2 = env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event, "categoryId": catID}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("按组别重算应 200,得到 %d %+v", status, env2.Error)
}
if count, total := env.resultLedger(t); count != 2 || total != 25 {
t.Fatalf("改通道后按组别重算不得翻倍,应仍为 2 条 25 分,得到 %d 条 %v 分", count, total)
}
}
// 作废一条成绩会改变同组别名次,重算结果必须回写 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)
}
}
}
}