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:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+42
View File
@@ -278,6 +278,48 @@ func TestExportRoundTripThroughDB(t *testing.T) {
}
}
// TestApplyClearsChannelsForUnscoredEvent 验证「只排名不计分」的项目导入后固定 0 通道(口径 2):
// 否则它会继承模板通道,把名次带进奖牌榜。
func TestApplyClearsChannelsForUnscoredEvent(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
var indID, profileID int64
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_IND'`).Scan(&indID); err != nil {
t.Fatalf("查询个人项目: %v", err)
}
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("查询个人模板: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, indID, profileID); err != nil {
t.Fatalf("插入个人通道: %v", err)
}
if _, err := db.Exec(`UPDATE events SET scored = 0 WHERE id = ?`, indID); err != nil {
t.Fatalf("关闭计分开关: %v", err)
}
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); err != nil {
t.Fatalf("Apply: %v", err)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 0 {
t.Fatalf("不计分项目的通道数 = %d,期望 0", got)
}
}
func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
-49
View File
@@ -31,23 +31,6 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
}
// ProfileFromDomain 把 domain 模板转回规则包类型。
func ProfileFromDomain(p domain.ScoringProfile) Profile {
rules := make([]Rule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, Rule{Rank: r.Rank, Points: r.Points})
}
return Profile{
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: p.TieMode,
RecordBonusMode: p.RecordBonusMode,
Rules: rules,
}
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
@@ -59,15 +42,6 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
}
}
// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。
func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel {
return Channel{
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
}
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
func (p *RulePack) ProfileIndex() map[string]Profile {
out := make(map[string]Profile, len(p.Profiles))
@@ -77,15 +51,6 @@ func (p *RulePack) ProfileIndex() map[string]Profile {
return out
}
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
TieBreak: append([]string(nil), s.TieBreak...),
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
}
// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。
func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
mode := r.Mode
@@ -99,21 +64,7 @@ func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels}
}
// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。
func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus {
levels := make([]RecordLevel, 0, len(c.Levels))
for _, lv := range c.Levels {
levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels}
}
// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。
func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig {
return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired}
}
// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。
func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment {
return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired}
}
+11 -1
View File
@@ -160,7 +160,7 @@ func rulesEqual(a, b []Rule) bool {
// resolveTargets 把规则包展开成「项目编码 → 目标通道」。
// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。
// 没被规则包提到的项目不出现在结果里,表示「保持原样」。
// 「只排名不计分」的项目固定解析成 0 通道;没被规则包提到的计分项目不出现在结果里,表示「保持原样」。
func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) {
overrideByCode := make(map[string]EventOverride, len(next.EventOverrides))
for _, o := range next.EventOverrides {
@@ -169,6 +169,16 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
targets := make(map[string][]Channel)
for _, ev := range cur.Events {
// 只排名不计分的项目固定 0 通道(devdocs 02 §2.4):否则会继承模板通道,
// 把名次带进奖牌榜。override 显式写了 scored:false 时同样清空。
scored := ev.Scored
if ov, ok := overrideByCode[ev.Code]; ok && ov.Scored != nil {
scored = *ov.Scored
}
if !scored {
targets[ev.Code] = nil
continue
}
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
+56
View File
@@ -201,3 +201,59 @@ func TestResolveTargetsPrecedence(t *testing.T) {
t.Errorf("未知项目识别错误: %v", unknown)
}
}
// TestResolveTargetsClearsChannelsForUnscored 验证「只排名不计分」的项目固定解析成 0 通道(口径 2):
// 否则它会继承模板通道,把名次带进奖牌榜。
func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
cur := currentSnapshot(t)
cur.Events = append(cur.Events, EventInfo{
ID: 4, Code: "EVT_NOSCORE", Name: "只排名示例",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
Scored: false, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}},
})
t.Run("继承模板的项目也被清空", func(t *testing.T) {
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
targets, _, err := resolveTargets(cur, next)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
removed := false
for _, c := range cs.Channels {
if c.EventCode == "EVT_NOSCORE" && c.Kind == ChangeRemoved && len(c.After) == 0 {
removed = true
}
}
if !removed {
t.Errorf("差异预览应显示不计分项目的通道被清空: %+v", cs.Channels)
}
})
t.Run("override 写了通道也清空", func(t *testing.T) {
pack := *cur.Pack
pack.EventOverrides = append(pack.EventOverrides, EventOverride{
EventCode: "EVT_NOSCORE",
Scored: BoolPtr(false),
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
})
targets, _, err := resolveTargets(cur, &pack)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
}
})
}
-19
View File
@@ -270,34 +270,15 @@ func TestProfileDomainConversion(t *testing.T) {
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
}
back := ProfileFromDomain(dp)
if !reflect.DeepEqual(back, pack.Profiles[0]) {
t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
}
if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
t.Errorf("通道往返不一致: %+v", got)
}
in := pack.Standings.ToStandingsInput()
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
t.Errorf("榜单口径转换错误: %+v", in)
}
if !reflect.DeepEqual(in.TieBreak, pack.Standings.TieBreak) {
t.Errorf("tie-break 未传入聚合输入: %+v", in.TieBreak)
}
rb := pack.RecordBonus.ToDomain()
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
t.Errorf("破纪录配置转换错误: %+v", rb)
}
if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位: