fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)

## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+21 -6
View File
@@ -85,9 +85,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam())); err != nil {
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
@@ -108,7 +108,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
// 4. 项目开关:只改 override 里显式给出的字段。
for _, ov := range next.EventOverrides {
eventID, ok := eventIDs[ov.EventCode]
if !ok || (ov.Scored == nil && ov.Multiplier == nil) {
if !ok || (ov.Scored == nil && ov.Multiplier == nil && ov.AwardMedals == nil) {
continue
}
scored, err := currentScored(tx, eventID)
@@ -125,8 +125,15 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if ov.Multiplier != nil {
multiplier = *ov.Multiplier
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`,
boolToInt(scored), multiplier, eventID); err != nil {
awardMedals, err := currentAwardMedals(tx, eventID)
if err != nil {
return nil, err
}
if ov.AwardMedals != nil {
awardMedals = *ov.AwardMedals
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ?, award_medals = ? WHERE id = ?`,
boolToInt(scored), multiplier, boolToInt(awardMedals), eventID); err != nil {
return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err)
}
res.EventsUpdated++
@@ -241,6 +248,14 @@ func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) {
return v, nil
}
func currentAwardMedals(tx *sql.Tx, eventID int64) (bool, error) {
var v int
if err := tx.QueryRow(`SELECT award_medals FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return false, fmt.Errorf("读取项目奖牌结算开关: %w", err)
}
return v != 0, nil
}
func boolToInt(v bool) int {
if v {
return 1
+7 -1
View File
@@ -32,7 +32,7 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// countToTeam 缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
out := domain.ScoreChannel{
EventID: eventID,
@@ -44,6 +44,12 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
}
if !c.CountsToMedals() {
out.CountToMedals = BoolPtr(false)
}
if !c.CountsToTeamMedals() {
out.CountToTeamMedals = BoolPtr(false)
}
return out
}
+6
View File
@@ -252,6 +252,12 @@ func channelsEqual(a, b []Channel) bool {
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
if a[i].CountsToMedals() != b[i].CountsToMedals() {
return false
}
if a[i].CountsToTeamMedals() != b[i].CountsToTeamMedals() {
return false
}
}
return true
}
+402
View File
@@ -0,0 +1,402 @@
package rules
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 奖牌结算三个开关(devdocs 17 §三 / §六):
// - Channel.countToMedals / countToTeamMedals(缺省 true)
// - EventOverride.awardMedals(三态,nil = 不动该字段)
// 这里覆盖解析、落库、回读、差异比对四条路,保证「导出 → 导入」幂等。
const medalFlagsPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p1, countToTeam: false, countToMedals: false, countToTeamMedals: false }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: p1 }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: p1 }
`
func TestParseMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0]
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("countToMedals: false 应落到结构体,实际 %+v", ch.CountToMedals)
}
if ch.CountsToMedals() {
t.Error("countToMedals: false 时 CountsToMedals() 应为 false")
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("countToTeamMedals: false 应落到结构体,实际 %+v", ch.CountToTeamMedals)
}
if ch.CountsToTeamMedals() {
t.Error("countToTeamMedals: false 时 CountsToTeamMedals() 应为 false")
}
ind := pack.EventOverrides[0]
if ind.AwardMedals == nil || *ind.AwardMedals {
t.Fatalf("awardMedals: false 应落到结构体,实际 %+v", ind.AwardMedals)
}
if pack.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写 awardMedals 时应保持 nil,实际 %v", *pack.EventOverrides[1].AwardMedals)
}
// 缺省即 true:不写三个键的通道既要保持 nil(不凭空造显式值),又要按 true 生效。
def := pack.EventOverrides[1].Channels
if def == nil || len(*def) != 1 {
t.Fatalf("EVT_TEAM 通道解析错误: %+v", def)
}
if (*def)[0].CountToMedals != nil || (*def)[0].CountToTeamMedals != nil {
t.Errorf("未写奖牌开关时应保持 nil,实际 %+v", (*def)[0])
}
if !(*def)[0].CountsToMedals() || !(*def)[0].CountsToTeamMedals() {
t.Error("未写奖牌开关时应按缺省 true 生效")
}
// 序列化后再解析必须完全一致(三个键要能出现在导出文件里)。
reparsed, err := Parse(mustMarshal(t, pack))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
if !channelsEqual(pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels) {
t.Errorf("模板通道往返不一致:\n%+v\n%+v", pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels)
}
again := reparsed.EventOverrides[0]
if again.AwardMedals == nil || *again.AwardMedals {
t.Errorf("awardMedals 往返不一致: %+v", again.AwardMedals)
}
if reparsed.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写的 awardMedals 往返后应保持 nil,实际 %v", *reparsed.EventOverrides[1].AwardMedals)
}
}
func TestChannelToDomainCarriesMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("ToDomain 应带上 countToMedals: false,实际 %+v", ch.CountToMedals)
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("ToDomain 应带上 countToTeamMedals: false,实际 %+v", ch.CountToTeamMedals)
}
// 缺省的通道不落指针,与 countToTeam 同口径。
def := (*pack.EventOverrides[1].Channels)[0].ToDomain(3, 7, 0)
if def.CountToMedals != nil || def.CountToTeamMedals != nil {
t.Errorf("缺省通道不应写显式值: %+v", def)
}
}
// 库里 award_medals=0、别的都缺省的项目,导出时必须写成只带 awardMedals 的 override,
// 否则「导出 → 导入」会把关闭的奖牌开关丢掉。
func TestLoadExportsAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_IND'`); err != nil {
t.Fatalf("预置奖牌开关: %v", err)
}
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
var ov *EventOverride
for i := range snap.Pack.EventOverrides {
if snap.Pack.EventOverrides[i].EventCode == "EVT_IND" {
ov = &snap.Pack.EventOverrides[i]
}
}
if ov == nil {
t.Fatalf("导出应包含 EVT_IND 的 override: %+v", snap.Pack.EventOverrides)
}
if ov.AwardMedals == nil || *ov.AwardMedals {
t.Fatalf("EVT_IND 应导出 awardMedals: false,实际 %+v", ov.AwardMedals)
}
if ov.Scored != nil || ov.Multiplier != nil || ov.Channels != nil {
t.Errorf("其余字段保持缺省,不该被一起写出来: %+v", ov)
}
// 导出结果重新导入必须无差异。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 落库 → 直接查列 → Load 回读,断言与输入一致,并且导出再导入无差异。
func TestApplyMedalFlagsThenLoadRoundTrip(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 10 }
- code: team_top6
name: 团体项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 15 }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToMedals: false, countToTeamMedals: false }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: team_top6, countToMedals: false }
`
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
// 小项级开关。
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 1 {
t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got)
}
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、团体奖牌并入照旧。
wants := map[string][3]int{
"EVT_IND": {1, 0, 0},
"EVT_TEAM": {1, 0, 1},
}
rows, err := db.Query(`SELECT e.code, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN events e ON e.id = c.event_id ORDER BY e.code, c.sort_order, c.id`)
if err != nil {
t.Fatalf("查询通道: %v", err)
}
defer func() { _ = rows.Close() }()
seen := map[string]int{}
for rows.Next() {
var code string
var countToTeam, countToMedals, countToTeamMedals int
if err := rows.Scan(&code, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("扫描通道: %v", err)
}
want, ok := wants[code]
if !ok {
t.Fatalf("出现预期外的通道: %s", code)
}
got := [3]int{countToTeam, countToMedals, countToTeamMedals}
if got != want {
t.Fatalf("%s 通道开关 = %v,期望 %v", code, got, want)
}
seen[code]++
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历通道: %v", err)
}
if seen["EVT_IND"] != 1 || seen["EVT_TEAM"] != 1 {
t.Fatalf("通道数异常: %v", seen)
}
// Load 回读:三个字段都要还原成「显式 false」而不是缺省 true。
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
ind, ok := snap.EventByCode("EVT_IND")
if !ok {
t.Fatal("Load 结果里缺少 EVT_IND")
}
if ind.AwardMedals {
t.Error("Load 应读出 EVT_IND award_medals = 0")
}
if len(ind.Channels) != 1 {
t.Fatalf("EVT_IND 通道数 = %d,期望 1", len(ind.Channels))
}
if ind.Channels[0].CountsToMedals() || ind.Channels[0].CountsToTeamMedals() {
t.Errorf("EVT_IND 通道应按显式 false 读回: %+v", ind.Channels[0])
}
team, ok := snap.EventByCode("EVT_TEAM")
if !ok {
t.Fatal("Load 结果里缺少 EVT_TEAM")
}
if !team.AwardMedals {
t.Error("Load 应读出 EVT_TEAM award_medals = 1")
}
if team.Channels[0].CountsToMedals() {
t.Error("EVT_TEAM 通道 count_to_medals 应为 false")
}
if !team.Channels[0].CountsToTeamMedals() {
t.Error("EVT_TEAM 未关闭 count_to_team_medals,应保持 true")
}
// 导出再导入必须无差异:显式 false 与缺省 true 不能被读串。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 只写 awardMedals 的 EventOverride 也要生效:判空若只看 scored/multiplier,这条会被整条跳过。
func TestApplyAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 只写奖牌开关 }
profiles:
- { code: individual_top6, name: 个人项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 10 } ] }
- { code: team_top6, name: 团体项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 15 } ] }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
- eventCode: EVT_TEAM
multiplier: 2
`
// 先把 EVT_TEAM 的奖牌开关也关掉,验证「override 不提该字段就不动它」。
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_TEAM'`); err != nil {
t.Fatalf("预置 EVT_TEAM 开关: %v", err)
}
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res, err := Apply(db, &ChangeSet{Next: next})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.EventsUpdated != 2 {
t.Errorf("项目更新数 = %d,期望 2(只写 awardMedals 的也要落库)", res.EventsUpdated)
}
if res.ChannelsWritten != 0 {
t.Errorf("没有模板/通道配置,不该重建通道,实际 %d", res.ChannelsWritten)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 0 {
t.Errorf("override 未写 awardMedals 时不应改动该列,实际 %d", got)
}
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_TEAM'`); got != 2 {
t.Errorf("EVT_TEAM 权重 = %v,期望 2", got)
}
// 种子里的通道既没被删也没被改。
if got := countRows(t, db, "event_score_channels"); got != 1 {
t.Errorf("通道数 = %d,期望 1(未被规则包提到)", got)
}
}
// 通道只差奖牌开关也必须报差异,否则前端预览看不到、用户以为没改动。
func TestDiffDetectsMedalFlagChanges(t *testing.T) {
base := Channel{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}
cases := []struct {
name string
mut func(Channel) Channel
}{
{"countToMedals", func(c Channel) Channel { c.CountToMedals = BoolPtr(false); return c }},
{"countToTeamMedals", func(c Channel) Channel { c.CountToTeamMedals = BoolPtr(false); return c }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{tc.mut(base)}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 1 || cs.Channels[0].EventCode != "EVT_IND" {
t.Fatalf("应报出 EVT_IND 的通道变化,实际 %+v", cs.Channels)
}
if cs.Channels[0].Kind != ChangeModified {
t.Errorf("变化类型 = %q,期望 modified", cs.Channels[0].Kind)
}
})
}
t.Run("缺省与显式 true 不算变化", func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{{
Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6",
CountToMedals: BoolPtr(true), CountToTeamMedals: BoolPtr(true),
}}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 0 {
t.Fatalf("显式 true 与缺省同义,不应报差异: %+v", cs.Channels)
}
})
}
+50 -36
View File
@@ -10,15 +10,16 @@ import (
// EventInfo 是展开通道模板所需的最小项目信息。
type EventInfo struct {
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
Channels []Channel
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
AwardMedals bool
Channels []Channel
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
@@ -64,10 +65,10 @@ func Load(db *sql.DB) (*Snapshot, error) {
return nil, err
}
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/不结算奖牌/权重非 1」的项目
// 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。
for _, e := range events {
needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1
needOverride := len(e.Channels) > 0 || !e.Scored || !e.AwardMedals || e.Multiplier != 1
if !needOverride {
continue
}
@@ -78,6 +79,9 @@ func Load(db *sql.DB) (*Snapshot, error) {
if e.Multiplier != 1 {
ov.Multiplier = Float64Ptr(e.Multiplier)
}
if !e.AwardMedals {
ov.AwardMedals = BoolPtr(false)
}
if len(e.Channels) > 0 {
ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...))
}
@@ -204,7 +208,7 @@ func settingByKey(db *sql.DB, key string) (string, error) {
}
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
@@ -216,34 +220,36 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
index := map[int64]int{}
for rows.Next() {
var (
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
awardMedalsRaw int
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil {
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
events = append(events, EventInfo{
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
AwardMedals: awardMedalsRaw != 0,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
@@ -253,14 +259,16 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
@@ -276,6 +284,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
}
if countToMedals == 0 {
ch.CountToMedals = BoolPtr(false)
}
if countToTeamMedals == 0 {
ch.CountToTeamMedals = BoolPtr(false)
}
events[idx].Channels = append(events[idx].Channels, ch)
}
if err := chRows.Err(); err != nil {
+19 -6
View File
@@ -104,11 +104,23 @@ type Channel struct {
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
// CountToMedals 决定该通道产出的名次是否参与奖牌结算;缺省 true。
// 与项目级 events.award_medals 是「与」的关系(devdocs 17 §4.5)。
CountToMedals *bool `yaml:"countToMedals,omitempty" json:"countToMedals,omitempty"`
// CountToTeamMedals 决定该通道产出的个人奖牌是否并入单位奖牌榜;缺省 true。
// 团体通道(scope=team)的奖牌本来就记在单位头上,不受该开关影响。
CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"`
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
// CountsToMedals 返回补齐缺省值后的奖牌结算开关(缺省 true)。
func (c Channel) CountsToMedals() bool { return boolOrDefault(c.CountToMedals, true) }
// CountsToTeamMedals 返回补齐缺省值后的「个人奖牌并入单位榜」开关(缺省 true)。
func (c Channel) CountsToTeamMedals() bool { return boolOrDefault(c.CountToTeamMedals, true) }
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`
@@ -124,14 +136,15 @@ type ChannelTemplate struct {
// EventOverride 是单个项目的例外配置。
//
// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分
// Scored / Multiplier / AwardMedals 为 nil 表示「不涉及」;Channels 用指针是为了区分
// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。
type EventOverride struct {
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
AwardMedals *bool `yaml:"awardMedals,omitempty" json:"awardMedals,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
}
// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。