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:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user