feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由 - store: system.db + event.db 两套迁移,业务表 + 种子数据 - domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试 - web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端) - console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
1 parent
a61d793d79
commit
7d5d94f815
76 files changed
+10131
No files matched your search
@@ -0,0 +1,256 @@
|
||||
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 {
|
||||
active = append(active, e)
|
||||
}
|
||||
}
|
||||
if sameLedgerSet(active, fresh) {
|
||||
return append([]LedgerEntry(nil), existing...), nil
|
||||
}
|
||||
|
||||
out, _ := VoidByEvent(existing, eventID, by, reason)
|
||||
return AppendLedger(out, fresh...)
|
||||
}
|
||||
|
||||
// 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