## 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 例 + 语法检查。
278 lines
7.6 KiB
Go
278 lines
7.6 KiB
Go
package rules
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
// ChangeKind 是差异类型。
|
|
type ChangeKind string
|
|
|
|
const (
|
|
ChangeAdded ChangeKind = "added"
|
|
ChangeModified ChangeKind = "modified"
|
|
ChangeRemoved ChangeKind = "removed"
|
|
)
|
|
|
|
// ProfileChange 是一个积分模板的增/改/删。
|
|
type ProfileChange struct {
|
|
Code string `json:"code"`
|
|
Kind ChangeKind `json:"kind"`
|
|
Before *Profile `json:"before,omitempty"`
|
|
After *Profile `json:"after,omitempty"`
|
|
// Fields 列出被修改的字段,便于前端只高亮变化项。
|
|
Fields []string `json:"fields,omitempty"`
|
|
}
|
|
|
|
// ChannelChange 是一个项目计分通道的变化。
|
|
type ChannelChange struct {
|
|
EventCode string `json:"eventCode"`
|
|
EventName string `json:"eventName,omitempty"`
|
|
Kind ChangeKind `json:"kind"`
|
|
Before []Channel `json:"before,omitempty"`
|
|
After []Channel `json:"after,omitempty"`
|
|
}
|
|
|
|
// SettingChange 是一条赛事设置的变化。
|
|
type SettingChange struct {
|
|
Key string `json:"key"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
}
|
|
|
|
// ChangeSet 是导入预览的结果,也是 Apply 的输入。
|
|
type ChangeSet struct {
|
|
Profiles []ProfileChange `json:"profiles,omitempty"`
|
|
Channels []ChannelChange `json:"channels,omitempty"`
|
|
Settings []SettingChange `json:"settings,omitempty"`
|
|
// UnknownEvents 是规则包里出现、但赛事库中不存在的项目编码;Apply 会跳过它们。
|
|
UnknownEvents []string `json:"unknownEvents,omitempty"`
|
|
// Next 是目标规则包;Apply 以它为完整目标状态落库。不参与 JSON 序列化。
|
|
Next *RulePack `json:"-"`
|
|
}
|
|
|
|
// Empty 判断这次导入是否真的会改动任何东西。
|
|
// 只用于预览展示;Apply 始终以 Next 为目标全量同步,不依赖它做短路。
|
|
func (cs *ChangeSet) Empty() bool {
|
|
return cs == nil || (len(cs.Profiles) == 0 && len(cs.Channels) == 0 && len(cs.Settings) == 0)
|
|
}
|
|
|
|
// Diff 计算「当前赛事库 → 目标规则包」的差异,只读不落库。
|
|
func Diff(cur *Snapshot, next *RulePack) (*ChangeSet, error) {
|
|
if cur == nil || cur.Pack == nil {
|
|
return nil, errors.New("rules: 当前快照为空,无法比对差异")
|
|
}
|
|
if next == nil {
|
|
return nil, fmt.Errorf("%w: 目标规则包为空", ErrNotRulePack)
|
|
}
|
|
|
|
target := next.Canonical()
|
|
if err := target.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cs := &ChangeSet{Next: &target}
|
|
cs.Profiles = diffProfiles(cur.Pack, &target)
|
|
|
|
targets, unknown, err := resolveTargets(cur, &target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cs.UnknownEvents = unknown
|
|
cs.Channels = diffChannels(cur, targets)
|
|
cs.Settings = diffSettings(cur.Pack, &target)
|
|
return cs, nil
|
|
}
|
|
|
|
func diffProfiles(cur, next *RulePack) []ProfileChange {
|
|
curIndex := cur.ProfileIndex()
|
|
nextIndex := next.ProfileIndex()
|
|
|
|
codes := make([]string, 0, len(curIndex)+len(nextIndex))
|
|
seen := map[string]bool{}
|
|
for code := range curIndex {
|
|
if !seen[code] {
|
|
seen[code] = true
|
|
codes = append(codes, code)
|
|
}
|
|
}
|
|
for code := range nextIndex {
|
|
if !seen[code] {
|
|
seen[code] = true
|
|
codes = append(codes, code)
|
|
}
|
|
}
|
|
sort.Strings(codes)
|
|
|
|
var out []ProfileChange
|
|
for _, code := range codes {
|
|
before, hasBefore := curIndex[code]
|
|
after, hasAfter := nextIndex[code]
|
|
switch {
|
|
case !hasBefore:
|
|
out = append(out, ProfileChange{Code: code, Kind: ChangeAdded, After: &after})
|
|
case !hasAfter:
|
|
out = append(out, ProfileChange{Code: code, Kind: ChangeRemoved, Before: &before})
|
|
default:
|
|
if fields := profileDiffFields(before, after); len(fields) > 0 {
|
|
out = append(out, ProfileChange{Code: code, Kind: ChangeModified, Before: &before, After: &after, Fields: fields})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func profileDiffFields(a, b Profile) []string {
|
|
var fields []string
|
|
if a.Name != b.Name {
|
|
fields = append(fields, "name")
|
|
}
|
|
if a.AwardRanks != b.AwardRanks {
|
|
fields = append(fields, "awardRanks")
|
|
}
|
|
if a.LastPlaceZero != b.LastPlaceZero {
|
|
fields = append(fields, "lastPlaceZero")
|
|
}
|
|
if a.TieMode != b.TieMode {
|
|
fields = append(fields, "tieMode")
|
|
}
|
|
if a.RecordBonusMode != b.RecordBonusMode {
|
|
fields = append(fields, "recordBonusMode")
|
|
}
|
|
if !rulesEqual(a.Rules, b.Rules) {
|
|
fields = append(fields, "rules")
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func rulesEqual(a, b []Rule) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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 {
|
|
overrideByCode[o.EventCode] = o
|
|
}
|
|
|
|
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
|
|
}
|
|
for _, t := range next.ChannelTemplates {
|
|
if matchEvent(t.Match, ev) {
|
|
targets[ev.Code] = append([]Channel(nil), t.Channels...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var unknown []string
|
|
for _, o := range next.EventOverrides {
|
|
if _, ok := cur.EventByCode(o.EventCode); !ok {
|
|
unknown = append(unknown, o.EventCode)
|
|
}
|
|
}
|
|
sort.Strings(unknown)
|
|
return targets, unknown, nil
|
|
}
|
|
|
|
func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
|
|
curByCode := make(map[string]EventInfo, len(cur.Events))
|
|
for _, ev := range cur.Events {
|
|
curByCode[ev.Code] = ev
|
|
}
|
|
|
|
codes := make([]string, 0, len(targets))
|
|
for code := range targets {
|
|
codes = append(codes, code)
|
|
}
|
|
sort.Strings(codes)
|
|
|
|
var out []ChannelChange
|
|
for _, code := range codes {
|
|
ev := curByCode[code]
|
|
before := ev.Channels
|
|
after := targets[code]
|
|
if channelsEqual(before, after) {
|
|
continue
|
|
}
|
|
kind := ChangeModified
|
|
switch {
|
|
case len(before) == 0:
|
|
kind = ChangeAdded
|
|
case len(after) == 0:
|
|
kind = ChangeRemoved
|
|
}
|
|
out = append(out, ChannelChange{
|
|
EventCode: code,
|
|
EventName: ev.Name,
|
|
Kind: kind,
|
|
Before: append([]Channel(nil), before...),
|
|
After: append([]Channel(nil), after...),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// channelsEqual 逐字段比较目标通道与库中通道。
|
|
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
|
|
func channelsEqual(a, b []Channel) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile {
|
|
return false
|
|
}
|
|
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
|
|
}
|
|
|
|
func diffSettings(cur, next *RulePack) []SettingChange {
|
|
curSettings := settingsFromPack(cur)
|
|
nextSettings := settingsFromPack(next)
|
|
|
|
var out []SettingChange
|
|
for _, key := range settingsKeys() {
|
|
before, after := curSettings[key], nextSettings[key]
|
|
if before != after {
|
|
out = append(out, SettingChange{Key: key, Before: before, After: after})
|
|
}
|
|
}
|
|
return out
|
|
}
|