fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修): - 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重, 通道优先级 athlete→team→id - 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一 - aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位 - 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑 - voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks - 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚 - 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400 - Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底 - /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings P1(计分口径 / 契约 / 现场): - standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验 RecordEligible 与 entry 归属;破纪录登记校验已发布成绩 - export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正 (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示 - 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/ recalculatedLedgerRows;登录审计 user_id 修正 - WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId; 实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新 - 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断; REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出 - 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端 points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护; 发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口 - Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII (已在 Meowhome 实跑验证) 自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、 node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// ---------- settings ----------
|
||||
@@ -34,7 +35,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
|
||||
}
|
||||
|
||||
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
return s.settingsSnapshotQ(ctx, s.ev)
|
||||
}
|
||||
|
||||
// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
|
||||
// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
|
||||
func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
|
||||
values, err := loadSettingsMap(ctx, q)
|
||||
if err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
@@ -43,7 +50,7 @@ func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
|
||||
|
||||
if code := values["scoring.default_profile"]; code != "" {
|
||||
var id int64
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
|
||||
err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
|
||||
@@ -114,6 +121,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
if p.RotateSeconds != nil {
|
||||
// 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
|
||||
if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
|
||||
return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
|
||||
}
|
||||
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
@@ -121,6 +132,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
}
|
||||
if p := patch.Standings; p != nil {
|
||||
if p.IndividualToTeamWeight != nil {
|
||||
// 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
|
||||
if *p.IndividualToTeamWeight <= 0 {
|
||||
return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
|
||||
}
|
||||
if err := set("standings.individual_to_team_weight",
|
||||
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
@@ -160,12 +175,26 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
}
|
||||
}
|
||||
|
||||
// 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
|
||||
snapshot, err := s.settingsSnapshotQ(ctx, tx)
|
||||
if err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return dto.EventSettings{}, internal(err)
|
||||
}
|
||||
return s.getSettings(ctx)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
|
||||
const (
|
||||
minRotateSeconds = 5
|
||||
maxRotateSeconds = 120
|
||||
)
|
||||
|
||||
func atofDefault(v string, def float64) float64 {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil {
|
||||
@@ -216,11 +245,28 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
|
||||
Athletes: athletes,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitSettingList(values["standings.tie_break"]),
|
||||
IndividualToTeamWeight: weight,
|
||||
ExcludeTeamEventMedals: exclude,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
|
||||
// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
|
||||
func splitSettingList(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
||||
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
||||
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
|
||||
@@ -363,6 +409,15 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
}
|
||||
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
}
|
||||
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
|
||||
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
|
||||
@@ -401,6 +456,9 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
item.AthleteName = strPtr(athleteName)
|
||||
item.Rank = intPtr(rank)
|
||||
item.ResultText = strPtr(resultText)
|
||||
if item.AthleteID != nil && *item.AthleteID != 0 {
|
||||
item.Points *= weight
|
||||
}
|
||||
out.Items = append(out.Items, item)
|
||||
out.TotalPoints += item.Points
|
||||
}
|
||||
@@ -455,6 +513,9 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
|
||||
default:
|
||||
return recordBreakDTO{}, badRequest("level 非法", in.Level)
|
||||
}
|
||||
if err := s.validateRecordBreakEntry(ctx, in); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
|
||||
@@ -472,6 +533,45 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
|
||||
// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
|
||||
func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
|
||||
var (
|
||||
entryEvent int64
|
||||
recordEligible bool
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
|
||||
in.EntryID).Scan(&entryEvent, &recordEligible)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return badRequest("entryId 不存在", in.EntryID)
|
||||
}
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if entryEvent != in.EventID {
|
||||
return badRequest("entryId 不属于该小项", in.EntryID)
|
||||
}
|
||||
if !recordEligible {
|
||||
return badRequest("该小项未开启破纪录登记", in.EventID)
|
||||
}
|
||||
var (
|
||||
state string
|
||||
status string
|
||||
)
|
||||
err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
|
||||
}
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
|
||||
return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
|
||||
Reference in new issue
Block a user