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

-228
View File
@@ -1,28 +1,5 @@
package domain
import (
"fmt"
"strings"
)
// LedgerOpKind 增量操作类型。
type LedgerOpKind string
const (
LedgerOpAppend LedgerOpKind = "append" // 追加一批流水
LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废
)
// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。
type LedgerOp struct {
Kind LedgerOpKind
Entries []LedgerEntry
SourceType SourceType
SourceID int64
By string
Reason string
}
// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。
type TeamPoints struct {
Total float64 // 单位总分
@@ -30,103 +7,6 @@ type TeamPoints struct {
Individual float64 // 记到个人的流水(原始分,未乘系数)
}
// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。
func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) {
out := make([]LedgerEntry, len(existing), len(existing)+len(adds))
copy(out, existing)
maxID := int64(0)
used := make(map[int64]bool, len(out)+len(adds))
for _, e := range out {
if e.ID > maxID {
maxID = e.ID
}
used[e.ID] = true
}
for _, a := range adds {
if a.ID == 0 {
continue
}
if used[a.ID] {
return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID)
}
used[a.ID] = true
if a.ID > maxID {
maxID = a.ID
}
}
for _, a := range adds {
if a.ID == 0 {
maxID++
a.ID = maxID
}
out = append(out, a)
}
return out, nil
}
// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。
func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), entries...)
for i := range out {
if out[i].ID != id {
continue
}
if out[i].Voided {
return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id)
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
return out, nil
}
return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id)
}
// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。
// 找不到时是空操作:撤销发布重复执行应当幂等。
func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。
func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].EventID != eventID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// ActiveLedger 返回未作废的流水。
func ActiveLedger(entries []LedgerEntry) []LedgerEntry {
out := make([]LedgerEntry, 0, len(entries))
for _, e := range entries {
if !e.Voided {
out = append(out, e)
}
}
return out
}
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
@@ -161,111 +41,3 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
}
return out
}
// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
active := make([]LedgerEntry, 0, len(existing))
for _, e := range existing {
if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
active = append(active, e)
}
}
if sameLedgerSet(active, fresh) {
return append([]LedgerEntry(nil), existing...), nil
}
out := append([]LedgerEntry(nil), existing...)
for i := range out {
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
}
return AppendLedger(out, fresh...)
}
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
// 人工调整由人负责,重算不得触碰。
func recomputableSource(t SourceType) bool {
return t == SourceResult || t == SourceRecordBonus
}
// ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), existing...)
for _, op := range ops {
switch op.Kind {
case LedgerOpAppend:
next, err := AppendLedger(out, op.Entries...)
if err != nil {
return nil, err
}
out = next
case LedgerOpVoidSource:
next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason)
out = next
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind)
}
}
return out, nil
}
// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。
func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) {
if !cfg.Enabled {
return nil, ErrManualAdjustmentDisabled
}
out := make([]LedgerEntry, 0, len(adjustments))
for _, a := range adjustments {
if a.Voided {
continue
}
if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" {
return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID)
}
out = append(out, LedgerEntry{
TeamID: a.TeamID,
EventID: a.EventID,
SourceType: SourceAdjustment,
SourceID: a.ID,
Points: a.Delta,
CreatedBy: a.CreatedBy,
CreatedAt: a.CreatedAt,
})
}
return out, nil
}
// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。
func sameLedgerSet(a, b []LedgerEntry) bool {
if len(a) != len(b) {
return false
}
am := ledgerSetMap(a)
bm := ledgerSetMap(b)
if len(am) != len(bm) {
return false
}
for k, v := range am {
w, ok := bm[k]
if !ok || !nearlyEqual(v, w) {
return false
}
}
return true
}
func ledgerSetMap(entries []LedgerEntry) map[string]float64 {
out := make(map[string]float64, len(entries))
for _, e := range entries {
key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID)
out[key] += e.Points
}
return out
}