272 lines
7.6 KiB
Go
272 lines
7.6 KiB
Go
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
|
|
}
|