## 算分正确性(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 的双通道形态按要求改变。
234 lines
10 KiB
Go
234 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|