Files
Scoreboard/server/internal/httpapi/results_clear_test.go
T
AskaEth 72362239c6 fix(entry): 回到录入页时记住上次的小项(按 id);feat: 新增「清空本组 / 本批成绩」;版本 dev_0.5.12
## 一、修「改小项显示名称后,撤销发布按钮消失」

**根因不是改名字。** 我一开始怀疑「某处按 name 关联」,验下来那条路是干净的:
加载录入页报名行的查询、成绩查询、引擎读数据全是按 event_id / category_id / code 关联的
(并补了一条「小项名 + 组别名一起改」的回归用例,证明录入页状态 / 流水 / 榜单逐字节不变)。

真机制:**进入录入页时不记得上次录的是哪个小项**,兜底挑「第一个**待录入**(pending)的小项」。
而**刚发布完的小项状态已经是 published**,恰好被这条兜底排除 → 回去时右侧换成了**另一个小项**,
而「撤销发布」按钮只按「当前表里有没有已发布的行」显示,自然就不在。

⇒ 现场看到的「左栏仍显示已发布、单位成绩仍有加分、但按钮没了」**三条本来不矛盾 ——
它们说的是两个不同的小项**。

**修法**:`pickInitialEventId()` 记住上次录入的小项(**按 id** 存 localStorage),
优先级 = URL 查询参数 → 上次录入的(**不排除已发布**)→ 第一个 pending → 列表第一个;
记忆失效(小项被删、隐私模式读不到存储)一律退回原兜底,不把人挡在录入页外。
**纯前端,零数据迁移。**

真机复现与复验(CDP 驱动真实浏览器):
- 修前:「离开再回来 → 换成了别的小项、没有撤销发布按钮」;
- 修后:「离开再回来 → 仍是那个小项、按钮在」。

## 二、新增「清空本组 / 本批成绩」(危险操作)

`POST /api/results/clear`(recorder 及以上 / CSRF / 幂等):
- `heat` **省略 = 清整组**、数字 = 该批次、显式 `null` = 只清未分批的行(三种要能分清);
- `confirm` 必须为 `true`(**服务端兜底**「没勾选不让提交」,不只是前端拦);
- **不提供「清空整个小项」** —— 爆炸半径太大;
- 语义 = **先撤销发布 + 再清内容**:成绩行整行归零 → **清整组时一并清掉「团体名次」**
  (否则会出现「清空了、榜上还有分」)→ 作废旧流水并按剩余已发布内容重写 →
  **小项状态对称退回**(只在原为 published 时降级,正好避免「左栏写着已发布却没有已发布成绩」)
  → 写审计 + 推榜。

界面:工具栏「清空本组」常驻;「清空本批」只在筛了批次时出现(没筛批次时它等于清整组,不重复给);
两粒都是危险色,示例数据模式 / 访客只读时禁用并说明原因。

**与「撤销发布」的区别**(界面上都写清了,很容易混):
- **撤销发布**:成绩内容**保留**,只把分收回;
- **清空**:成绩内容**一起清掉**,分也收回。

## 三、顺带:按名字关联的同类扫描

扫了 13 处「按名字关联」的地方并逐条判定,结论:
**数据关联一律按 id/code**;只有当「人写的名字要在表里对齐」时才按名字
(导入报名表里的组别名、Excel 工作表名、缺证件号时的同名兜底)——
那几处是**有意为之**、报错也能照着改,**未改**。

## 四、验证

- 全量自检两轮**全绿、退出码 0**:服务端 14 包 / 前端 **974 例** 0 fail / Console 96 例 0 fail。
- 新增用例:`event_rename_publish_test.go`(改名后状态/流水/榜单逐字节不变)、
  `entry-initial-event.test.ts`(8 例)、`results_clear_test.go`、`entry-clear.test.ts`。

## 版本

dev_0.5.11 → dev_0.5.12(无数据迁移)。
2026-09-17 01:14:18 +08:00

307 lines
14 KiB
Go

package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// clearResults 走真实接口清空指定范围,返回响应体。
func clearResults(t *testing.T, env *testEnv, client *http.Client, body map[string]any, wantStatus int) map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
if status != wantStatus {
t.Fatalf("清空应 %d,得到 %d %+v", wantStatus, status, env2.Error)
}
if wantStatus != http.StatusOK {
return nil
}
return decodeData[map[string]any](t, env2)
}
// validLedgerPoints 数该小项当前有效流水的合计分。
func validLedgerPoints(t *testing.T, env *testEnv, eventID int64) int {
t.Helper()
return countRows(t, env,
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger
WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, eventID)
}
// 清空本组:已发布的分被收回(榜单分数下降)、成绩内容回到「没录过」、写审计与变更历史。
//
// 这是主人点名的危险操作入口:撤销发布保留成绩内容,清空连内容一起清掉,两者不能混为一谈。
func TestClearGroupResultsWipesScoresAndContent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "clear-group",
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"},
)
// 前置:榜上确有名次分(前两名 10 + 8 = 18),且两条都是已发布。
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("前置:A 队应有 10 分,得到 %v", got)
}
if got := validLedgerPoints(t, env, ids.event); got != 18 {
t.Fatalf("前置:有效流水应合计 18 分,得到 %d", got)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
body := clearResults(t, env, client, map[string]any{
"eventId": ids.event, "categoryId": ids.category, "confirm": true, "reason": "现场重赛",
}, http.StatusOK)
// 回执要说清清了几条、其中几条原先已发布、作废了几条流水。
if body["scope"] != "group" || body["cleared"] != float64(2) || body["published"] != float64(2) {
t.Fatalf("回执不对: %+v", body)
}
if body["voidedLedger"] != float64(2) {
t.Fatalf("应作废 2 条流水(两条名次分各一条),得到 %v", body["voidedLedger"])
}
// ① 分数收回:榜单回到 0,有效流水一条不剩,作废行保留(审计痕迹)。
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("清空后 A 队应 0 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("清空后 B 队应 0 分,得到 %v", got)
}
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 1`, 2, ids.event)
// ② 成绩内容清空(回到「没录过」):名次 / 成绩值 / 文本 / 状态 / 备注全清,state 退回 draft。
assertCount(t, env,
`SELECT COUNT(*) FROM results
WHERE event_id = ? AND state = 'draft' AND rank IS NULL AND result_value IS NULL
AND result_text IS NULL AND unit IS NULL AND note IS NULL AND status = 'ok'`, 2, ids.event)
// 录名次的那一列也要跟着走:清空后不该还留着旧名次。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event)
// ③ 录入页读到的这一组不再有「已发布」的行(按钮与锁定都据此判断)。
board := entryStates(t, env, client, ids.event)
if board[ids.entryA] != "draft" || board[ids.entryB] != "draft" {
t.Fatalf("清空后成绩状态应是 draft,得到 %v", board)
}
// ④ 小项进度跟着退回:左栏不能一边写着「已发布」、一边一条已发布成绩都没有。
var status string
if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil {
t.Fatalf("读小项状态: %v", err)
}
if status == "published" {
t.Fatalf("清空后小项状态不该还是 published(左栏会显示「已发布」却没有已发布成绩)")
}
// ⑤ 审计:谁在什么时候清空了哪一组,连带条数。
var action, payload string
if err := env.ev.QueryRow(
`SELECT action, after_json FROM audit_logs WHERE action = 'result.clear'`).Scan(&action, &payload); err != nil {
t.Fatalf("应写一条 result.clear 审计: %v", err)
}
for _, want := range []string{`"categoryId":`, `"scope":"group"`, `"cleared":2`, `"published":2`, `现场重赛`} {
if !strings.Contains(payload, want) {
t.Fatalf("审计载荷应含 %s,得到 %s", want, payload)
}
}
// ⑥ 变更历史:清空按「撤销发布」记一条,message 里写明清了多少条。
var message string
if err := env.ev.QueryRow(
`SELECT message FROM result_revisions WHERE event_id = ? AND action = 'unpublish'
ORDER BY id DESC LIMIT 1`, ids.event).Scan(&message); err != nil {
t.Fatalf("应写一条 unpublish 修订: %v", err)
}
if !strings.Contains(message, "清空本组成绩:2 条") {
t.Fatalf("修订文案应写明清空条数,得到 %q", message)
}
}
// 清空本批:只清当前批次,别的批次的已发布成绩与分数原样保留。
func TestClearBatchKeepsOtherBatches(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 6)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第 1 批:前 3 条;第 2 批:后 3 条。两批都录成绩并发布。
for i, entryID := range entries {
heat := int64(1)
if i >= 3 {
heat = 2
}
if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{entryID}, "heat": heat,
}, true); status != http.StatusOK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error)
}
}
times := make([]float64, 6)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 6, 0, times),
}, true); status != http.StatusOK {
t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
// 前置:6 条已发布,前六名 10+8+6+3+2+1 = 30 分。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 6, ids.event)
if got := validLedgerPoints(t, env, ids.event); got != 30 {
t.Fatalf("前置:有效流水应 30 分,得到 %d", got)
}
body := clearResults(t, env, client, map[string]any{
"eventId": ids.event, "categoryId": ids.category, "heat": 1, "confirm": true,
}, http.StatusOK)
if body["scope"] != "batch" || body["cleared"] != float64(3) || body["published"] != float64(3) {
t.Fatalf("回执不对: %+v", body)
}
// 第 1 批清零,第 2 批原样还在。
for i := 0; i < 3; i++ {
var state string
if err := env.ev.QueryRow(`SELECT state FROM results WHERE entry_id = ?`, entries[i]).Scan(&state); err != nil {
t.Fatalf("读第 1 批状态: %v", err)
}
if state != "draft" {
t.Fatalf("第 1 批第 %d 条应退回草稿,得到 %s", i+1, state)
}
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 3, ids.event)
// 剩下第 2 批三个人:第 1、2、3 名 → 10 + 8 + 6 = 24 分(名次整体往前挪,与「只发布过这三条」一致)。
if got := validLedgerPoints(t, env, ids.event); got != 24 {
t.Fatalf("清掉一批后有效流水应 24 分,得到 %d", got)
}
// 榜单分数必须真的下降:清空前 30 分,清掉一批后只剩第 2 批那三个人(10+8+6=24)。
if got := teamPoints(t, env, client, ids.teamA) + teamPoints(t, env, client, ids.teamB); got != 24 {
t.Fatalf("清批后两单位合计应 24 分,得到 %v", got)
}
}
// 没勾选确认不许提交:服务端也拦(前端禁用确认键只是界面防呆)。
func TestClearRequiresConfirmCheckbox(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "clear-confirm",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
for _, body := range []map[string]any{
{"eventId": ids.event, "categoryId": ids.category},
{"eventId": ids.event, "categoryId": ids.category, "confirm": false},
} {
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
if status != http.StatusBadRequest {
t.Fatalf("没勾选确认应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "勾选") {
t.Fatalf("400 应说清要先勾选,得到 %+v", env2.Error)
}
}
// 一条都不能被动过。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
if got := validLedgerPoints(t, env, ids.event); got != 10 {
t.Fatalf("被拦下的请求不该动流水,得到 %d", got)
}
}
// 清空要按「小项 + 组别」为界:缺 categoryId 一律 400(没有「一键清空整项」这条危险路)。
func TestClearRequiresCategory(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, body := range []map[string]any{
{"eventId": ids.event, "confirm": true},
{"eventId": ids.event, "categoryId": 0, "confirm": true},
} {
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true)
if status != http.StatusBadRequest {
t.Fatalf("缺 categoryId 应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "categoryId") {
t.Fatalf("400 应点名 categoryId,得到 %+v", env2.Error)
}
}
}
// 空范围给明确回答,而不是静默「清空 0 条」——操作员多半看错了批次 / 组别。
func TestClearEmptyScopeIsRejected(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPost, "/api/results/clear", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("没有成绩时清空应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "没有成绩") {
t.Fatalf("400 应说清没有成绩可清,得到 %+v", env2.Error)
}
}
// 权限与录成绩同档:访客 403,recorder 放行。
func TestClearNeedsRecorder(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "clear-perm",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
guest, _ := env.client()
status, _ := env.do(guest, http.MethodPost, "/api/results/clear", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
}, false)
if status != http.StatusForbidden && status != http.StatusUnauthorized {
t.Fatalf("访客清空应被挡下(401/403),得到 %d", status)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
}
// 清空整个组别时,连带清掉「按单位录入的团体名次」:否则那一批分没有成绩行,
// 重算会原样再写一遍——现场就变成「清空了榜上却还有分」。
func TestClearGroupAlsoClearsTeamManualRanks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 换成「团体 + 手工名次」通道:团体名次走 channel_manual_ranks。
if _, err := env.ev.Exec(
`DELETE FROM event_score_channels WHERE event_id = ?`, ids.event); err != nil {
t.Fatalf("清通道: %v", err)
}
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读团体模板: %v", err)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 0)`, ids.event, profileID)
// 录两个单位的名次 → 发布 → 榜上每个单位各得名次分。
if status, e := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{
{"teamId": ids.teamA, "rank": 1}, {"teamId": ids.teamB, "rank": 2},
}}, true); status != http.StatusOK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, e.Error)
}
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
// 前置:纯团体名次项目,报名项上没有成绩,分全挂在单位上(15 + 10)。
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("前置:A 队应 15 分,得到 %v", got)
}
body := clearResults(t, env, client, map[string]any{
"eventId": ids.event, "categoryId": ids.category, "confirm": true,
}, http.StatusOK)
if body["teamRanks"] != float64(2) {
t.Fatalf("应清掉 2 条团体名次,得到 %v", body["teamRanks"])
}
assertCount(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, 0, ids.event)
// 榜上的分必须真的归零——这正是「清空」与「撤销发布」现场差别最大的地方。
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("清空后 A 队应 0 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("清空后 B 队应 0 分,得到 %v", got)
}
}