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 // 单位总分 TeamOnly float64 // 仅记单位的流水 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 { if weight <= 0 { weight = 1 } out := make(map[int64]TeamPoints) for _, e := range entries { if e.Voided { continue } p := out[e.TeamID] if e.AthleteID == 0 { p.TeamOnly += e.Points } else { p.Individual += e.Points } p.Total = p.TeamOnly + weight*p.Individual out[e.TeamID] = p } return out } // SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。 func SumByAthlete(entries []LedgerEntry) map[int64]float64 { out := make(map[int64]float64) for _, e := range entries { if e.Voided || e.AthleteID == 0 { continue } out[e.AthleteID] += e.Points } 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 }