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 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+108 -18
View File
@@ -16,16 +16,19 @@ import (
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
award_medals`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
e eventDTO
scheduledAt, venue, note sql.NullString
awardMedals int
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note, &e.AvgMode)
&scheduledAt, &venue, &e.SortOrder, &note, &e.AvgMode, &awardMedals)
e.AwardMedals = awardMedals != 0
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
@@ -124,12 +127,14 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
award_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
boolToInt(in.AwardMedals))
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
@@ -184,15 +189,25 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
}
defer func() { _ = tx.Rollback() }()
// 先读旧值:multiplier / avg_mode / scored / award_medals 这四个字段进算分与奖牌口径,
// 「是否真变」决定要不要重算,拿不到旧值就无从判断。
before, err := eventByIDQ(ctx, tx, id)
if err != nil {
return eventDTO{}, err
}
newMultiplier := multiplierOrOne(in.Multiplier)
res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?,
award_medals = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
in.RecordEligible, in.Scored, newMultiplier, status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
boolToInt(in.AwardMedals), id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
@@ -205,9 +220,30 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
// 只有这四个进公式的字段真的变了才重算:改名 / 换场地这类改动重算全项既慢又会
// 无谓地作废、重写一遍流水。avgMode 之类的空值口径已在 normalize 阶段落成具体值,可直接比。
recompute := multiplierChanged(before.Multiplier, newMultiplier) ||
before.AvgMode != avgMode ||
before.Scored != in.Scored ||
before.AwardMedals != in.AwardMedals
actorID := middleware.User(ctx).ID
if recompute {
score, err := recomputeLedgerTx(ctx, tx, id, 0, actorID, "小项计分配置变更重算")
if err != nil {
return eventDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return eventDTO{}, err
}
}
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if recompute {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return eventDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
@@ -307,6 +343,10 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
//
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
//
// 字段校验与 updateCategory 共用同一套(validateCategoryGender / validateCategoryAges):
// 新建或重排组别时这条路是唯一入口,漏校验就会让 ageMin=500、gender='M' 这样的值直接进库
// (gender 撞 CHECK 报 500,年龄则静默入库),而就地更新路径又拒绝原样保存。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
@@ -331,6 +371,12 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
if strings.TrimSpace(c.Name) == "" {
return nil, badRequest("组别 name 必填", i)
}
if err := validateCategoryGender(c.Gender); err != nil {
return nil, err
}
if err := validateCategoryAges(c.AgeMin, c.AgeMax); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
@@ -375,7 +421,8 @@ func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (ca
}
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
// 两条写入路径(整表替换与就地更新)共用:以前只有就地更新校验,整表替换能建出
// ageMin=500 / ageMax=-3 的组别,脏值进得去、之后就地更新又拒绝原样保存。
func validateCategoryAges(ageMin, ageMax *int) error {
for _, v := range []*int{ageMin, ageMax} {
if v != nil && (*v < 0 || *v > 120) {
@@ -388,6 +435,21 @@ func validateCategoryAges(ageMin, ageMax *int) error {
return nil
}
// validateCategoryGender 组别性别可以留空(= 不限),填了就必须是「男」或「女」。
//
// event_categories.gender 有 CHECK (gender IN ('男','女')),不拦就落成 500 + 原始约束文本;
// 整表替换与就地更新共用这一条,两条路径的 400 语义才一致。
// 刻意不做 TrimSpace:带空格的值按非法拒绝,「校验放行、落库撞 CHECK」比直接报错更难查。
func validateCategoryGender(gender *string) error {
if gender == nil {
return nil
}
if v := *gender; v != "" && v != "男" && v != "女" {
return badRequest("组别 gender 只能是 男 / 女", gender)
}
return nil
}
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
//
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
@@ -414,8 +476,8 @@ func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64,
if name == "" {
return nil, false, badRequest("组别 name 必填", nil)
}
if in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
if err := validateCategoryGender(in.Gender); err != nil {
return nil, false, err
}
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
return nil, false, err
@@ -516,7 +578,8 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
@@ -526,13 +589,18 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
out := make([]channelDTO, 0)
for rows.Next() {
var (
c channelDTO
countToTeam int
c channelDTO
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
c.CountToTeam = boolPtr(countToTeam != 0)
c.CountToMedals = boolPtr(countToMedals != 0)
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
out = append(out, c)
}
return out, rows.Err()
@@ -544,6 +612,12 @@ func channelCountsToTeam(c channelDTO) bool {
return c.CountToTeam == nil || *c.CountToTeam
}
// channelFlag 是通道两个奖牌开关的缺省口径:缺省(null)按 true 处理,
// 与「升级前所有通道都发牌、个人奖牌计入单位榜」的既有行为一致。
func channelFlag(v *bool) bool {
return v == nil || *v
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -567,9 +641,11 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
@@ -579,9 +655,23 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, internal(err)
}
}
// 这是「DELETE 全表 + 重新 INSERT」:通道 id 每次都变,挂在旧 id 上的 channel_ranks
// 会被外键级联清空。不重算就会留下「通道换了、名次是空的」——大屏奖牌榜随之清零。
// 名次与流水一起按新通道重建,并推榜单。团体手工名次不挂通道外键,录入数据不受影响。
actorID := middleware.User(ctx).ID
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "通道配置变更重算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}