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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user