Files
jiang13-bbs/backend/service/points.go

678 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}