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 }