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。
This commit is contained in:
AskaEth committed 2026-09-11 17:14:02 +08:00
1 parent 8dd599bd97
commit 631c8dd8e1
80 files changed
+12468 -519

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
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
}