用户给出的 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。
371 lines
13 KiB
Go
371 lines
13 KiB
Go
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
|
||
}
|