Files
Scoreboard/server/internal/httpapi/breakdown_revisions_test.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

234 lines
10 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"
)
// breakdownItems 读某单位的积分明细行。
func breakdownItems(t *testing.T, env *testEnv, client *http.Client, teamID int64) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", teamID), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
}
rows := decodeData[map[string]any](t, env2)["items"].([]any)
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
t.Fatalf("明细行不是对象: %+v", row)
}
out = append(out, item)
}
return out
}
// publishTrailResults 走真实的草稿 + 发布接口发布一批成绩(每次换一个 requestId,不走幂等缓存)。
func publishTrailResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string,
rows ...map[string]any) {
t.Helper()
if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID, "requestId": tag + "-draft", "results": rows,
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// 「发布 → 裁判发现点错、退回草稿改一遍 → 重新发布」之后,明细要能说清这条成绩被改过、
// 最后一次因为什么改、什么时候 —— 现场观众看到的分要是解释得清的。
//
// 同时钉住「重新发布会产生新流水行」这件事:修订痕迹跟着 (小项, 报名项) 这条线走,
// 换了流水 id 照样追得到。
func TestBreakdownCarriesRevisionTrailAfterRepublish(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
firstLedger := items[0]["ledgerId"]
if got := items[0]["revisionCount"]; got != float64(0) {
t.Fatalf("刚发布、没人改过的成绩 revisionCount 应为 0,得到 %v", got)
}
if items[0]["lastRevisionAt"] != nil || items[0]["lastRevisionReason"] != nil {
t.Fatalf("没改过的成绩不该有修订时间 / 原因: %+v", items[0])
}
// 裁判发现成绩录错:退回草稿(写理由)→ 改成正确值 → 重新发布。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s",
"status": "ok", "note": "成绩录入笔误"})
items = breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
item := items[0]
if item["ledgerId"] == firstLedger {
t.Fatalf("重新发布应作废旧流水、写一条新流水(旧 id %v),得到 %v", firstLedger, item["ledgerId"])
}
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("改过一次的成绩 revisionCount 应为 1,得到 %v(%+v)", got, item)
}
if got := item["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("最后一次修订原因 = %v,期望 成绩录入笔误", got)
}
if at, ok := item["lastRevisionAt"].(string); !ok || at == "" {
t.Fatalf("最后一次修订时间应下发,得到 %v", item["lastRevisionAt"])
}
if got := item["lastRevisedBy"]; got != "recorder" {
t.Fatalf("最后一次修订的操作人 = %v,期望 recorder", got)
}
// 未登录访客(大屏读的就是这个接口)拿得到「被改过、什么时候、为什么」,
// 但拿不到「是谁点错的」—— 那是裁判自己看的账,与 /api/revisions 只对 recorder 开放同一口径。
guest, _ := env.client()
items = breakdownItems(t, env, guest, ids.teamA)
if got := items[0]["revisionCount"]; got != float64(1) {
t.Fatalf("访客也该看到修订次数,得到 %v", got)
}
if got := items[0]["lastRevisionReason"]; got != "成绩录入笔误" {
t.Fatalf("访客也该看到修订原因,得到 %v", got)
}
if got := items[0]["lastRevisedBy"]; got != nil {
t.Fatalf("访客不该拿到操作人姓名,得到 %v", got)
}
}
// 一次都没改过的成绩不能标成「已修订」——这是最要防的误报。
//
// 两次发布之间引擎会重算名次、把 state / verified_at 重写一遍,同小项别人的成绩也在变;
// 这些都不该让一条没动过的成绩挂上角标。
func TestBreakdownKeepsUnchangedResultUnrevised(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"})
// A 单位这条成绩一个字没改,只是把别人的成绩改了一次再发布(名次会跟着重算)。
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryB).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "计时有误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryB, "resultValue": 11.60, "resultText": "11.60", "unit": "s", "status": "ok"})
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 1 {
t.Fatalf("明细行数 = %d,期望 1", len(items))
}
for field, want := range map[string]any{
"revisionCount": float64(0),
"lastRevisionAt": nil,
"lastRevisionReason": nil,
"lastRevisedBy": nil,
} {
if got := items[0][field]; got != want {
t.Fatalf("没改过的成绩 %s 应为 %v,得到 %v(%+v)", field, want, got, items[0])
}
}
}
// 破纪录加分这张流水是站在成绩旁边的另一笔分:它不继承成绩的修订痕迹。
// 挂上去等于对外说「那笔加分被改过」,而现场改的是成绩 —— 那是误报。
func TestBreakdownRecordBonusDoesNotInheritTrail(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")
publishTrailResults(t, env, client, ids.event, "trail-1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
var resultID int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID),
map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK {
t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error)
}
publishTrailResults(t, env, client, ids.event, "trail-2",
map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s", "status": "ok"})
// 同一报名项再挂一条破纪录加分流水(与 writeLedgerTx 产出的形态一致:带 entry_id / result_id)。
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, ?, ?, ?, ?, 'record_bonus', 1, 3, 1)`,
ids.teamA, ids.athleteA, ids.event, ids.entryA, resultID)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2(成绩 + 破纪录加分)", len(items))
}
for _, item := range items {
switch item["sourceType"] {
case "result":
if got := item["revisionCount"]; got != float64(1) {
t.Fatalf("被改过的成绩应报 1 次修订,得到 %v(%+v)", got, item)
}
case "record_bonus":
if got := item["revisionCount"]; got != nil {
t.Fatalf("破纪录加分不继承成绩的修订痕迹,得到 %v(%+v)", got, item)
}
default:
t.Fatalf("意外的来源类型: %+v", item)
}
}
}
// 聚合流水(按队内平均)与人工调整没有「一条成绩」可追:整组字段留空,不许猜一个数字出来。
func TestBreakdownAggregateAndAdjustmentHaveNoTrail(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, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (?, NULL, NULL, NULL, NULL, 'adjustment', 1, 5, 1)`, ids.teamA)
items := breakdownItems(t, env, client, ids.teamA)
if len(items) != 2 {
t.Fatalf("明细行数 = %d,期望 2", len(items))
}
for _, item := range items {
if got := item["revisionCount"]; got != nil {
t.Fatalf("追不出来源的流水 revisionCount 应为 null,得到 %v(%+v)", got, item)
}
}
}