Files
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

361 lines
13 KiB
Go
Raw Permalink 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
}
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
//
// 退赛 / 恢复 / 移出都属回滚类动作,延迟结算(live_on_publish=false)下也必须当场重算:
// - 它们改的不是「录了多少」,而是「谁还算数」,与撤销发布、作废成绩同一个性质,
// 文档 02 §5.1 也承诺退赛会把 results.rank 显式清掉再按新名次重算,没有「延迟结算下除外」;
// - 不重算就会留下「退赛者仍挂着名次与分数、同组别人的名次停在旧口径」,移出报名更糟:
// 流水已经物理删掉、榜单数据被改,大屏却推不动,永远停在上一次的旧画面;
// - 现场可能已经结算过一次(人工立即结算、改组别权重、规则导入都会在延迟模式下写流水)
// 而整组还没录完,此时 scopeSettled 返回 false,只按「录完了没」判断就补不上这一刀。
actorID := middleware.User(ctx).ID
eventIDs := make([]int64, 0, 2)
for _, scope := range distinctScopes(affected) {
// 退赛的成绩不再占名次:results.rank(个人名次)必须一并清掉,channel_ranks 由
// writeLedgerTx 按当前已发布成绩整组重建,不清就会留下「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)
}
}
// 与发布 / 立即结算同一套:recomputeLedgerTx(算 + 作废旧流水 + 写新流水 + 重建 channel_ranks)
// 之后再把个人名次写回 results.rank,别处改了重算口径这里要跟着改。
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, recomputeReason(action))
if err != nil {
return entryBatchResultDTO{}, err
}
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
// 奖牌榜与运动员榜看的是 channel_ranks,由 writeLedgerTx 一并重建。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return entryBatchResultDTO{}, err
}
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 {
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
}