feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
231
backend/service/points.go
Normal file
231
backend/service/points.go
Normal file
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user