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:
AskaEth committed 2026-09-12 01:27:09 +08:00
1 parent 6841cf49aa
commit ed2c0b3f3c
35 files changed
+4003 -115

No files matched your search

+314 -27
View File
@@ -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, &note); 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(&currentID)
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) {