316 lines
8.6 KiB
Go
316 lines
8.6 KiB
Go
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"`
|
|
}
|
|
|
|
// 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 []LedgerItem `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),
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
var recent []model.PointLedger
|
|
if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
st.RecentLedger = make([]LedgerItem, 0, len(recent))
|
|
for _, r := range recent {
|
|
st.RecentLedger = append(st.RecentLedger, 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,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|