Files
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

180 lines
9.1 KiB
Go

package httpapi_test
import (
"fmt"
"net/http"
"reflect"
"testing"
)
// resultIDOf 取某条报名项当前成绩行的 id。
func resultIDOf(t *testing.T, env *testEnv, entryID int64) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&id); err != nil {
t.Fatalf("读成绩 id: %v", err)
}
return id
}
// 作废 → 重新录入 → 改 → 重新发布:名次、流水与榜单必须与「一开始就填对」逐字节一致(A16)。
//
// 这是主人「手滑填错」场景的验收:填错不是删数据,改完重新发布后,榜上不该留下任何
// 「曾经填错」的痕迹(旧流水要作废、名次要让位、别人的名次要跟着回退)。
func TestReopenResultMatchesCorrectionFromScratch(t *testing.T) {
// ① 现场路径:先按填错的值发布 → 作废这一条 → 重新录入 → 改成正确值 → 重新发布本组。
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "typo",
map[string]any{"entryId": ids.entryA, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
// 填错的是 A 的成绩(11.50 填成了 11.92),改对后 A 该是第一。
// 前置:填错时 B 是第一,A 只有第 2 名。
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); got[ids.entryB] != 1 || got[ids.entryA] != 2 {
t.Fatalf("前置名次不对: %v", got)
}
resultA := resultIDOf(t, env, ids.entryA)
if status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/void", resultA),
map[string]any{"reason": "成绩填错", "requestId": "typo-void"}, true); status != http.StatusOK {
t.Fatalf("作废应 200,得到 %d %+v", status, env2.Error)
}
// 重新录入要推一条 result 变更(大屏据此重拉成绩),change_log.action 只有 upsert/void/replace。
upsertsBefore := changeLogCount(t, env, "result", "upsert")
standingsBefore := changeLogCount(t, env, "standings", "replace")
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "成绩填错,重新录入", "requestId": "typo-reopen"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重新录入应 200,得到 %d %+v", status, env2.Error)
}
reopened := decodeData[map[string]any](t, env2)
if reopened["state"] != "draft" || reopened["status"] != "ok" {
t.Fatalf("重新录入后应是草稿且状态复位为 ok: %+v", reopened)
}
if reopened["rank"] != nil {
t.Fatalf("重新录入后名次应被清空: %+v", reopened)
}
if reopened["note"] != "成绩填错,重新录入" {
t.Fatalf("重新录入理由应写进 note: %+v", reopened)
}
if n := changeLogCount(t, env, "result", "upsert") - upsertsBefore; n != 1 {
t.Fatalf("重新录入应写 1 条 result 变更,得到 %d", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'result.reopen'`); n != 1 {
t.Fatalf("重新录入应写 1 条审计,得到 %d", n)
}
// 退回草稿后这一条立刻不再计分(回滚类动作无条件重算并推榜,与作废同口径):
// 少了这一步,榜上就还挂着一条「已经退回草稿」的成绩。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 0 {
t.Fatalf("退回草稿后该条不该再有有效流水,得到 %d 条", n)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("退回草稿后单位总分应清零,得到 %v", got)
}
if n := changeLogCount(t, env, "standings", "replace") - standingsBefore; n < 1 {
t.Fatalf("重新录入必须推榜(standings 变更),得到 %d 条", n)
}
// 退回草稿的意义就在这里:这一条现在真的能改(已发布行会被 saveDraft 拒绝)。
publishResults(t, env, client, ids.event, "fixed",
map[string]any{"entryId": ids.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"},
)
// ② 对照路径:同样两条成绩,一开始就填对(全新环境,报名项 / 单位 id 与上面逐一对应)。
fresh, freshIDs, freshClient := recorderWithSeed(t)
publishResults(t, fresh, freshClient, freshIDs.event, "correct",
map[string]any{"entryId": freshIDs.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"},
map[string]any{"entryId": freshIDs.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
entries := []int64{ids.entryA, ids.entryB}
if got, want := ranksOf(t, env, entries), ranksOf(t, fresh, entries); !reflect.DeepEqual(got, want) {
t.Fatalf("改错后重新发布的名次应与一开始就填对一致:%v vs %v", got, want)
}
if got, want := ledgerRowsOf(t, env, ids.event, nil), ledgerRowsOf(t, fresh, freshIDs.event, nil); !reflect.DeepEqual(got, want) {
t.Fatalf("改错后重新发布的流水应与一开始就填对一致:\n%v\n%v", got, want)
}
if got, want := rawData(t, env, client, "/api/standings"), rawData(t, fresh, freshClient, "/api/standings"); got != want {
t.Fatalf("改错后重新发布的榜单应与一开始就填对一致:\n%s\n%s", got, want)
}
}
// 直接对一条「已发布」的成绩重新录入(不经作废):它必须立刻从榜上消失。
//
// 现场更常见的路径是先作废再重新录入,但接口本身允许直接重录(只有草稿才拒绝),
// 少了「无条件重算 + 推榜」,这一条就会一边是草稿、一边还在榜上计分。
func TestReopenPublishedResultDropsItFromBoard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "published-then-reopen",
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"},
)
if got := teamPoints(t, env, client, ids.teamA); got == 0 {
t.Fatalf("前置:A 队应有名次分,得到 %v", got)
}
resultA := resultIDOf(t, env, ids.entryA)
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "要改成绩", "requestId": "reopen-published"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重新录入应 200,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 0 {
t.Fatalf("退回草稿后不该还挂着旧流水,得到 %d 条", n)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("退回草稿后 A 队总分应清零,得到 %v", got)
}
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
t.Fatalf("退回草稿后名次应让出来,得到 %v", got)
}
// 让出来的名次立刻归 B。
if got := ranksOf(t, env, []int64{ids.entryB}); got[ids.entryB] != 1 {
t.Fatalf("A 让出的第 1 名应归 B,得到 %v", got)
}
}
// 重新录入的校验:理由必填、非草稿才能退回、登记不存在 404;已是草稿时不许再退(否则会清掉录入员正在改的内容)。
func TestReopenResultValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "reopen-check",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
resultA := resultIDOf(t, env, ids.entryA)
// 理由必填,措辞与作废一致。
status, env2 := env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("空理由应 400,得到 %d %+v", status, env2.Error)
}
// 登记不存在。
if status, env2 = env.do(client, http.MethodPost, "/api/results/999999/reopen",
map[string]any{"reason": "不存在"}, true); status != http.StatusNotFound {
t.Fatalf("成绩不存在应 404,得到 %d %+v", status, env2.Error)
}
// 已是草稿:再退一次只会把录入员正在填的内容清空,必须拒绝。
if status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "第一次", "requestId": "reopen-1"}, true); status != http.StatusOK {
t.Fatalf("第一次重新录入应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA),
map[string]any{"reason": "第二次", "requestId": "reopen-2"}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "该成绩已是草稿,可直接修改" {
t.Fatalf("草稿再退回应 400「该成绩已是草稿,可直接修改」,得到 %d %+v", status, env2.Error)
}
// guest(未登录)不能重新录入。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost,
fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "guest"}, false); status != http.StatusUnauthorized {
t.Fatalf("未登录重新录入应 401,得到 %d", status)
}
}