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:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -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, ¬e, &e.AvgMode)
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user