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
+120
-86
@@ -85,7 +85,13 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
|
||||
teams, err := loadTeams(db)
|
||||
// 排名输入只含启用单位,与 REST 一致。
|
||||
teams, err := loadTeams(db, true)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
|
||||
allTeams, err := loadTeams(db, false)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
@@ -107,24 +113,29 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
Athletes: athletes,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitSettingList(settings["standings.tie_break"]),
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
|
||||
}
|
||||
return StandingsWorkbook{
|
||||
Meta: meta,
|
||||
Team: domain.BuildTeamStandings(in),
|
||||
Medal: domain.BuildMedalStandings(in),
|
||||
Athlete: domain.BuildAthleteStandings(in),
|
||||
}, nil
|
||||
team := domain.BuildTeamStandings(in)
|
||||
medal := domain.BuildMedalStandings(in)
|
||||
athlete := domain.BuildAthleteStandings(in)
|
||||
fillTeamDisplay(team, medal, athlete, allTeams)
|
||||
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
|
||||
}
|
||||
|
||||
func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
|
||||
rows, err := db.Query(`
|
||||
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
|
||||
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
|
||||
query := `
|
||||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE active = 1
|
||||
ORDER BY sort_order, code`)
|
||||
FROM teams`
|
||||
if activeOnly {
|
||||
query += ` WHERE active = 1`
|
||||
}
|
||||
query += ` ORDER BY sort_order, code`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||
}
|
||||
@@ -146,6 +157,66 @@ func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
|
||||
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
|
||||
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
|
||||
byID := make(map[int64]domain.Team, len(teams))
|
||||
for _, t := range teams {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
for i := range team {
|
||||
if t, ok := byID[team[i].TeamID]; ok {
|
||||
if team[i].Code == "" {
|
||||
team[i].Code = t.Code
|
||||
}
|
||||
if team[i].Name == "" {
|
||||
team[i].Name = t.Name
|
||||
}
|
||||
if team[i].ShortName == "" {
|
||||
team[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range medal {
|
||||
if t, ok := byID[medal[i].TeamID]; ok {
|
||||
if medal[i].Code == "" {
|
||||
medal[i].Code = t.Code
|
||||
}
|
||||
if medal[i].Name == "" {
|
||||
medal[i].Name = t.Name
|
||||
}
|
||||
if medal[i].ShortName == "" {
|
||||
medal[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range athlete {
|
||||
if t, ok := byID[athlete[i].TeamID]; ok {
|
||||
if athlete[i].TeamCode == "" {
|
||||
athlete[i].TeamCode = t.Code
|
||||
}
|
||||
if athlete[i].TeamName == "" {
|
||||
athlete[i].TeamName = t.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
|
||||
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
|
||||
}
|
||||
|
||||
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
|
||||
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
|
||||
rows, err := db.Query(`
|
||||
@@ -201,24 +272,22 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
}
|
||||
|
||||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||||
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
|
||||
// 重算分值,也就能保证与正式计分结果完全一致。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
scopes, err := loadChannelScopes(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
|
||||
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events e ON e.id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
|
||||
AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
|
||||
ORDER BY r.event_id, r.rank, r.id`)
|
||||
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
|
||||
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND ev.scored = 1
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取名次: %w", err)
|
||||
}
|
||||
@@ -227,32 +296,23 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
resultValue sql.NullFloat64
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
|
||||
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
if resultValue.Valid {
|
||||
v := resultValue.Float64
|
||||
rr.Rank = rank
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
if rr.TeamID == 0 {
|
||||
continue
|
||||
}
|
||||
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
|
||||
// 因此按 scope 展开成多条明细。
|
||||
for _, sc := range scopes[rr.EventID] {
|
||||
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
|
||||
continue
|
||||
}
|
||||
item := rr
|
||||
item.Scope = sc
|
||||
item.Basis = domain.BasisResult
|
||||
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, item)
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历名次: %w", err)
|
||||
@@ -260,39 +320,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
|
||||
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.Scope)
|
||||
for rows.Next() {
|
||||
var eventID int64
|
||||
var scope domain.Scope
|
||||
if err := rows.Scan(&eventID, &scope); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
|
||||
}
|
||||
if !containsScope(out[eventID], scope) {
|
||||
out[eventID] = append(out[eventID], scope)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsScope(list []domain.Scope, scope domain.Scope) bool {
|
||||
for _, s := range list {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// eventInfo 是导出分项成绩表需要的项目信息。
|
||||
type eventInfo struct {
|
||||
id int64
|
||||
@@ -471,7 +498,7 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if db == nil {
|
||||
return TeamDetailWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
meta, settings, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
@@ -483,7 +510,12 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
|
||||
return TeamDetailWorkbook{
|
||||
Meta: meta,
|
||||
Team: team,
|
||||
Rows: rows,
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
|
||||
@@ -508,7 +540,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
|
||||
l.source_type, l.points, COALESCE(l.void_reason, '')
|
||||
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
@@ -527,11 +559,13 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
for rows.Next() {
|
||||
var r TeamDetailRow
|
||||
var source string
|
||||
var athleteID int64
|
||||
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
|
||||
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
|
||||
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
|
||||
}
|
||||
r.Source = sourceLabel(source)
|
||||
r.individual = athleteID != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
Reference in new issue
Block a user