补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
6.6 KiB
Go
232 lines
6.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
|
||
}
|
||
|
||
// 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"` // 最近全局流水(脱敏 note)
|
||
}
|
||
|
||
// AdminEconomyStats 管理端经济看板
|
||
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||
st := &PointsEconomyStats{PostsByType: map[string]int64{}}
|
||
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
|
||
}
|
||
today := time.Now().Truncate(24 * time.Hour)
|
||
// 用日期字符串更稳妥(与 checkin 一致用 date)
|
||
if err := s.db.Model(&model.Checkin{}).
|
||
Where("checkin_date::date = CURRENT_DATE").
|
||
Count(&st.CheckinToday).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
_ = today
|
||
|
||
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
|
||
}
|
||
|
||
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
|
||
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,
|
||
})
|
||
}
|
||
return st, nil
|
||
}
|