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:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in new issue
Block a user