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 例。
This commit is contained in:
1 parent
6841cf49aa
commit
ed2c0b3f3c
35 files changed
+4003
-115
No files matched your search
@@ -234,6 +234,9 @@ type recordBreakDTO struct {
|
||||
State string `json:"state"`
|
||||
ApprovedBy *int64 `json:"approvedBy"`
|
||||
ApprovedAt *string `json:"approvedAt"`
|
||||
// Note 是驳回 / 作废这条登记时写下的理由(0021 新增的列);从未被驳回的登记(含
|
||||
// 老库里的历史行)为 null——空理由与「没有理由」是两回事,所以这里用指针。
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
type auditLogDTO struct {
|
||||
|
||||
@@ -105,6 +105,21 @@ func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
})
|
||||
}
|
||||
|
||||
// reopenResultHandler 把一条成绩退回草稿,供录入员直接修改后重新发布本组。
|
||||
func (s *server) reopenResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in reopenResultPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
return s.reopenResult(r.Context(), id, in.Reason, middlewareUserID(r))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 积分与排名 ----------
|
||||
|
||||
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
@@ -226,6 +241,53 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques
|
||||
return s.approveRecordBreak(r.Context(), id, middlewareUserID(r))
|
||||
}
|
||||
|
||||
// listRecordBreaksHandler 列出破纪录登记(最新在前),可选按审核状态过滤。
|
||||
// 状态拼错时明确报 400:静默回空列表会让现场以为「登记全没了」。
|
||||
func (s *server) listRecordBreaksHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
||||
switch state {
|
||||
case "", "pending", "approved", "rejected":
|
||||
default:
|
||||
return nil, badRequest("state 只能是 pending/approved/rejected", state)
|
||||
}
|
||||
return s.listRecordBreaks(r.Context(), state)
|
||||
}
|
||||
|
||||
// rejectRecordBreakHandler 驳回 / 作废一条破纪录登记;理由必填并落到 note。
|
||||
func (s *server) rejectRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in rejectRecordBreakPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
return s.rejectRecordBreak(r.Context(), id, middlewareUserID(r), in.Reason)
|
||||
})
|
||||
}
|
||||
|
||||
// createRecordHandler 手工登记一条历史纪录(纪录库的「新增」)。
|
||||
func (s *server) createRecordHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in recordEntryInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
return s.createRecordEntry(r.Context(), in, middlewareUserID(r))
|
||||
})
|
||||
}
|
||||
|
||||
// deleteRecordHandler 删除一条纪录(纪录库的「删除」)。
|
||||
func (s *server) deleteRecordHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteRecordEntry(r.Context(), id, middlewareUserID(r))
|
||||
}
|
||||
|
||||
// ---------- 大屏控制与运维 ----------
|
||||
|
||||
// announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
|
||||
)
|
||||
|
||||
// applyEventMigrationsBefore 把赛事库停在 stopAt 之前:只执行并登记文件名小于它的迁移。
|
||||
// 与 migrations/event 包里的 applyBefore 同口径,只是那份是未导出函数,httpapi 的测试包调不到。
|
||||
func applyEventMigrationsBefore(t *testing.T, db *sql.DB, stopAt string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
t.Fatalf("创建 schema_migrations: %v", err)
|
||||
}
|
||||
fsys, err := fs.Sub(migrations.EventFS, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("定位迁移目录: %v", err)
|
||||
}
|
||||
names, err := fs.Glob(fsys, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("列出迁移: %v", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if n >= stopAt {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, n)
|
||||
if err != nil {
|
||||
t.Fatalf("读取迁移 %s: %v", n, err)
|
||||
}
|
||||
if _, err := db.Exec(string(body)); err != nil {
|
||||
t.Fatalf("应用迁移 %s: %v", n, err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
|
||||
t.Fatalf("登记迁移 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旧版赛事包(不含 record_breaks.note 的旧 event.db)导入后:走 main.go 的同一条升级路径,
|
||||
// 旧数据一行不丢,新接口(审核清单 / 驳回 / 纪录库写入)当场可用。
|
||||
//
|
||||
// 这是主人特别交代的兼容底线:迁移只能做加法,且任何新列都只能在升级之后才被读到。
|
||||
func TestLegacyEventPackUpgradeKeepsDataAndEnablesNewFeatures(t *testing.T) {
|
||||
env := newTestEnvPrepared(t, func(ev *sql.DB) {
|
||||
applyEventMigrationsBefore(t, ev, "0021")
|
||||
|
||||
exec := func(query string, args ...any) int64 {
|
||||
t.Helper()
|
||||
res, err := ev.Exec(query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("造老库数据失败: %v\n%s", err, query)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
|
||||
exec(`INSERT INTO athletes(name, gender, team_id, active) VALUES ('张伟', '男', 1, 1)`)
|
||||
exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
|
||||
exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status)
|
||||
VALUES (1, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 1, 'published')`)
|
||||
exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`)
|
||||
exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`)
|
||||
exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`)
|
||||
exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, status, state)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 'ok', 'published')`)
|
||||
// 批准过的破纪录:纪录库已经是「本届纪录当前、历史纪录让位」的样子。
|
||||
exec(`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
|
||||
VALUES (1, 'meet', 11.20, '11.20', '李四', 0, 'history')`)
|
||||
exec(`INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source)
|
||||
VALUES (1, 'meet', 11.58, '11.58', '张伟', 1, 'current')`)
|
||||
exec(`INSERT INTO record_breaks(event_id, entry_id, level, result_value, result_text, bonus_points, state)
|
||||
VALUES (1, 1, 'meet', 11.58, '11.58', 2, 'approved')`)
|
||||
exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`)
|
||||
exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 'record_bonus', 1, 2)`)
|
||||
exec(`INSERT INTO settings(key, value, updated_at) VALUES ('record_bonus.enabled', 'true', datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||||
exec(`INSERT INTO settings(key, value, updated_at) VALUES ('record_bonus.levels',
|
||||
'[{"code":"meet","name":"赛会纪录","points":2}]', datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||||
}, nil)
|
||||
|
||||
// ① 升级之后老数据一行不少、一行不多。
|
||||
for table, want := range map[string]int{
|
||||
"teams": 1, "athletes": 1, "events": 1, "entries": 1, "results": 1,
|
||||
"records": 2, "record_breaks": 1, "score_ledger": 2,
|
||||
} {
|
||||
if got := countRows(t, env, `SELECT COUNT(*) FROM `+table); got != want {
|
||||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||||
}
|
||||
}
|
||||
var (
|
||||
rank int
|
||||
state string
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
|
||||
t.Fatalf("读老成绩: %v", err)
|
||||
}
|
||||
if rank != 1 || state != "published" {
|
||||
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
|
||||
}
|
||||
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// ② 新接口在升级后的旧库上当场可用:审核清单读得到老登记,note 为 null。
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/record-breaks?state=approved", nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读审核清单应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
breaks := decodeData[[]map[string]any](t, env2)
|
||||
if len(breaks) != 1 || breaks[0]["note"] != nil || breaks[0]["state"] != "approved" {
|
||||
t.Fatalf("老登记应原样出现在清单里且 note 为 null: %+v", breaks)
|
||||
}
|
||||
breakID := int64(breaks[0]["id"].(float64))
|
||||
|
||||
// ③ 驳回这条已批准的登记:加分消失、榜单回退、该档位纪录恢复为上一条历史纪录。
|
||||
if got := teamPoints(t, env, client, 1); got != 12 {
|
||||
t.Fatalf("前置:单位总分应是 10+2,得到 %v", got)
|
||||
}
|
||||
status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
|
||||
map[string]any{"reason": "录像复核未破"}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("驳回老登记应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
rejected := decodeData[map[string]any](t, env2)
|
||||
if rejected["state"] != "rejected" || rejected["note"] != "录像复核未破" {
|
||||
t.Fatalf("驳回后状态与理由不对: %+v", rejected)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
|
||||
WHERE source_type = 'record_bonus' AND voided = 0`); n != 0 {
|
||||
t.Fatalf("驳回后加分流水应作废,仍有 %d 条存活", n)
|
||||
}
|
||||
if got := teamPoints(t, env, client, 1); got != 10 {
|
||||
t.Fatalf("驳回后单位总分应回到 10,得到 %v", got)
|
||||
}
|
||||
var (
|
||||
currentName string
|
||||
currentID int64
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT id, COALESCE(holder_name, '') FROM records
|
||||
WHERE event_id = 1 AND level = 'meet' AND is_current = 1`).
|
||||
Scan(¤tID, ¤tName); err != nil {
|
||||
t.Fatalf("读当前纪录: %v", err)
|
||||
}
|
||||
if currentID != 1 || currentName != "李四" {
|
||||
t.Errorf("驳回后当前纪录应恢复为历史那条(李四),得到 id=%d holder=%q", currentID, currentName)
|
||||
}
|
||||
|
||||
// ④ 纪录库的写入路径在旧库升级后可用:登记一条历史纪录。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": 1, "level": "city", "resultValue": 10.90, "holderName": "王五",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("升级后的旧库登记历史纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["source"] != "history" || created["isCurrent"] != true {
|
||||
t.Fatalf("新登记的历史纪录不对: %+v", created)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = 1 AND level = 'city'`); n != 1 {
|
||||
t.Fatalf("历史纪录应落库,得到 %d 行", n)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,37 @@ type recordBreakInput struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// reopenResultPayload 是 POST /api/results/{id}/reopen 的请求体:
|
||||
// 把一条成绩退回草稿让录入员直接改,理由必填(与作废同口径)。
|
||||
type reopenResultPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// rejectRecordBreakPayload 是 POST /api/record-breaks/{id}/reject 的请求体:
|
||||
// 驳回 / 作废一条破纪录登记,理由必填并落到 record_breaks.note。
|
||||
type rejectRecordBreakPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// recordEntryInput 是 POST /api/records 的请求体:手工登记一条历史纪录。
|
||||
//
|
||||
// source 只接受 history(缺省即 history):source='current' 是「确认破纪录」写出来的产物,
|
||||
// 客户端造不得——那会让一条没人批准过的成绩看起来像是本届刚破的纪录。
|
||||
type recordEntryInput struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
Level string `json:"level"`
|
||||
// ResultValue 与 ResultText 至少给一个:纪录可以只有数值(10.5)或只有文本('10秒5')。
|
||||
ResultValue *float64 `json:"resultValue"`
|
||||
ResultText *string `json:"resultText"`
|
||||
HolderName *string `json:"holderName"`
|
||||
HolderTeamID *int64 `json:"holderTeamId"`
|
||||
AchievedAt *string `json:"achievedAt"`
|
||||
Source string `json:"source"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// displayCommandInput 是大屏控制指令的请求体。
|
||||
type displayCommandInput struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordRow 是纪录库里一行的存储层事实(测试直接读表,不走接口)。
|
||||
type recordRow struct {
|
||||
ID int64
|
||||
IsCurrent bool
|
||||
Source string
|
||||
ResultText string
|
||||
HolderName sql.NullString
|
||||
}
|
||||
|
||||
// recordsOf 读出某小项某档位的全部纪录行(含非当前行),按 id 升序。
|
||||
func recordsOf(t *testing.T, env *testEnv, eventID int64, level string) []recordRow {
|
||||
t.Helper()
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT id, is_current, source, COALESCE(result_text, ''), holder_name
|
||||
FROM records WHERE event_id = ? AND level = ? ORDER BY id`, eventID, level)
|
||||
if err != nil {
|
||||
t.Fatalf("读纪录库: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]recordRow, 0)
|
||||
for rows.Next() {
|
||||
var r recordRow
|
||||
if err := rows.Scan(&r.ID, &r.IsCurrent, &r.Source, &r.ResultText, &r.HolderName); err != nil {
|
||||
t.Fatalf("扫描纪录: %v", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历纪录: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentRecord 取某小项某档位的当前纪录;没有当前纪录时返回 nil。
|
||||
func currentRecord(t *testing.T, env *testEnv, eventID int64, level string) *recordRow {
|
||||
t.Helper()
|
||||
for _, r := range recordsOf(t, env, eventID, level) {
|
||||
if r.IsCurrent {
|
||||
row := r
|
||||
return &row
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBreak 登记一条破纪录并返回它的 id。
|
||||
func createBreak(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entryID int64, level string, value float64) int64 {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
||||
"eventId": ids.event, "entryId": entryID, "level": level,
|
||||
"resultValue": value, "resultText": fmt.Sprintf("%.2f", value),
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||||
}
|
||||
|
||||
// 驳回一条「已批准」的破纪录:加分消失、榜单回退、该档位纪录恢复到上一条(A41)。
|
||||
func TestRejectApprovedRecordBreakRevertsBonusAndRecord(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
enableRecordBonus(t, env)
|
||||
publishResults(t, env, client, ids.event, "reject",
|
||||
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"},
|
||||
)
|
||||
// 先手工登记一条历史纪录:驳回破纪录后要恢复的正是它。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": "meet", "resultValue": 11.20, "resultText": "11.20",
|
||||
"holderName": "李四", "achievedAt": "2019-05-01",
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
historyID := int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||||
|
||||
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
|
||||
if status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("批准破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// 前置:加分 2 分已进榜,纪录库当前行换成了本届纪录(source=current)。
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("前置:批准后应有 2 分加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("前置:单位总分应是 10+2,得到 %v", got)
|
||||
}
|
||||
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.Source != "current" || cur.ID == historyID {
|
||||
t.Fatalf("前置:当前纪录应是批准写下的那条: %+v", cur)
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
|
||||
map[string]any{"reason": "录像复核未破纪录", "requestId": "reject-1"}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
rejected := decodeData[map[string]any](t, env2)
|
||||
if rejected["state"] != "rejected" || rejected["note"] != "录像复核未破纪录" {
|
||||
t.Fatalf("驳回后状态与理由不对: %+v", rejected)
|
||||
}
|
||||
|
||||
// 加分随驳回消失,榜单回到批准前。
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("驳回后加分应清零,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
|
||||
t.Fatalf("驳回后单位总分应回到 10,得到 %v", got)
|
||||
}
|
||||
// 该档位纪录恢复到上一条(历史纪录),本届那条退出当前。
|
||||
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.ID != historyID || cur.Source != "history" {
|
||||
t.Fatalf("驳回后当前纪录应恢复为历史那条 %d: %+v", historyID, cur)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = ? AND level = 'meet' AND is_current = 1`,
|
||||
ids.event); n != 1 {
|
||||
t.Fatalf("同一档位只该有一条当前纪录,得到 %d", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record_break.reject'`); n != 1 {
|
||||
t.Fatalf("驳回应写 1 条审计,得到 %d", n)
|
||||
}
|
||||
|
||||
// 再驳回一次:已经是 rejected,必须拒绝而不是空转。
|
||||
status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
|
||||
map[string]any{"reason": "再驳回", "requestId": "reject-2"}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "该破纪录登记已被驳回/作废" {
|
||||
t.Fatalf("重复驳回应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// 理由必填、登记不存在。
|
||||
if status, _ = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID), map[string]any{"reason": " "}, true); status != http.StatusBadRequest {
|
||||
t.Fatalf("空理由应 400,得到 %d", status)
|
||||
}
|
||||
if status, _ = env.do(client, http.MethodPost, "/api/record-breaks/999999/reject",
|
||||
map[string]any{"reason": "不存在"}, true); status != http.StatusNotFound {
|
||||
t.Fatalf("登记不存在应 404,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 驳回一条 pending 的登记:没有加过分,就不该动榜单与纪录库。
|
||||
func TestRejectPendingRecordBreakLeavesScoresAlone(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
enableRecordBonus(t, env)
|
||||
publishResults(t, env, client, ids.event, "pending-reject",
|
||||
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"},
|
||||
)
|
||||
pointsBefore := teamPoints(t, env, client, ids.teamA)
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
|
||||
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
|
||||
if status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
|
||||
map[string]any{"reason": "现场登记错了"}, true); status != http.StatusOK {
|
||||
t.Fatalf("驳回 pending 登记应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("pending 驳回不该产生加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != pointsBefore {
|
||||
t.Fatalf("pending 驳回不该改动总分:%v → %v", pointsBefore, got)
|
||||
}
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("pending 驳回不该改动榜单:\n%s\n%s", standingsBefore, got)
|
||||
}
|
||||
if rows := recordsOf(t, env, ids.event, "meet"); len(rows) != 0 {
|
||||
t.Fatalf("pending 驳回不该写纪录库,得到 %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新批准一条被驳回的登记:加分回来、理由被清掉、纪录库重新记上本届纪录。
|
||||
func TestReapproveRejectedRecordBreak(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
enableRecordBonus(t, env)
|
||||
publishResults(t, env, client, ids.event, "reapprove",
|
||||
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"},
|
||||
)
|
||||
breakID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
|
||||
if status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", breakID),
|
||||
map[string]any{"reason": "先驳回", "requestId": "reapprove-reject"}, true); status != http.StatusOK {
|
||||
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// 被驳回的登记给不了加分(引擎只认 approved)。
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("驳回后不该有加分,得到 %v", got)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("重新批准应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
approved := decodeData[map[string]any](t, env2)
|
||||
if approved["state"] != "approved" {
|
||||
t.Fatalf("重新批准后状态应为 approved: %+v", approved)
|
||||
}
|
||||
if approved["note"] != nil {
|
||||
t.Fatalf("重新批准应清掉驳回理由,得到 %+v", approved["note"])
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("重新批准后加分应回来,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("重新批准后单位总分应是 12,得到 %v", got)
|
||||
}
|
||||
if cur := currentRecord(t, env, ids.event, "meet"); cur == nil || cur.Source != "current" {
|
||||
t.Fatalf("重新批准后当前纪录应是本届纪录: %+v", cur)
|
||||
}
|
||||
// 已经 approved 的再批准一次仍按老口径拒绝,不能重复加分。
|
||||
status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true)
|
||||
if status != http.StatusConflict || env2.Error == nil || env2.Error.Message != "该破纪录登记已处理" {
|
||||
t.Fatalf("已批准的登记再批准应 409(既有口径),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("重复批准不该重复加分,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 审核清单:按状态过滤、最新在前、老库升级来的行 note 为 null;guest 读不到。
|
||||
func TestListRecordBreaksFilterAndPermission(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "list-breaks",
|
||||
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"},
|
||||
)
|
||||
pendingID := createBreak(t, env, client, ids, ids.entryA, "meet", 11.58)
|
||||
approvedID := createBreak(t, env, client, ids, ids.entryB, "city", 11.92)
|
||||
env.exec(t, `UPDATE record_breaks SET state = 'approved' WHERE id = ?`, approvedID)
|
||||
rejectedID := createBreak(t, env, client, ids, ids.entryA, "province", 11.58)
|
||||
if status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/reject", rejectedID),
|
||||
map[string]any{"reason": "不算"}, true); status != http.StatusOK {
|
||||
t.Fatalf("驳回应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
list := func(query string) []map[string]any {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/record-breaks"+query, nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读审核清单应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return decodeData[[]map[string]any](t, env2)
|
||||
}
|
||||
|
||||
all := list("")
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("不过滤应返回 3 条,得到 %d: %+v", len(all), all)
|
||||
}
|
||||
// 最新在前:id 降序。
|
||||
if int64(all[0]["id"].(float64)) != rejectedID || int64(all[2]["id"].(float64)) != pendingID {
|
||||
t.Fatalf("清单应按 id 降序: %+v", all)
|
||||
}
|
||||
// 老库 / 未驳回的行没有理由,必须是 null(不是空串)。
|
||||
for _, item := range all {
|
||||
note := item["note"]
|
||||
switch int64(item["id"].(float64)) {
|
||||
case rejectedID:
|
||||
if note != "不算" {
|
||||
t.Fatalf("被驳回的行应带上理由: %+v", item)
|
||||
}
|
||||
case pendingID, approvedID:
|
||||
if note != nil {
|
||||
t.Fatalf("没被驳回的行 note 应为 null: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := list("?state=pending"); len(got) != 1 || int64(got[0]["id"].(float64)) != pendingID {
|
||||
t.Fatalf("?state=pending 结果不对: %+v", got)
|
||||
}
|
||||
if got := list("?state=approved"); len(got) != 1 || int64(got[0]["id"].(float64)) != approvedID {
|
||||
t.Fatalf("?state=approved 结果不对: %+v", got)
|
||||
}
|
||||
if got := list("?state=rejected"); len(got) != 1 || int64(got[0]["id"].(float64)) != rejectedID {
|
||||
t.Fatalf("?state=rejected 结果不对: %+v", got)
|
||||
}
|
||||
// 状态拼错要明确报错,不能静默回空列表。
|
||||
if status, env2 := env.do(client, http.MethodGet, "/api/record-breaks?state=bogus", nil, false); status != http.StatusBadRequest {
|
||||
t.Fatalf("非法 state 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// guest 读不到(含未审核的登记与驳回理由)。
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodGet, "/api/record-breaks", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("guest 读审核清单应 403,得到 %d", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordsList 读 GET /api/records(当前纪录清单)。
|
||||
func recordsList(t *testing.T, env *testEnv, client *http.Client, eventID int64) []map[string]any {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, fmt.Sprintf("/api/records?eventId=%d", eventID), nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读纪录库应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return decodeData[[]map[string]any](t, env2)
|
||||
}
|
||||
|
||||
// 手工登记历史纪录:新行成为当前纪录(source=history),同档位旧 current 让位(C11)。
|
||||
func TestCreateHistoryRecordReplacesCurrent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": "meet", "resultValue": 11.20, "resultText": "11.20",
|
||||
"holderName": "李四", "holderTeamId": ids.teamA, "achievedAt": "2019-05-01",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
first := decodeData[map[string]any](t, env2)
|
||||
if first["source"] != "history" || first["isCurrent"] != true {
|
||||
t.Fatalf("新登记的历史纪录应是当前纪录且 source=history: %+v", first)
|
||||
}
|
||||
if first["holderName"] != "李四" || first["achievedAt"] != "2019-05-01" {
|
||||
t.Fatalf("持有者与达成时间没落库: %+v", first)
|
||||
}
|
||||
firstID := int64(first["id"].(float64))
|
||||
|
||||
// 只给文本、不给数值也能录:很多老纪录只留了一句话。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": "meet", "resultText": "10秒4",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("只有成绩文本的历史纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
second := decodeData[map[string]any](t, env2)
|
||||
secondID := int64(second["id"].(float64))
|
||||
|
||||
// 接口读到的当前纪录只有新那条,且 source == history。
|
||||
list := recordsList(t, env, client, ids.event)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("同一档位应只有一条当前纪录,得到 %+v", list)
|
||||
}
|
||||
if list[0]["source"] != "history" || int64(list[0]["id"].(float64)) != secondID {
|
||||
t.Fatalf("当前纪录应是刚登记的那条历史纪录: %+v", list[0])
|
||||
}
|
||||
// 存储层:旧 current 被置 0,但仍留在库里(历史不是删掉,是让位)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE id = ? AND is_current = 0`, firstID); n != 1 {
|
||||
t.Fatalf("旧 current 应被置 0 并保留,得到 %d", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record.create'`); n != 2 {
|
||||
t.Fatalf("每次登记应写 1 条审计,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 登记历史纪录的校验:eventId / level / 成绩值必须有,且不许客户端造 source=current。
|
||||
func TestCreateHistoryRecordValidation(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"缺 eventId", map[string]any{"level": "meet", "resultValue": 11.2}},
|
||||
{"level 非法", map[string]any{"eventId": 1, "level": "world", "resultValue": 11.2}},
|
||||
{"成绩值全缺", map[string]any{"eventId": 1, "level": "meet"}},
|
||||
{"eventId 不存在", map[string]any{"eventId": 999999, "level": "meet", "resultValue": 11.2}},
|
||||
// source=current 是「确认破纪录」的产物,客户端造不得。
|
||||
{"伪造 current", map[string]any{"eventId": 1, "level": "meet", "resultValue": 11.2, "source": "current"}},
|
||||
} {
|
||||
body := tc.body
|
||||
// 让 eventId=1 的用例指向真实小项,同时保留「不存在」用例的 999999。
|
||||
if v, ok := body["eventId"]; ok && v == 1 {
|
||||
body = copyWithEvent(body, ids.event)
|
||||
}
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/records", body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM records`); n != 0 {
|
||||
t.Fatalf("被拒绝的登记不该落库,得到 %d 行", n)
|
||||
}
|
||||
// holderTeamId 不存在要报 400(外键兜底),不是 500。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": "meet", "resultValue": 11.2, "holderTeamId": 999999,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("holderTeamId 不存在应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// guest 不能登记。
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": "meet", "resultValue": 11.2,
|
||||
}, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录登记应 401,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除当前纪录要恢复同档位上一条;删掉最后一条就把该档位留空(C11)。
|
||||
func TestDeleteHistoryRecordRestoresPrevious(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
mk := func(level string, value float64, holder string) int64 {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/records", map[string]any{
|
||||
"eventId": ids.event, "level": level, "resultValue": value, "holderName": holder,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("登记历史纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
return int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||||
}
|
||||
older := mk("meet", 11.40, "老纪录")
|
||||
newer := mk("meet", 11.20, "新纪录")
|
||||
mk("city", 10.90, "另一档位")
|
||||
|
||||
// 删当前纪录:上一条历史纪录回到当前。
|
||||
if status, env2 := env.do(client, http.MethodDelete,
|
||||
fmt.Sprintf("/api/records/%d", newer), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("删除纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
list := recordsList(t, env, client, ids.event)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("删除后应剩 2 条当前纪录(meet 的上一条 + city),得到 %+v", list)
|
||||
}
|
||||
var meetCurrent map[string]any
|
||||
for _, item := range list {
|
||||
if item["level"] == "meet" {
|
||||
meetCurrent = item
|
||||
}
|
||||
}
|
||||
if meetCurrent == nil || int64(meetCurrent["id"].(float64)) != older {
|
||||
t.Fatalf("删除当前纪录后应恢复上一条 %d: %+v", older, meetCurrent)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'record.delete'`); n != 1 {
|
||||
t.Fatalf("删除应写 1 条审计,得到 %d", n)
|
||||
}
|
||||
|
||||
// 再删最后一条:该档位没有当前纪录,接口不再返回它。
|
||||
if status, env2 := env.do(client, http.MethodDelete,
|
||||
fmt.Sprintf("/api/records/%d", older), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("删除最后一条应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
for _, item := range recordsList(t, env, client, ids.event) {
|
||||
if item["level"] == "meet" {
|
||||
t.Fatalf("删空后该档位不该再有当前纪录: %+v", item)
|
||||
}
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE event_id = ?`, ids.event); n != 1 {
|
||||
t.Fatalf("库里应只剩 city 那条,得到 %d", n)
|
||||
}
|
||||
|
||||
// 不存在的 id → 404;guest 不能删。
|
||||
if status, env2 := env.do(client, http.MethodDelete, "/api/records/999999", nil, true); status != http.StatusNotFound {
|
||||
t.Fatalf("删除不存在的纪录应 404,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
guest, _ := env.client()
|
||||
if status, _ := env.do(guest, http.MethodDelete, fmt.Sprintf("/api/records/%d", newer), nil, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("未登录删除应 401,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// copyWithEvent 复制请求体并把 eventId 换成真实小项(校验用例里写死 1 只是占位)。
|
||||
func copyWithEvent(body map[string]any, eventID int64) map[string]any {
|
||||
out := make(map[string]any, len(body))
|
||||
for k, v := range body {
|
||||
out[k] = v
|
||||
}
|
||||
out["eventId"] = eventID
|
||||
return out
|
||||
}
|
||||
@@ -726,10 +726,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
|
||||
// ---------- records ----------
|
||||
|
||||
// recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。
|
||||
// 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。
|
||||
const recordSourceHistory = "history"
|
||||
|
||||
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
|
||||
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||||
holder_name, holder_team_id, achieved_at, is_current, source
|
||||
FROM records WHERE is_current = 1`
|
||||
query := `SELECT ` + recordEntryColumns + ` FROM records WHERE is_current = 1`
|
||||
args := []any{}
|
||||
if eventID > 0 {
|
||||
query += ` AND event_id = ?`
|
||||
@@ -745,19 +747,10 @@ func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryD
|
||||
|
||||
out := make([]recordEntryDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r recordEntryDTO
|
||||
holderName sql.NullString
|
||||
holderTeamID sql.NullInt64
|
||||
achievedAt sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
|
||||
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
|
||||
r, err := scanRecordEntry(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
r.HolderName = strPtr(holderName)
|
||||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||||
r.AchievedAt = strPtr(achievedAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -832,30 +825,70 @@ func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInp
|
||||
}
|
||||
|
||||
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
value sql.NullFloat64
|
||||
text sql.NullString
|
||||
approvedBy sql.NullInt64
|
||||
approvedAt sql.NullString
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
|
||||
FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
|
||||
b, err := scanRecordBreak(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// recordBreakColumns 是 recordBreakDTO 的列清单;note 来自迁移 0021(可空)。
|
||||
const recordBreakColumns = `id, event_id, entry_id, level, result_value, result_text, bonus_points,
|
||||
state, approved_by, approved_at, note`
|
||||
|
||||
func scanRecordBreak(row interface{ Scan(...any) error }) (recordBreakDTO, error) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
value sql.NullFloat64
|
||||
text sql.NullString
|
||||
approvedBy sql.NullInt64
|
||||
approvedAt sql.NullString
|
||||
note sql.NullString
|
||||
)
|
||||
if err := row.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints,
|
||||
&b.State, &approvedBy, &approvedAt, ¬e); err != nil {
|
||||
return b, err
|
||||
}
|
||||
b.ResultValue = value.Float64
|
||||
b.ResultText = text.String
|
||||
b.ApprovedBy = i64Ptr(approvedBy)
|
||||
b.ApprovedAt = strPtr(approvedAt)
|
||||
b.Note = strPtr(note)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// listRecordBreaks 列出破纪录登记(最新在前)。state 为空表示不过滤;
|
||||
// 传了就按它过滤,非法状态由 handler 拦在更外层(避免一个拼错的状态静默回空列表)。
|
||||
func (s *server) listRecordBreaks(ctx context.Context, state string) ([]recordBreakDTO, error) {
|
||||
query := `SELECT ` + recordBreakColumns + ` FROM record_breaks`
|
||||
args := []any{}
|
||||
if state != "" {
|
||||
query += ` WHERE state = ?`
|
||||
args = append(args, state)
|
||||
}
|
||||
query += ` ORDER BY id DESC`
|
||||
|
||||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]recordBreakDTO, 0)
|
||||
for rows.Next() {
|
||||
b, err := scanRecordBreak(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -879,11 +912,14 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
}
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state != "pending" {
|
||||
if state != "pending" && state != "rejected" {
|
||||
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
|
||||
}
|
||||
// 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由:
|
||||
// note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
|
||||
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now'), note = NULL
|
||||
WHERE id = ?`,
|
||||
nullID(userID), id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
@@ -914,6 +950,102 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// rejectRecordBreak 驳回 / 作废一条破纪录登记(A41)。
|
||||
//
|
||||
// 原本是 approved 的登记意味着它已经给该小项加过分,所以驳回不只是改状态:
|
||||
//
|
||||
// ① 状态改 rejected 并把理由写进 note;
|
||||
// ② 重算该小项(引擎只认 approved 的登记),加分与随之变动的名次流水一起撤掉,再推榜;
|
||||
// ③ 把「确认破纪录」写进纪录库的那条 current 纪录撤下来,并恢复该级别上一条纪录。
|
||||
//
|
||||
// pending 直接驳回没有加过分,②③ 都不用做——这也是本文件里唯一区分「从哪个状态驳回」的地方。
|
||||
func (s *server) rejectRecordBreak(ctx context.Context, id, userID int64, reason string) (recordBreakDTO, error) {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return recordBreakDTO{}, badRequest("reason 必填", nil)
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
state string
|
||||
eventID int64
|
||||
level string
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT state, event_id, level FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&state, &eventID, &level); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state == "rejected" {
|
||||
return recordBreakDTO{}, badRequest("该破纪录登记已被驳回/作废", nil)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET state = 'rejected', note = ? WHERE id = ?`, reason, id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state == "approved" {
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "驳回破纪录:"+reason); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := revertCurrentRecord(ctx, tx, eventID, level); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
}
|
||||
// 登记表不进实时快照,但「破纪录登记」这一块要跟着刷新,所以照 approve 的口径写一条变更。
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
// 只有真的重算过才推榜:pending 驳回没动过任何分数,凭空推一次榜单只会让大屏白刷。
|
||||
if state == "approved" {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
s.audit(ctx, "record_break.reject", "record_break", id,
|
||||
map[string]any{"state": state}, map[string]any{"state": "rejected", "reason": reason})
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// revertCurrentRecord 撤下「确认破纪录」写进纪录库的那条当前纪录,并恢复该档位上一条纪录。
|
||||
//
|
||||
// 只能按 (小项, 档位) 定位:records 上没有指回 record_breaks 的列,0021 也只加了一列理由,
|
||||
// 没有为此重建表。同一 (小项, 档位) 的当前纪录本来就只有一条(upsertCurrentRecord 每次都先
|
||||
// 把旧的置 0 再插新的),所以「当前那条」就是这次批准写下的那条。
|
||||
// 恢复对象取剩下 id 最大的一条;一条都没有就把该档位留空——「无纪录」是事实,
|
||||
// 留一条刚被撤掉的纪录反而会让纪录库自相矛盾。
|
||||
func revertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID int64, level string) error {
|
||||
var currentID sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT id FROM records WHERE event_id = ? AND level = ? AND is_current = 1 ORDER BY id DESC LIMIT 1`,
|
||||
eventID, level).Scan(¤tID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return internal(err)
|
||||
}
|
||||
if !currentID.Valid {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE id = ?`, currentID.Int64); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 1
|
||||
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? AND id <> ?
|
||||
ORDER BY id DESC LIMIT 1)`,
|
||||
eventID, level, currentID.Int64); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
|
||||
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
|
||||
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
|
||||
@@ -952,6 +1084,161 @@ func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordEntryColumns 是 recordEntryDTO 的列清单。
|
||||
const recordEntryColumns = `id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||||
holder_name, holder_team_id, achieved_at, is_current, source`
|
||||
|
||||
func scanRecordEntry(row interface{ Scan(...any) error }) (recordEntryDTO, error) {
|
||||
var (
|
||||
r recordEntryDTO
|
||||
holderName sql.NullString
|
||||
holderTeamID sql.NullInt64
|
||||
achievedAt sql.NullString
|
||||
)
|
||||
if err := row.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
|
||||
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.HolderName = strPtr(holderName)
|
||||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||||
r.AchievedAt = strPtr(achievedAt)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *server) getRecordEntry(ctx context.Context, id int64) (recordEntryDTO, error) {
|
||||
r, err := scanRecordEntry(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordEntryDTO{}, notFound("纪录不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// createRecordEntry 手工登记一条历史纪录(C11):同 (小项, 档位) 的旧 current 让位,
|
||||
// 新行成为该档位的当前纪录(与 upsertCurrentRecord 同口径,只是 source 记 history)。
|
||||
//
|
||||
// holder 名称 / 单位都可以空:很多历史纪录只留下了一个成绩数字,人是谁已经查不到了;
|
||||
// 逼着录入员编一个名字,只会往库里塞假数据。
|
||||
func (s *server) createRecordEntry(ctx context.Context, in recordEntryInput, userID int64) (recordEntryDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return recordEntryDTO{}, badRequest("eventId 必填", nil)
|
||||
}
|
||||
switch in.Level {
|
||||
case "meet", "city", "province", "national":
|
||||
default:
|
||||
return recordEntryDTO{}, badRequest("level 非法", in.Level)
|
||||
}
|
||||
if in.ResultValue == nil && in.ResultText == nil {
|
||||
return recordEntryDTO{}, badRequest("resultValue 与 resultText 至少填一个", nil)
|
||||
}
|
||||
// 只收 history:source='current' 是「确认破纪录」的产物,客户端造出来的 current
|
||||
// 会把一条没人批准过的成绩伪装成本届刚破的纪录,还会被后续批准当成上一条纪录恢复。
|
||||
if in.Source != "" && in.Source != recordSourceHistory {
|
||||
return recordEntryDTO{}, badRequest("source 只能是 history", in.Source)
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var exists int
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, in.EventID).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordEntryDTO{}, badRequest("eventId 不存在", in.EventID)
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
|
||||
in.EventID, in.Level); err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id,
|
||||
achieved_at, is_current, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'history')`,
|
||||
in.EventID, in.Level, nullF64(in.ResultValue), nullStr(in.ResultText),
|
||||
nullStr(in.HolderName), nullI64(in.HolderTeamID), nullStr(in.AchievedAt))
|
||||
if isForeignKeyViolation(err) {
|
||||
return recordEntryDTO{}, badRequest("holderTeamId 不存在", in.HolderTeamID)
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
// 纪录库进了实时快照(realtime.snapshot 读 records),不写变更大屏上的纪录页不会刷新。
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordEntryDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
created, err := s.getRecordEntry(ctx, id)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, err
|
||||
}
|
||||
s.audit(ctx, "record.create", "record", id, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// deleteRecordEntry 删除一条纪录;删掉的若是当前纪录,恢复同 (小项, 档位) 上一条为当前,
|
||||
// 一条都没有就把该档位留空(与 revertCurrentRecord 同一口径)。
|
||||
func (s *server) deleteRecordEntry(ctx context.Context, id, userID int64) error {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
eventID int64
|
||||
level string
|
||||
isCurrent bool
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT event_id, level, is_current FROM records WHERE id = ?`, id).
|
||||
Scan(&eventID, &level, &isCurrent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound("纪录不存在")
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
before, err := scanRecordEntry(tx.QueryRowContext(ctx,
|
||||
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if isCurrent {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 1
|
||||
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ?
|
||||
ORDER BY id DESC LIMIT 1)`,
|
||||
eventID, level); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "record", id, "void", map[string]any{"id": id}, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
s.audit(ctx, "record.delete", "record", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- adjustments ----------
|
||||
|
||||
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -942,37 +942,28 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
|
||||
return s.resultsByEvent(ctx, in.EventID)
|
||||
}
|
||||
|
||||
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
|
||||
if reason == "" {
|
||||
return resultDTO{}, badRequest("reason 必填", nil)
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
before resultDTO
|
||||
eventID int64
|
||||
catID int64
|
||||
)
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
|
||||
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
|
||||
e.category_id
|
||||
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
|
||||
// loadResultForAction 读一条成绩及其所属 (小项, 组别),供「作废 / 重新录入」这类单条成绩
|
||||
// 动作使用:两者要读的字段完全一样,各自写一遍扫描迟早会漂移。
|
||||
func loadResultForAction(ctx context.Context, q queryer, resultID int64) (resultDTO, int64, int64, error) {
|
||||
var (
|
||||
before resultDTO
|
||||
eventID, catID int64
|
||||
rank, recordedBy, verifiedBy sql.NullInt64
|
||||
text, unit, recordedAt, verifiedAt, note sql.NullString
|
||||
value, override sql.NullFloat64
|
||||
)
|
||||
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
|
||||
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resultDTO{}, notFound("成绩不存在")
|
||||
}
|
||||
return resultDTO{}, internal(err)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
|
||||
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
|
||||
e.category_id
|
||||
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID).
|
||||
Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
|
||||
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resultDTO{}, 0, 0, notFound("成绩不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return resultDTO{}, 0, 0, internal(err)
|
||||
}
|
||||
before.EventID = eventID
|
||||
before.Rank = intPtr(rank)
|
||||
@@ -985,6 +976,23 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
before.VerifiedBy = i64Ptr(verifiedBy)
|
||||
before.VerifiedAt = strPtr(verifiedAt)
|
||||
before.Note = strPtr(note)
|
||||
return before, eventID, catID, nil
|
||||
}
|
||||
|
||||
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
|
||||
if reason == "" {
|
||||
return resultDTO{}, badRequest("reason 必填", nil)
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
@@ -1032,6 +1040,66 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
return s.getResult(ctx, resultID)
|
||||
}
|
||||
|
||||
// reopenResult 把一条成绩退回草稿(A16)。现场「手滑填错」时,「作废本条」只把它从榜上撤掉,
|
||||
// 录入员仍然改不了它(作废行不是草稿,saveDraft 只认草稿);本接口把状态退回 draft 并复位成
|
||||
// 一条干净的成绩,录入员直接改、再重新发布本组即可——不需要「撤销发布」整组把别人的成绩也翻下来。
|
||||
//
|
||||
// 重算口径与作废完全一致:这是回滚类动作(改的是「这条成绩算不算数」),无条件重算该
|
||||
// (小项, 组别) 并推榜,不受 standings.live_on_publish 与延迟结算约束。否则退回草稿后
|
||||
// 旧名次与旧流水还留在榜上,等于这条成绩「既不算数又还在计分」。
|
||||
func (s *server) reopenResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
|
||||
if reason == "" {
|
||||
return resultDTO{}, badRequest("reason 必填", nil)
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 已经是草稿就没有「退回」可言,硬做只会把录入员正在改的内容清掉(下面会复位 status / rank)。
|
||||
if before.State == string(domain.ResultStateDraft) {
|
||||
return resultDTO{}, badRequest("该成绩已是草稿,可直接修改", nil)
|
||||
}
|
||||
// status 复位成 ok:作废写下的 withdrawn 只是「这条不算数」的标记,退回草稿后它就是一条
|
||||
// 待重新录入的空成绩,留着 withdrawn 会让录入页按异常状态显示。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET state = 'draft', status = 'ok', rank = NULL, note = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`, reason, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "重新录入:"+reason)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||
if err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
// change_log.action 的 CHECK 只认 upsert / void / replace(0009),退回草稿是这一行的
|
||||
// 状态更新,所以走 upsert;「重新录入」这个语义记在审计(result.reopen)里。
|
||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "upsert", userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
s.audit(ctx, "result.reopen", "result", resultID, before, after)
|
||||
return s.getResult(ctx, resultID)
|
||||
}
|
||||
|
||||
// ---------- 分批结算 ----------
|
||||
|
||||
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
|
||||
|
||||
@@ -264,6 +264,9 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
|
||||
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
|
||||
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
|
||||
// 「重新录入」把成绩退回草稿让录入员直接改(A16)。与作废同权限、同重算口径:
|
||||
// 它是回滚类动作,不受 standings.live_on_publish 约束。
|
||||
mux.HandleFunc("POST /api/results/{id}/reopen", s.write(auth.RoleRecorder, s.reopenResultHandler))
|
||||
|
||||
// 积分与排名
|
||||
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
|
||||
@@ -275,8 +278,16 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
// 纪录
|
||||
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
|
||||
// 纪录库的写入路径(C11):手工登记 / 删除历史纪录。这不是破纪录登记,
|
||||
// 所以两者分开:/api/records 只碰纪录库本身,source 只收 history。
|
||||
mux.HandleFunc("POST /api/records", s.write(auth.RoleRecorder, s.createRecordHandler))
|
||||
mux.HandleFunc("DELETE /api/records/{id}", s.writeVoid(auth.RoleRecorder, s.deleteRecordHandler))
|
||||
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
|
||||
// 审核清单:guest 不可读(含未审核的登记与驳回理由),recorder 起可读。
|
||||
mux.HandleFunc("GET /api/record-breaks", s.read(auth.RoleRecorder, s.listRecordBreaksHandler))
|
||||
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
|
||||
// 驳回 / 作废(A41):已批准的登记被驳回时要撤掉加分、回退榜单与纪录库,见 rejectRecordBreak。
|
||||
mux.HandleFunc("POST /api/record-breaks/{id}/reject", s.write(auth.RoleRecorder, s.rejectRecordBreakHandler))
|
||||
|
||||
// 大屏控制与运维
|
||||
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
|
||||
|
||||
@@ -38,6 +38,14 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
|
||||
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
|
||||
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
|
||||
t.Helper()
|
||||
return newTestEnvPrepared(t, nil, customize)
|
||||
}
|
||||
|
||||
// newTestEnvPrepared 在赛事库迁移之前给测试一次插手的机会(prepare),
|
||||
// 用于构造「旧版赛事包」现场:先把库停在某条迁移之前、塞进老数据,再走 store.MigrateEvent
|
||||
// 的真实升级路径——与 main.go 打开旧包后补跑迁移的顺序完全一致。
|
||||
func newTestEnvPrepared(t *testing.T, prepare func(ev *sql.DB), customize func(*httpapi.Options)) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -60,6 +68,9 @@ func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
|
||||
}
|
||||
ev.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = ev.Close() })
|
||||
if prepare != nil {
|
||||
prepare(ev)
|
||||
}
|
||||
if err := store.MigrateEvent(ev); err != nil {
|
||||
t.Fatalf("迁移赛事库: %v", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user