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

+318
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"slices"
"strings"
@@ -645,6 +646,323 @@ func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause e
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
}
// ---------- 运动员批量动作 ----------
const (
athleteBatchDisable = "disable"
athleteBatchPurge = "purge"
)
// athleteBatchItem 是批量动作里逐条的结果。
//
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
// 静默跳过又会让他以为整批都成了,所以一条一条回报。
type athleteBatchItem struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。
// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。
type athleteBatchResult struct {
Action string `json:"action"`
DryRun bool `json:"dryRun,omitempty"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Items []athleteBatchItem `json:"items"`
Purge *athletePurge `json:"purge,omitempty"`
}
// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。
type purgeScope struct{ eventID, categoryID int64 }
func idArgs(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
return args
}
// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。
func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) {
rows, err := q.QueryContext(ctx,
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
found := make(map[int64]bool, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, internal(err)
}
found[id] = true
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
out := make([]int64, 0, len(found))
for _, id := range ids {
if found[id] {
out = append(out, id)
}
}
return out, nil
}
// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。
func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult {
ok := make(map[int64]bool, len(existing))
for _, id := range existing {
ok[id] = true
}
items := make([]athleteBatchItem, 0, len(ids))
result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge}
for _, id := range ids {
if ok[id] {
items = append(items, athleteBatchItem{ID: id, OK: true})
result.Succeeded++
continue
}
items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"})
result.Failed++
}
result.Items = items
return result
}
// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。
//
// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流;
// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。
// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。
func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) {
switch action {
case athleteBatchDisable, athleteBatchPurge:
default:
return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action)
}
ids := dedupeIDs(rawIDs)
if len(ids) == 0 {
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
}
if action == athleteBatchPurge {
return s.batchPurgeAthletes(ctx, ids, dryRun)
}
return s.batchDisableAthletes(ctx, ids, dryRun)
}
// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0,
// 报名、成绩、流水一行不动,也不触发任何重算。
func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
existing, err := existingAthleteIDs(ctx, s.ev, ids)
if err != nil {
return athleteBatchResult{}, err
}
if !dryRun && len(existing) > 0 {
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`,
idArgs(existing)...); err != nil {
return athleteBatchResult{}, internal(err)
}
}
return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil
}
// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录,
// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。
//
// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。
// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。
func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return athleteBatchResult{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
// 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务,
// 而且「已被别人删掉」和「id 不存在」本来就该一起回报。
existing, err := existingAthleteIDs(ctx, tx, ids)
if err != nil {
return athleteBatchResult{}, err
}
// 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。
scopes, err := purgeScopesTx(ctx, tx, existing)
if err != nil {
return athleteBatchResult{}, err
}
counts, err := deleteAthletesTx(ctx, tx, existing)
if err != nil {
var api *apiError
if errors.As(err, &api) {
return athleteBatchResult{}, err
}
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
purge := &athletePurge{
DeletedEntries: counts.DeletedEntries,
DeletedResults: counts.DeletedResults,
DeletedLedger: counts.DeletedLedger,
DeletedBreaks: counts.DeletedBreaks,
}
eventIDs := make([]int64, 0, len(scopes))
for _, sc := range scopes {
if !slices.Contains(eventIDs, sc.eventID) {
eventIDs = append(eventIDs, sc.eventID)
}
}
purge.RecomputedEvent = eventIDs
if dryRun {
// 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。
return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil
}
actorID := middleware.User(ctx).ID
for _, sc := range scopes {
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算")
if err != nil {
return athleteBatchResult{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return athleteBatchResult{}, err
}
}
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return athleteBatchResult{}, err
}
}
if len(eventIDs) > 0 {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return athleteBatchResult{}, err
}
}
if err := tx.Commit(); err != nil {
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil
}
// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。
func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := tx.QueryContext(ctx,
`SELECT DISTINCT e.event_id, e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id
WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]purgeScope, 0, 4)
for rows.Next() {
var sc purgeScope
if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil {
return nil, internal(err)
}
out = append(out, sc)
}
return out, rows.Err()
}
// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。
// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。
func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) {
var out athletePurge
if len(ids) == 0 {
return out, nil
}
ph := placeholders(len(ids))
args := idArgs(ids)
// score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。
ledgerArgs := append(append(append([]any{}, args...), args...), args...)
for _, step := range []struct {
query string
args []any
count *int
}{
{`DELETE FROM score_ledger
WHERE athlete_id IN (` + ph + `)
OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
WHERE e.athlete_id IN (` + ph + `))`,
ledgerArgs, &out.DeletedLedger},
{`DELETE FROM record_breaks
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedBreaks},
{`DELETE FROM results
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedResults},
{`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries},
} {
res, err := tx.ExecContext(ctx, step.query, step.args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
*step.count = int(n)
}
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
// 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态,
// 用 409 明说并让整个事务回滚,别把成绩删了却报成功。
if int(n) != len(ids) {
return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试")
}
return out, nil
}
// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。
func (s *server) batchPurgeBlocked(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("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。")
}
// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。
func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athletesBatchPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun)
if err != nil {
return nil, err
}
// dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。
if result.DryRun {
return result, nil
}
// 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。
// 逐条明细与失败原因都进 after,事后能对着名单查。
s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result)
return result, nil
}
// ---------- sports ----------
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`