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) } } }