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
@@ -24,8 +24,8 @@ const latestResultsLimit = 20
|
||||
type Snapshot struct {
|
||||
Version int64 `json:"version"`
|
||||
Settings dto.EventSettings `json:"settings"`
|
||||
Teams []domain.Team `json:"teams"`
|
||||
Events []domain.Event `json:"events"`
|
||||
Teams []dto.Team `json:"teams"`
|
||||
Events []dto.Event `json:"events"`
|
||||
Standings []dto.StandingRow `json:"standings"`
|
||||
Records []Record `json:"records"`
|
||||
LatestResults []dto.EventResultGroup `json:"latestResults"`
|
||||
@@ -61,8 +61,8 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
snap := &Snapshot{
|
||||
Teams: []domain.Team{},
|
||||
Events: []domain.Event{},
|
||||
Teams: []dto.Team{},
|
||||
Events: []dto.Event{},
|
||||
Standings: []dto.StandingRow{},
|
||||
Records: []Record{},
|
||||
LatestResults: []dto.EventResultGroup{},
|
||||
@@ -78,13 +78,15 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
if err := fillSettings(ctx, tx, values, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadTeams(ctx, tx, snap); err != nil {
|
||||
teams, err := loadTeams(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.Teams = dto.TeamsFromDomain(teams)
|
||||
if err := loadEvents(ctx, tx, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadStandings(ctx, tx, snap, values); err != nil {
|
||||
if err := loadStandings(ctx, tx, snap, teams, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadRecords(ctx, tx, snap); err != nil {
|
||||
@@ -142,29 +144,32 @@ func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, sna
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
|
||||
// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
|
||||
func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE active = 1
|
||||
FROM teams
|
||||
ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取单位: %w", err)
|
||||
return nil, fmt.Errorf("读取单位: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]domain.Team, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
t domain.Team
|
||||
active int
|
||||
)
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
|
||||
return fmt.Errorf("解析单位: %w", err)
|
||||
return nil, fmt.Errorf("解析单位: %w", err)
|
||||
}
|
||||
t.Active = active != 0
|
||||
snap.Teams = append(snap.Teams, t)
|
||||
out = append(out, t)
|
||||
}
|
||||
return rows.Err()
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
@@ -204,14 +209,15 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
e.Status = domain.EventStatus(status)
|
||||
e.RecordEligible = recordEligible != 0
|
||||
e.Scored = scored != 0
|
||||
snap.Events = append(snap.Events, e)
|
||||
snap.Events = append(snap.Events, dto.EventFromDomain(e))
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
|
||||
// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
|
||||
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
|
||||
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error {
|
||||
ledger, err := loadLedger(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -220,17 +226,36 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeTeams := make([]domain.Team, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
if t.Active {
|
||||
activeTeams = append(activeTeams, t)
|
||||
}
|
||||
}
|
||||
rows := domain.BuildTeamStandings(domain.StandingsInput{
|
||||
Teams: snap.Teams,
|
||||
Teams: activeTeams,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitTieBreak(values["standings.tie_break"]),
|
||||
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
|
||||
})
|
||||
snap.Standings = dto.StandingRows(rows, snap.Teams)
|
||||
snap.Standings = dto.StandingRows(rows, teams)
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
|
||||
func splitTieBreak(raw string) []string {
|
||||
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
|
||||
}
|
||||
|
||||
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
|
||||
@@ -331,6 +356,7 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
}
|
||||
|
||||
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
|
||||
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
|
||||
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
|
||||
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
@@ -347,7 +373,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published'
|
||||
WHERE r.state = 'published' AND r.status <> 'withdrawn'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
|
||||
LIMIT ?`, latestResultsLimit)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user