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

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

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

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

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

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

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

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

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

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

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

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

## 四、验证

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

## 版本

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

262 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}