package service import ( "errors" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) var ( ErrInsufficientPoints = errors.New("积分不足") ErrInvalidPoints = errors.New("积分数量无效") ) // PointsService 用户积分账户 type PointsService struct { db *gorm.DB } func NewPointsService(db *gorm.DB) *PointsService { return &PointsService{db: db} } // Balance 查询可用积分 func (s *PointsService) Balance(userID uint) (int, error) { var u model.User if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil { return 0, err } return u.Points, nil } // CreditTx 在事务内入账 func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { if delta <= 0 { return 0, ErrInvalidPoints } var u model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Select("id", "points").First(&u, userID).Error; err != nil { return 0, err } bal := u.Points + delta if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil { return 0, err } if err := tx.Create(&model.PointLedger{ UserID: userID, Delta: delta, Balance: bal, Reason: reason, RefType: refType, RefID: refID, Note: note, }).Error; err != nil { return 0, err } return bal, nil } // DebitTx 在事务内扣款 func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { if delta <= 0 { return 0, ErrInvalidPoints } var u model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Select("id", "points").First(&u, userID).Error; err != nil { return 0, err } if u.Points < delta { return 0, ErrInsufficientPoints } bal := u.Points - delta if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil { return 0, err } if err := tx.Create(&model.PointLedger{ UserID: userID, Delta: -delta, Balance: bal, Reason: reason, RefType: refType, RefID: refID, Note: note, }).Error; err != nil { return 0, err } return bal, nil } // Credit 入账 func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { var bal int err := s.db.Transaction(func(tx *gorm.DB) error { var e error bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note) return e }) return bal, err } // Debit 扣款 func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { var bal int err := s.db.Transaction(func(tx *gorm.DB) error { var e error bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note) return e }) return bal, err } // LedgerItem 流水展示项 type LedgerItem struct { ID uint `json:"id"` Delta int `json:"delta"` Balance int `json:"balance"` Reason string `json:"reason"` RefType string `json:"ref_type"` RefID uint `json:"ref_id"` Note string `json:"note"` CreatedAt time.Time `json:"created_at"` } // LedgerParty 管理端流水相关用户摘要 type LedgerParty struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` } // AdminLedgerItem 管理端最近流水:单边入账带 user;用户互转合并为 from → to type AdminLedgerItem struct { ID uint `json:"id"` Delta int `json:"delta"` Balance int `json:"balance"` Reason string `json:"reason"` RefType string `json:"ref_type"` RefID uint `json:"ref_id"` Note string `json:"note"` CreatedAt time.Time `json:"created_at"` User *LedgerParty `json:"user,omitempty"` From *LedgerParty `json:"from,omitempty"` To *LedgerParty `json:"to,omitempty"` } const ( adminRecentLedgerLimit = 15 adminRecentLedgerFetch = 32 ledgerPairMaxDist = 8 ) func absInt(n int) int { if n < 0 { return -n } return n } func ledgerPartyOf(u model.User) LedgerParty { return LedgerParty{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar} } func copyLedgerParty(p LedgerParty) *LedgerParty { if p.ID == 0 { return nil } cp := p return &cp } // complementaryLedgerRef 用户互转对向 ref_type;非互转返回空 func complementaryLedgerRef(reason, refType string) string { switch reason { case model.PointReasonUnlockPost: if refType == "post" { return "post_earn" } if refType == "post_earn" { return "post" } case model.PointReasonDownloadFile: if refType == "attachment" { return "attachment_earn" } if refType == "attachment_earn" { return "attachment" } } return "" } func ledgersComplement(a, b model.PointLedger) bool { comp := complementaryLedgerRef(a.Reason, a.RefType) if comp == "" || b.Reason != a.Reason || b.RefID != a.RefID || b.RefType != comp { return false } if absInt(a.Delta) != absInt(b.Delta) || absInt(a.Delta) == 0 { return false } return (a.Delta > 0) != (b.Delta > 0) } func idDist(a, b uint) int { if a > b { return int(a - b) } return int(b - a) } func findComplementIndex(rows []model.PointLedger, i int, used []bool) int { r := rows[i] if complementaryLedgerRef(r.Reason, r.RefType) == "" { return -1 } best := -1 bestDist := ledgerPairMaxDist + 1 for j := range rows { if j == i || used[j] || !ledgersComplement(r, rows[j]) { continue } d := idDist(r.ID, rows[j].ID) if d == 0 || d > ledgerPairMaxDist || d >= bestDist { continue } bestDist = d best = j } return best } // pairLedgerIndices 将用户互转的支出/收益配成一对;返回 [本条, 对向](无对向则为 -1) func pairLedgerIndices(rows []model.PointLedger) [][2]int { used := make([]bool, len(rows)) out := make([][2]int, 0, len(rows)) for i := range rows { if used[i] { continue } j := findComplementIndex(rows, i, used) if j >= 0 { used[i] = true used[j] = true out = append(out, [2]int{i, j}) continue } used[i] = true out = append(out, [2]int{i, -1}) } return out } func mergePairedLedger(a, b model.PointLedger) model.PointLedger { newer, older := a, b if b.ID > a.ID { newer, older = b, a } out := newer out.Delta = absInt(newer.Delta) if newer.Delta < 0 { out.Delta = absInt(older.Delta) } debit, credit := a, b if a.Delta > 0 { debit, credit = b, a } if debit.Note != "" { out.Note = debit.Note } else { out.Note = credit.Note } return out } func transferEnds(a, b model.PointLedger) (fromID, toID uint) { if a.Delta < 0 { return a.UserID, b.UserID } if b.Delta < 0 { return b.UserID, a.UserID } if a.Delta > 0 { return b.UserID, a.UserID } return a.UserID, b.UserID } // Ledger 分页查询本人积分流水 func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } var total int64 q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID) if err := q.Count(&total).Error; err != nil { return nil, 0, err } var rows []model.PointLedger if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { return nil, 0, err } out := make([]LedgerItem, 0, len(rows)) for _, r := range rows { out = append(out, LedgerItem{ ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, }) } return out, total, nil } // DeltaDay 单日积分发放与消耗 type DeltaDay struct { Date string `json:"date"` Granted int64 `json:"granted"` Spent int64 `json:"spent"` } // PointsEconomyStats 全站积分与类型帖概览(管理端) type PointsEconomyStats struct { TotalBalance int64 `json:"total_balance"` LedgerCount int64 `json:"ledger_count"` CheckinToday int64 `json:"checkin_today"` OpenBounties int64 `json:"open_bounties"` EscrowedPoints int64 `json:"escrowed_points"` PostsByType map[string]int64 `json:"posts_by_type"` RecentLedger []AdminLedgerItem `json:"recent_ledger"` CheckinSeries []DayCount `json:"checkin_series"` LedgerByReason []NamedCount `json:"ledger_by_reason"` DeltaSeries []DeltaDay `json:"delta_series"` NetDelta7d int64 `json:"net_delta_7d"` } // AdminEconomyStats 管理端经济看板 func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { st := &PointsEconomyStats{ PostsByType: map[string]int64{}, CheckinSeries: make([]DayCount, 0), LedgerByReason: make([]NamedCount, 0), DeltaSeries: make([]DeltaDay, 0), RecentLedger: make([]AdminLedgerItem, 0), } if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL"). Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil { return nil, err } if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.Checkin{}). Where("checkin_date::date = CURRENT_DATE"). Count(&st.CheckinToday).Error; err != nil { return nil, err } type row struct { PostType string Cnt int64 } var rows []row if err := s.db.Model(&model.Post{}). Select("post_type, count(*) as cnt"). Where("deleted_at IS NULL"). Group("post_type").Scan(&rows).Error; err != nil { return nil, err } for _, r := range rows { pt := model.NormalizePostType(r.PostType) st.PostsByType[pt] += r.Cnt } var bountyPosts []model.Post if err := s.db.Select("id, type_meta"). Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). Find(&bountyPosts).Error; err != nil { return nil, err } for _, p := range bountyPosts { m, err := parseBountyMeta(p.TypeMeta) if err != nil { continue } if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired { st.OpenBounties++ st.EscrowedPoints += int64(m.Points) } } recent, err := s.buildAdminRecentLedger() if err != nil { return nil, err } st.RecentLedger = recent now := time.Now() day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) start14 := day0.AddDate(0, 0, -13) start7 := day0.AddDate(0, 0, -6) type dayCnt struct { Day string Cnt int64 } var checkinRows []dayCnt if err := s.db.Raw(` SELECT to_char(checkin_date, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM checkins WHERE checkin_date >= ? AND checkin_date <= ? GROUP BY day ORDER BY day`, start14, day0).Scan(&checkinRows).Error; err != nil { return nil, err } checkinMap := map[string]int64{} for _, r := range checkinRows { checkinMap[r.Day] = r.Cnt } st.CheckinSeries = fillDaySeries(start14, day0, checkinMap) type reasonRow struct { Reason string Cnt int64 } var reasons []reasonRow if err := s.db.Model(&model.PointLedger{}). Select("reason, count(*) as cnt"). Group("reason"). Order("cnt DESC"). Limit(12).Scan(&reasons).Error; err != nil { return nil, err } for _, r := range reasons { st.LedgerByReason = append(st.LedgerByReason, NamedCount{ Key: r.Reason, Label: r.Reason, Count: r.Cnt, }) } type deltaRow struct { Day string Granted int64 Spent int64 } var deltas []deltaRow if err := s.db.Raw(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS granted, COALESCE(SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END), 0) AS spent FROM point_ledgers WHERE created_at >= ? AND created_at < ? GROUP BY day ORDER BY day`, start14, day0.AddDate(0, 0, 1)).Scan(&deltas).Error; err != nil { return nil, err } deltaMap := map[string]DeltaDay{} for _, r := range deltas { deltaMap[r.Day] = DeltaDay{Date: r.Day, Granted: r.Granted, Spent: r.Spent} } for d := start14; !d.After(day0); d = d.AddDate(0, 0, 1) { k := dateKey(d) dd := deltaMap[k] dd.Date = k st.DeltaSeries = append(st.DeltaSeries, dd) if !d.Before(start7) { st.NetDelta7d += dd.Granted - dd.Spent } } return st, nil } func (s *PointsService) lookupLedgerMate(r model.PointLedger) (*model.PointLedger, error) { comp := complementaryLedgerRef(r.Reason, r.RefType) if comp == "" || r.RefID == 0 { return nil, nil } amt := absInt(r.Delta) if amt == 0 { return nil, nil } lo, hi := uint(1), r.ID+uint(ledgerPairMaxDist) if r.ID > uint(ledgerPairMaxDist) { lo = r.ID - uint(ledgerPairMaxDist) } var mates []model.PointLedger q := s.db.Where("reason = ? AND ref_id = ? AND ref_type = ? AND id BETWEEN ? AND ? AND id <> ?", r.Reason, r.RefID, comp, lo, hi, r.ID) if r.Delta > 0 { q = q.Where("delta = ?", -amt) } else { q = q.Where("delta = ?", amt) } if err := q.Find(&mates).Error; err != nil { return nil, err } best := -1 bestDist := ledgerPairMaxDist + 1 for i := range mates { d := idDist(r.ID, mates[i].ID) if d == 0 || d > ledgerPairMaxDist || d >= bestDist { continue } bestDist = d best = i } if best < 0 { return nil, nil } return &mates[best], nil } func (s *PointsService) loadLedgerParties(ids []uint) (map[uint]LedgerParty, error) { out := map[uint]LedgerParty{} if len(ids) == 0 { return out, nil } seen := map[uint]struct{}{} uniq := make([]uint, 0, len(ids)) for _, id := range ids { if id == 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} uniq = append(uniq, id) } if len(uniq) == 0 { return out, nil } var users []model.User if err := s.db.Unscoped().Select("id", "username", "nickname", "avatar"). Where("id IN ?", uniq).Find(&users).Error; err != nil { return nil, err } for _, u := range users { out[u.ID] = ledgerPartyOf(u) } return out, nil } func (s *PointsService) bountyPayers(rows []model.PointLedger) (map[uint]uint, error) { ids := make([]uint, 0) seen := map[uint]struct{}{} for _, r := range rows { if r.Reason != model.PointReasonBountyAward || r.RefType != "post" || r.RefID == 0 { continue } if _, ok := seen[r.RefID]; ok { continue } seen[r.RefID] = struct{}{} ids = append(ids, r.RefID) } out := map[uint]uint{} if len(ids) == 0 { return out, nil } var posts []model.Post if err := s.db.Unscoped().Select("id", "user_id").Where("id IN ?", ids).Find(&posts).Error; err != nil { return nil, err } for _, p := range posts { out[p.ID] = p.UserID } return out, nil } func (s *PointsService) buildAdminRecentLedger() ([]AdminLedgerItem, error) { out := make([]AdminLedgerItem, 0, adminRecentLedgerLimit) var recent []model.PointLedger if err := s.db.Order("id DESC").Limit(adminRecentLedgerFetch).Find(&recent).Error; err != nil { return nil, err } if len(recent) == 0 { return out, nil } pairs := pairLedgerIndices(recent) outside := map[int]model.PointLedger{} for _, p := range pairs { if p[1] >= 0 { continue } r := recent[p[0]] if complementaryLedgerRef(r.Reason, r.RefType) == "" { continue } mate, err := s.lookupLedgerMate(r) if err != nil { return nil, err } if mate != nil { outside[p[0]] = *mate } } payers, err := s.bountyPayers(recent) if err != nil { return nil, err } userIDs := make([]uint, 0, len(recent)+len(outside)+len(payers)) for _, r := range recent { userIDs = append(userIDs, r.UserID) } for _, m := range outside { userIDs = append(userIDs, m.UserID) } for _, uid := range payers { userIDs = append(userIDs, uid) } parties, err := s.loadLedgerParties(userIDs) if err != nil { return nil, err } party := func(id uint) *LedgerParty { if id == 0 { return nil } if p, ok := parties[id]; ok { return copyLedgerParty(p) } return &LedgerParty{ID: id, Nickname: "已注销"} } itemFrom := func(r model.PointLedger) AdminLedgerItem { return AdminLedgerItem{ ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, User: party(r.UserID), } } for _, p := range pairs { if len(out) >= adminRecentLedgerLimit { break } a := recent[p[0]] var mate *model.PointLedger if p[1] >= 0 { m := recent[p[1]] mate = &m } else if m, ok := outside[p[0]]; ok { mate = &m } if mate != nil { merged := mergePairedLedger(a, *mate) fromID, toID := transferEnds(a, *mate) item := itemFrom(merged) item.Delta = absInt(merged.Delta) item.User = nil item.From = party(fromID) item.To = party(toID) out = append(out, item) continue } item := itemFrom(a) if a.Reason == model.PointReasonBountyAward && a.Delta > 0 { if payerID := payers[a.RefID]; payerID > 0 && payerID != a.UserID { item.From = party(payerID) item.To = party(a.UserID) item.User = nil item.Delta = a.Delta } } out = append(out, item) } return out, nil }