Files
Scoreboard/server/internal/httpapi/entries_batch.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

371 lines
13 KiB
Go
Raw 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"
"fmt"
"net/http"
"slices"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// maxEntryBatch 是单次批量操作的上限:前端一次勾选几十条是常态,
// 500 足够覆盖「一个组别全选」,再大说明是脚本误用,要求分批提交。
const maxEntryBatch = 500
// entryBatchItemDTO 是逐条回报:ids 里每一个 id 都必有一条,
// 不存在 / 被跳过都要写清原因,不能整批失败,也不能静默略过。
type entryBatchItemDTO struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Note string `json:"note,omitempty"`
}
// entryBatchSummaryDTO 是整批汇总:ok 含跳过的行,changed 才是真正改动的行数;
// failed 只统计 id 不存在这类逐条失败(整批 SQL 出错会直接 5xx / 409,不进这里)。
type entryBatchSummaryDTO struct {
OK int `json:"ok"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Changed int `json:"changed"`
RemovedEntries int `json:"removedEntries"`
RemovedResults int `json:"removedResults"`
RemovedLedger int `json:"removedLedger"`
RemovedBreaks int `json:"removedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
}
type entryBatchResultDTO struct {
Items []entryBatchItemDTO `json:"items"`
Summary entryBatchSummaryDTO `json:"summary"`
}
// entryBatchTarget 是勾选行在库里的落点:换状态要看当前 status,重算要按 (小项, 组别) 分组。
type entryBatchTarget struct {
id int64
eventID int64
categoryID int64
status string
}
// batchEntriesHandler 是 POST /api/entries/batch:
// scratch(批量退赛,可恢复)/ restore(批量恢复)/ remove(批量移出报名,物理删除)。
func (s *server) batchEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in entryBatchPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
action := strings.TrimSpace(in.Action)
switch action {
case "scratch", "restore", "remove":
default:
return nil, badRequest("action 只能是 scratch / restore / remove", in.Action)
}
// 重复 id 去重(前端全选后二次勾选很容易重复),非法 id 直接丢掉。
ids := dedupeIDs(in.IDs)
if len(ids) == 0 {
return nil, badRequest("ids 不能为空", nil)
}
if len(ids) > maxEntryBatch {
return nil, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
}
result, err := s.batchEntries(r.Context(), action, ids)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry."+action, "entry", 0, nil, map[string]any{
"ids": ids,
"reason": strings.TrimSpace(in.Reason),
"summary": result.Summary,
})
return result, nil
}
// entrySkipNote 解释某一行为什么没被改动:跳过必须带出现状,
// 否则操作员只看到「没退赛」会以为是系统漏了。
func entrySkipNote(action, status string) string {
switch status {
case "scratched":
return "已是退赛状态,跳过"
case "registered":
return "当前正常参赛,无需恢复"
case "dq":
return "已被判罚取消资格(dq),跳过,不覆盖原判定"
case "dns":
return "已标记未出场(dns),跳过,不覆盖原判定"
}
return "当前状态为「" + status + "」,跳过"
}
// recomputeReason 是流水作废原因,写进 score_ledger.void_reason,事后能对上「榜为什么变了」。
func recomputeReason(action string) string {
switch action {
case "scratch":
return "批量退赛后重算"
case "restore":
return "批量恢复后重算"
}
return "批量移出报名后重算"
}
// loadEntryBatchTargets 读回请求里存在的报名项;不存在的由调用方逐条回报。
func loadEntryBatchTargets(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64]entryBatchTarget, error) {
args := idsToArgs(ids)
rows, err := tx.QueryContext(ctx,
`SELECT id, event_id, category_id, status FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]entryBatchTarget, len(ids))
for rows.Next() {
var t entryBatchTarget
if err := rows.Scan(&t.id, &t.eventID, &t.categoryID, &t.status); err != nil {
return nil, internal(err)
}
out[t.id] = t
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return out, nil
}
// entryRemoveStats 是移出报名的物理删除规模,随响应汇总返回。
type entryRemoveStats struct {
entries int
results int
ledger int
breaks int
}
// removeEntriesTx 物理删除报名项及其成绩痕迹。
//
// 顺序按外键方向来(与 repo.go 的 purgeAthlete 同一套解法):
// score_ledger 对 entries / results 都是 ON DELETE SET NULL,直接删 entries 只会把
// entry_id 置空——人没了分还留在榜上,所以必须按 entry_id 显式清掉(result_id 一并兜住);
// record_breaks 是 CASCADE、results 对 entries 没有 ON DELETE,都要显式先删。
func (s *server) removeEntriesTx(ctx context.Context, tx *sql.Tx, ids []int64) (entryRemoveStats, error) {
stats := entryRemoveStats{}
if len(ids) == 0 {
return stats, nil
}
ph := placeholders(len(ids))
idArgs := idsToArgs(ids)
steps := []struct {
query string
args []any
count *int
}{
{`DELETE FROM score_ledger
WHERE entry_id IN (` + ph + `)
OR result_id IN (SELECT id FROM results WHERE entry_id IN (` + ph + `))`,
append(append([]any{}, idArgs...), idArgs...), &stats.ledger},
{`DELETE FROM record_breaks WHERE entry_id IN (` + ph + `)`, idArgs, &stats.breaks},
{`DELETE FROM results WHERE entry_id IN (` + ph + `)`, idArgs, &stats.results},
{`DELETE FROM entries WHERE id IN (` + ph + `)`, idArgs, &stats.entries},
}
for _, step := range steps {
res, err := tx.ExecContext(ctx, step.query, step.args...)
if err != nil {
return entryRemoveStats{}, s.entryBatchBlocked(ctx, tx, err)
}
n, err := res.RowsAffected()
if err != nil {
return entryRemoveStats{}, internal(err)
}
*step.count = int(n)
}
return stats, nil
}
// entryBatchBlocked 兜底:撞上外键约束时回滚并给可读的 409,不让它冒成 500。
// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。
func (s *server) entryBatchBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
if !isForeignKeyViolation(cause) {
return internal(cause)
}
s.logger.WarnContext(ctx, "批量移出报名撞上外键约束", "err", cause)
_ = tx.Rollback()
return conflict("报名项仍被其它数据引用,本批已回滚,报名与成绩都没有改动。请刷新后重试。")
}
// batchEntries 在一个事务里完成整批状态变更或删除,并按 (小项, 组别) 分组重算积分。
//
// 重算口径照 repo.go 的 purgeAthlete:删完成绩(或改完报名状态)后必须重算流水、
// 把新名次写回 results,再推小项与榜单,否则大屏还挂着旧分数、别人的名次也不会变。
func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (entryBatchResultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return entryBatchResultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
targets, err := loadEntryBatchTargets(ctx, tx, ids)
if err != nil {
return entryBatchResultDTO{}, err
}
out := entryBatchResultDTO{Items: make([]entryBatchItemDTO, 0, len(ids))}
affected := make([]entryBatchTarget, 0, len(ids))
for _, id := range ids {
target, ok := targets[id]
if !ok {
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: false, Error: "报名项不存在"})
out.Summary.Failed++
continue
}
// 退赛只认 registered、恢复只认 scratched;dq/dns 是别人的判定,一律不覆盖。
want := ""
switch action {
case "scratch":
want = "registered"
case "restore":
want = "scratched"
}
if want != "" && target.status != want {
out.Items = append(out.Items, entryBatchItemDTO{
ID: id, OK: true, Skipped: true, Note: entrySkipNote(action, target.status),
})
out.Summary.OK++
out.Summary.Skipped++
continue
}
affected = append(affected, target)
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: true})
out.Summary.OK++
out.Summary.Changed++
}
changedIDs := targetIDs(affected)
switch action {
case "scratch", "restore":
if len(changedIDs) > 0 {
status := "scratched"
if action == "restore" {
status = "registered"
}
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET status = ? WHERE id IN (`+placeholders(len(changedIDs))+`)`,
append([]any{status}, idsToArgs(changedIDs)...)...); err != nil {
return entryBatchResultDTO{}, internal(err)
}
}
case "remove":
stats, err := s.removeEntriesTx(ctx, tx, changedIDs)
if err != nil {
return entryBatchResultDTO{}, err
}
out.Summary.RemovedEntries = stats.entries
out.Summary.RemovedResults = stats.results
out.Summary.RemovedLedger = stats.ledger
out.Summary.RemovedBreaks = stats.breaks
}
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
actorID := middleware.User(ctx).ID
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
return entryBatchResultDTO{}, err
}
eventIDs := make([]int64, 0, 2)
settled := false
for _, scope := range distinctScopes(affected) {
// 退赛 / 恢复会改变「这一组该不该等它」:标成退赛后整组可能刚好录完,
// 延迟结算下必须补一次结算,否则榜单会永远停在没有流水的那一版。
settle := live
if !live {
var err error
settle, err = scopeSettled(ctx, tx, scope.eventID, scope.categoryID)
if err != nil {
return entryBatchResultDTO{}, err
}
}
if settle {
// 退赛的成绩不再占名次:results.rank 必须一并清掉。
// buildRanked(奖牌统计 / 运动员榜)只认 results.rank + state='published',
// 不清就会留下「0 分却还挂着金牌」;恢复时由 applyComputedRanks 按新名次写回。
if action == "scratch" {
if _, err := tx.ExecContext(ctx,
`UPDATE results SET rank = NULL
WHERE state = 'published'
AND entry_id IN (SELECT id FROM entries
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
return entryBatchResultDTO{}, internal(err)
}
}
score, err := computeEventScore(ctx, tx, scope.eventID, scope.categoryID)
if err != nil {
return entryBatchResultDTO{}, err
}
// 只重建流水不够:成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return entryBatchResultDTO{}, err
}
if err := writeLedgerTx(ctx, tx, score, scope.eventID, scope.categoryID, actorID, recomputeReason(action)); err != nil {
return entryBatchResultDTO{}, err
}
settled = true
}
if !slices.Contains(eventIDs, scope.eventID) {
eventIDs = append(eventIDs, scope.eventID)
}
}
// 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return entryBatchResultDTO{}, err
}
}
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
if len(eventIDs) > 0 && settled {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return entryBatchResultDTO{}, err
}
}
out.Summary.RecomputedEvent = eventIDs
if err := tx.Commit(); err != nil {
return entryBatchResultDTO{}, s.entryBatchBlocked(ctx, tx, err)
}
return out, nil
}
func targetIDs(targets []entryBatchTarget) []int64 {
out := make([]int64, 0, len(targets))
for _, t := range targets {
out = append(out, t.id)
}
return out
}
// entryScope 是重算的最小单位:积分与名次都按「小项 + 组别」算。
type entryScope struct{ eventID, categoryID int64 }
// distinctScopes 按出现顺序去重,保证同一批请求的重算次序稳定、可复现。
func distinctScopes(targets []entryBatchTarget) []entryScope {
out := make([]entryScope, 0, len(targets))
for _, t := range targets {
scope := entryScope{t.eventID, t.categoryID}
if !slices.Contains(out, scope) {
out = append(out, scope)
}
}
return out
}
func idsToArgs(ids []int64) []any {
out := make([]any, 0, len(ids))
for _, id := range ids {
out = append(out, id)
}
return out
}