fix: 审查修复波次(P0x4 + P1x30+)

- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
AskaEth committed 2026-09-09 21:06:40 +08:00
1 parent 8dae63ee70
commit 7f9701c465
61 files changed
+3844 -333

No files matched your search

+4 -5
View File
@@ -55,7 +55,6 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
Aggregate: c.Aggregate,
SortOrder: sortOrder,
}
}
@@ -63,10 +62,9 @@ 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,
Aggregate: c.Aggregate,
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
}
}
@@ -82,6 +80,7 @@ func (p *RulePack) ProfileIndex() map[string]Profile {
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
TieBreak: append([]string(nil), s.TieBreak...),
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
+47
View File
@@ -181,6 +181,10 @@ func TestParseRejectsBadInput(t *testing.T) {
{"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation},
{"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation},
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
// aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。
{"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil},
// 联队归属未实现,规则包里不允许出现相关配置。
{"联队归属未实现", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
}
for _, tc := range cases {
@@ -283,6 +287,9 @@ func TestProfileDomainConversion(t *testing.T) {
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 {
@@ -292,3 +299,43 @@ func TestProfileDomainConversion(t *testing.T) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
// 只读 enabled/mode 而不读 levels,会让破纪录加分永远算 0。
func TestRecordBonusFromSettings(t *testing.T) {
settings := map[string]string{
settingRecordBonusEnabled: "true",
settingRecordBonusMode: "sum",
settingRecordBonusLevels: `[{"code":"meet","name":"赛会纪录","points":2},{"code":"city","name":"市级纪录","points":5}]`,
}
cfg := RecordBonusFromSettings(settings)
if !cfg.Enabled || cfg.Mode != domain.RecordBonusSum {
t.Fatalf("开关/口径错误: %+v", cfg)
}
if len(cfg.Levels) != 2 {
t.Fatalf("档位数 = %d,期望 2(levels 必须被解析)", len(cfg.Levels))
}
if cfg.Levels[1].Code != "city" || cfg.Levels[1].Points != 5 {
t.Fatalf("档位内容错误: %+v", cfg.Levels[1])
}
t.Run("缺省值", func(t *testing.T) {
cfg := RecordBonusFromSettings(map[string]string{})
if cfg.Enabled || cfg.Mode != domain.RecordBonusMax || len(cfg.Levels) != 0 {
t.Fatalf("缺省配置错误: %+v", cfg)
}
})
}
// TestRecordBonusSettingsRoundTrip 验证规则包写入 settings 后还能原样读回档位。
func TestRecordBonusSettingsRoundTrip(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
got := RecordBonusFromSettings(settingsFromPack(pack))
want := pack.RecordBonus.ToDomain()
if !reflect.DeepEqual(got, want) {
t.Fatalf("settings 往返后破纪录配置不一致:\n%+v\n%+v", got, want)
}
}
+6
View File
@@ -90,6 +90,12 @@ func recordBonusFromSettings(m map[string]string) RecordBonus {
return rb
}
// RecordBonusFromSettings 从赛事库 settings 键值对还原破纪录加分配置(含档位)。
// 上层加载器必须经此函数读取 record_bonus.levels,否则档位缺失、破纪录加分恒为 0。
func RecordBonusFromSettings(m map[string]string) domain.RecordBonusConfig {
return recordBonusFromSettings(m).ToDomain()
}
func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment {
return ManualAdjustment{
Enabled: settingBool(m, settingManualEnabled, false),
+13 -23
View File
@@ -9,13 +9,14 @@ const (
)
// 榜单排序键;规则包里写错键名要报错,而不是静默忽略。
// 取值来自 domain,保证领域层排序与规则包校验用的是同一套键名。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
TieBreakPoints = domain.TieBreakPoints
TieBreakGold = domain.TieBreakGold
TieBreakSilver = domain.TieBreakSilver
TieBreakBronze = domain.TieBreakBronze
TieBreakTeamCode = domain.TieBreakTeamCode
TieBreakName = domain.TieBreakName
)
// DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。
@@ -95,11 +96,11 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 固定按同一主体成绩之和汇总;汇总口径不可配置(库里没有对应列)。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"`
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
}
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
@@ -188,31 +189,20 @@ func (p RulePack) Canonical() RulePack {
out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates))
for i, t := range p.ChannelTemplates {
out.ChannelTemplates[i] = t
out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels)
out.ChannelTemplates[i].Channels = append([]Channel(nil), t.Channels...)
}
out.EventOverrides = make([]EventOverride, len(p.EventOverrides))
for i, o := range p.EventOverrides {
out.EventOverrides[i] = o
if o.Channels != nil {
cs := canonicalChannels(*o.Channels)
cs := append([]Channel(nil), (*o.Channels)...)
out.EventOverrides[i].Channels = &cs
}
}
return out
}
func canonicalChannels(in []Channel) []Channel {
out := make([]Channel, len(in))
for i, ch := range in {
out[i] = ch
if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" {
out[i].Aggregate = domain.AggregateSum
}
}
return out
}
func boolOrDefault(p *bool, def bool) bool {
if p == nil {
return def
-9
View File
@@ -226,15 +226,6 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
switch ch.Aggregate {
case "":
case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount:
if ch.Basis != domain.BasisAggregate {
l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate")
}
default:
l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
if seen[key] {