## 一、修「改小项显示名称后,撤销发布按钮消失」 **根因不是改名字。** 我一开始怀疑「某处按 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(无数据迁移)。
262 lines
13 KiB
Go
262 lines
13 KiB
Go
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
|
||
}
|