fix(entry): 回到录入页时记住上次的小项(按 id);feat: 新增「清空本组 / 本批成绩」;版本 dev_0.5.12

## 一、修「改小项显示名称后,撤销发布按钮消失」

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

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

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

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

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

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

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

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

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

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

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

## 四、验证

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

## 版本

dev_0.5.11 → dev_0.5.12(无数据迁移)。
This commit is contained in:
AskaEth committed 2026-09-17 01:14:18 +08:00
1 parent 7a64e83ecf
commit 72362239c6
20 files changed
+1448 -18

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
package httpapi
import (
"context"
"database/sql"
"strconv"
"strings"
)
// 清空「当前组 / 批次」的成绩(POST /api/results/clear)。
//
// 语义(主人定的):**先撤销发布,再把成绩内容清空**,回到「没录过」的样子——
// - 已发布的收回(state 退回 draft)、该范围产生的积分流水作废(大屏与榜单跟着回退);
// - 名次 / 成绩值 / 成绩文本 / 单位 / 并列 / 状态 / 备注全部清掉;
// - 整个组别(scope=group)时,连同该组别的「按单位录入的团体名次」一起清掉:
// 它同样是这一组的成绩内容,不清就变成「清空了榜上却还有分」(那批分没有成绩行,
// 重算时会原样再写一遍)。
//
// 与「撤销发布」的区别必须守住:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。
// 所以这是危险操作:必须显式勾选确认(服务端也要求 confirm=true)、全程写审计。
//
// 批次(heat)只是录入 / 展示分组,不参与算分,所以这里按 heat 过滤的只有「成绩行」:
// 团体名次不分批次,清「本批」时一律不动它。
// clearResultsPayload 是 POST /api/results/clear 的请求体。
//
// heat 用「在不在请求体里」区分三件事,所以必须配 decodeJSONPresence 读:
// 不传 = 整个组别(含未分批的行);传数字 = 只清这一批次;传 null = 只清「未分批」的行。
// 用 *int64 单看值分不出「不传」与「传 null」——前者是清整组,后者只清未分批,差得很远。
type clearResultsPayload struct {
publishPayload
Heat *int64 `json:"heat"`
// Confirm 必须为 true:这是「勾选了『我知道这会清掉已发布的分数』」在协议上的落点。
// 前端禁用确认键只是界面防呆,服务端不能靠它。
Confirm bool `json:"confirm"`
// Reason 是操作员填的原因(可空);写进流水作废原因与审计,便于事后复盘。
Reason string `json:"reason"`
}
// 清空范围的三种取值:与请求体里 heat 的三种写法一一对应。
const (
clearScopeGroup = "group" // 整个组别(含未分批的行)
clearScopeBatch = "batch" // 指定批次
clearScopeUnbatched = "unbatched" // 只清「未分批」的行
)
// clearResultsResultDTO 是清空回执:清了多少条、其中多少条原先已发布、作废了多少条流水。
type clearResultsResultDTO struct {
Scope string `json:"scope"`
Heat *int64 `json:"heat"`
// Cleared 是本次被清空的成绩行数;Published 是其中原先处于已发布状态的条数。
Cleared int `json:"cleared"`
Published int `json:"published"`
// VoidedLedger 是本次作废的积分流水条数;TeamRanks 是连同清掉的团体手工名次条数。
VoidedLedger int `json:"voidedLedger"`
TeamRanks int `json:"teamRanks"`
}
// clearResults 清空指定范围内的成绩。heatPresent 表示请求体里到底有没有 heat 这个键。
func (s *server) clearResults(ctx context.Context, in clearResultsPayload, heatPresent bool, userID int64) (clearResultsResultDTO, error) {
if in.EventID == 0 {
return clearResultsResultDTO{}, badRequest("eventId 必填", nil)
}
if in.CategoryID == nil || *in.CategoryID <= 0 {
// 故意不提供「清空整个小项」:那条路的爆炸半径是几十条成绩乘上组别数,
// 而界面上每一次操作看到的都是「当前这一组」。要清整项就逐组清。
return clearResultsResultDTO{}, badRequest("categoryId 必填:清空以「小项 + 组别」为界", nil)
}
if !in.Confirm {
return clearResultsResultDTO{}, badRequest("请先勾选「我知道这会清掉已发布的分数」再提交", nil)
}
scope := clearScopeGroup
if heatPresent {
switch {
case in.Heat == nil:
scope = clearScopeUnbatched
case *in.Heat < 1 || *in.Heat > maxEntryHeat:
return clearResultsResultDTO{}, badRequest("heat 需要是 1-999 的整数,或传 null 只清未分批的行", nil)
default:
scope = clearScopeBatch
}
}
categoryID := *in.CategoryID
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return clearResultsResultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
// 范围里的报名项:整组 = 该组别全部;按批次 = 该组别里 heat 命中的那些。
entryScope := `SELECT id FROM entries WHERE event_id = ? AND category_id = ?`
args := []any{in.EventID, categoryID}
switch scope {
case clearScopeBatch:
entryScope += ` AND heat = ?`
args = append(args, *in.Heat)
case clearScopeUnbatched:
entryScope += ` AND heat IS NULL`
}
// 清空前的读数:条数与已发布条数进回执、审计与变更历史,
// 让「清了多少条、其中多少条动过分」有据可查(现场事后只认这句话)。
var cleared, published int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(state = 'published'), 0) FROM results
WHERE entry_id IN (`+entryScope+`)`, args...).Scan(&cleared, &published); err != nil {
return clearResultsResultDTO{}, internal(err)
}
// 团体名次(只在清整个组别时算数)也是这一组的成绩内容,先数出来:
// 纯团体名次的项目(球类那种)一条 results 行都没有,只有它才是「可清的东西」。
teamRanks := 0
if scope == clearScopeGroup {
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`,
in.EventID, categoryID).Scan(&teamRanks); err != nil {
return clearResultsResultDTO{}, internal(err)
}
}
// 「什么都没有」要明确回答,而不是静默回一句「清空 0 条」——操作员多半看错了批次 / 组别,
// 提示成功会让他以为已经清干净了。判据是这一组此刻还有没有任何成绩痕迹:
// 成绩行、团体名次、以及还没作废的流水(只有它才能证明「榜上还挂着分」)。
validLedger := s.validLedgerPoints(ctx, tx, in.EventID, categoryID)
if cleared == 0 && teamRanks == 0 && validLedger == 0 {
return clearResultsResultDTO{}, badRequest("该范围没有成绩可清空(这一组或这一批次还没录过)", nil)
}
// 成绩内容整行归零:回到「没录过」的样子(录入页把「有行但全空」与「没有行」显示成同一件事)。
// state 必须退回 draft——留在 published 就成了「榜上没分、表格却锁着」的半成品。
// points_override 一并清掉:它是已停用的历史列,留着只会让这一条之后既发不出去也撤不回来。
if _, err := tx.ExecContext(ctx,
`UPDATE results
SET state = 'draft', rank = NULL, result_text = NULL, result_value = NULL, unit = NULL,
is_tie = 0, status = 'ok', note = NULL, points_override = NULL,
verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
WHERE entry_id IN (`+entryScope+`)`, args...); err != nil {
return clearResultsResultDTO{}, internal(err)
}
// 团体名次只在「清整个组别」时动:它按 (小项, 组别, 单位) 存,与批次无关,
// 清一批却把它抹掉会连带清掉别的批次的团体分。
if scope == clearScopeGroup && teamRanks > 0 {
if _, err := tx.ExecContext(ctx,
`DELETE FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`, in.EventID, categoryID); err != nil {
return clearResultsResultDTO{}, internal(err)
}
}
// 作废旧流水并按「剩下的已发布内容」重写:撤销发布走的是同一条路(先算后作废,流水只追加),
// 所以清空之后榜上剩下的分,与「这些成绩从没发布过」逐字节一致。
voidedBefore := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID)
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, categoryID, userID, clearVoidReason(in.Reason, scope)); err != nil {
return clearResultsResultDTO{}, err
}
voidedAfter := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID)
// 小项进度与撤销发布对称地退回:这一下把该组已发布的成绩全撤了,小项状态不跟着退,
// 左栏就会写着「已发布」而这一组其实一条已发布成绩都没有——正是现场最容易被误导的那种假象。
// 与撤销发布同一口径:只在整项此前被标成 published 时降级,pending / ongoing 是人工设的进度,不覆盖。
if _, err := tx.ExecContext(ctx,
`UPDATE events SET status = 'finished' WHERE id = ? AND status = 'published'`,
in.EventID); err != nil {
return clearResultsResultDTO{}, internal(err)
}
results, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return clearResultsResultDTO{}, err
}
// 变更历史:action 只有 publish / unpublish / edit 三种(0008 的 CHECK),
// 清空本质上是「撤销发布 + 清内容」,归到 unpublish 并在 message 里写明清了多少条。
// 为它新加一个 action 取值要重建整张表,代价与收益不成比例。
if err := recordRevision(ctx, tx, in.EventID, "unpublish", results, userID,
clearRevisionNote(scope, in.Heat, cleared, published, teamRanks)); err != nil {
return clearResultsResultDTO{}, err
}
if err := recordChanges(ctx, tx, "result", results, "upsert", userID); err != nil {
return clearResultsResultDTO{}, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return clearResultsResultDTO{}, err
}
if err := tx.Commit(); err != nil {
return clearResultsResultDTO{}, internal(err)
}
out := clearResultsResultDTO{
Scope: scope, Heat: in.Heat, Cleared: cleared, Published: published,
VoidedLedger: voidedAfter - voidedBefore, TeamRanks: teamRanks,
}
// 审计:谁在什么时候清空了哪一组 / 哪一批、清掉多少条、作废多少条流水。
// 这几项都要有——「变更历史里能查到谁在什么时候清空了哪一组」是主人点名的要求。
s.audit(ctx, "result.clear", "event", in.EventID, nil, map[string]any{
"eventId": in.EventID, "categoryId": categoryID, "scope": scope, "heat": in.Heat,
"cleared": cleared, "published": published, "voidedLedger": out.VoidedLedger,
"teamRanks": teamRanks, "reason": strings.TrimSpace(in.Reason),
})
return out, nil
}
// voidedLedgerCount 数该 (小项, 组别) 范围内已作废的流水条数。
//
// 重算内部是「先作废、再按当前已发布内容重写」,所以前后两次计数的差值就是本次作废掉的条数。
// 作废是「改标记」而不是删行,历史累积的作废行不会干扰:差值只反映这一次。
func (s *server) voidedLedgerCount(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) int {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM score_ledger
WHERE voided = 1 AND source_type IN ('result', 'record_bonus') AND event_id = ?
AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))`,
eventID, categoryID).Scan(&n); err != nil {
return 0
}
return n
}
// validLedgerPoints 数该 (小项, 组别) 范围内还没作废的流水分合计。
// 判据与 writeLedgerTx 的作废范围逐字一致:成绩分 / 破纪录加分,含「无报名项归属」的
// 团体名次与聚合流水(它们挂在 event 上,靠 category 分不出来,只能整项算)。
func (s *server) validLedgerPoints(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) float64 {
var points float64
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND source_type IN ('result', 'record_bonus') AND event_id = ?
AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))`,
eventID, categoryID).Scan(&points); err != nil {
return 0
}
return points
}
// clearVoidReason 是流水作废原因;操作员填了原因就带上,便于事后复盘。
func clearVoidReason(reason, scope string) string {
base := "清空成绩"
if scope == clearScopeBatch || scope == clearScopeUnbatched {
base += "(当前批次)"
}
if trimmed := strings.TrimSpace(reason); trimmed != "" {
return base + ":" + trimmed
}
return base
}
// clearRevisionNote 是变更历史里那句话:说清范围、条数与「已发布的分被收回」。
func clearRevisionNote(scope string, heat *int64, cleared, published, teamRanks int) string {
word := "本组"
switch scope {
case clearScopeBatch:
word = "第 " + strconv.FormatInt(*heat, 10) + " 批"
case clearScopeUnbatched:
word = "未分批"
}
note := "清空" + word + "成绩:" + strconv.Itoa(cleared) + " 条(其中已发布 " +
strconv.Itoa(published) + " 条,分数已收回)"
if teamRanks > 0 {
note += ",团体名次 " + strconv.Itoa(teamRanks) + " 条一并清空"
}
return note
}