增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,12 +2,18 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
// 最近访问写入节流,避免每次 API 都打库
|
||||
const lastAccessTouchInterval = 5 * time.Minute
|
||||
|
||||
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
|
||||
|
||||
const TokenExpire = 7 * 24 * time.Hour
|
||||
|
||||
type Claims struct {
|
||||
@@ -100,7 +106,7 @@ func (s *AuthService) Login(username, password, clientIP string) (string, *model
|
||||
return token, &user, err
|
||||
}
|
||||
|
||||
// recordLogin 记录上次登录时间与 IP(失败不影响登录)
|
||||
// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录)
|
||||
func (s *AuthService) recordLogin(user *model.User, clientIP string) {
|
||||
now := time.Now()
|
||||
ip := clientIP
|
||||
@@ -108,11 +114,29 @@ func (s *AuthService) recordLogin(user *model.User, clientIP string) {
|
||||
ip = ip[:45]
|
||||
}
|
||||
_ = model.DB.Model(user).Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": ip,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": ip,
|
||||
"last_access_at": now,
|
||||
}).Error
|
||||
user.LastLoginAt = &now
|
||||
user.LastLoginIP = ip
|
||||
user.LastAccessAt = &now
|
||||
lastAccessTouchCache.Store(user.ID, now)
|
||||
}
|
||||
|
||||
// TouchLastAccess 记录最近访问时间(节流写入,失败忽略)
|
||||
func (s *AuthService) TouchLastAccess(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if v, ok := lastAccessTouchCache.Load(userID); ok {
|
||||
if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
lastAccessTouchCache.Store(userID, now)
|
||||
_ = model.DB.Model(&model.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT
|
||||
|
||||
272
service/badge.go
Normal file
272
service/badge.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BadgeService 徽章定义与发放
|
||||
type BadgeService struct{}
|
||||
|
||||
func NewBadgeService() *BadgeService { return &BadgeService{} }
|
||||
|
||||
// ListDefs 列出徽章定义
|
||||
func (s *BadgeService) ListDefs(includeDisabled bool) ([]model.BadgeDef, error) {
|
||||
q := model.DB.Order("sort_order asc, id asc")
|
||||
if !includeDisabled {
|
||||
q = q.Where("enabled = ?", true)
|
||||
}
|
||||
var rows []model.BadgeDef
|
||||
err := q.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// UpsertDef 创建或更新徽章定义(按 code)
|
||||
func (s *BadgeService) UpsertDef(def *model.BadgeDef) error {
|
||||
if def.Code == "" || def.Name == "" {
|
||||
return errors.New("徽章代码与名称不能为空")
|
||||
}
|
||||
if def.Kind != model.BadgeKindAuto && def.Kind != model.BadgeKindLimited {
|
||||
return errors.New("无效的徽章类型")
|
||||
}
|
||||
var existing model.BadgeDef
|
||||
err := model.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ID == 0 {
|
||||
return model.DB.Create(def).Error
|
||||
}
|
||||
def.ID = existing.ID
|
||||
return model.DB.Model(&existing).Updates(map[string]interface{}{
|
||||
"name": def.Name,
|
||||
"description": def.Description,
|
||||
"icon": def.Icon,
|
||||
"kind": def.Kind,
|
||||
"metric": def.Metric,
|
||||
"threshold": def.Threshold,
|
||||
"sort_order": def.SortOrder,
|
||||
"enabled": def.Enabled,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// AwardLimited 站长颁发限定徽章
|
||||
func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
|
||||
var def model.BadgeDef
|
||||
if err := model.DB.First(&def, badgeID).Error; err != nil {
|
||||
return errors.New("徽章不存在")
|
||||
}
|
||||
if def.Kind != model.BadgeKindLimited {
|
||||
return errors.New("仅可颁发限定徽章")
|
||||
}
|
||||
if !def.Enabled {
|
||||
return errors.New("徽章已停用")
|
||||
}
|
||||
var n int64
|
||||
model.DB.Model(&model.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n)
|
||||
if n > 0 {
|
||||
return errors.New("用户已拥有该徽章")
|
||||
}
|
||||
return model.DB.Create(&model.UserBadge{
|
||||
UserID: userID,
|
||||
BadgeID: badgeID,
|
||||
AwardedAt: time.Now(),
|
||||
AwardedBy: adminID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Revoke 收回徽章
|
||||
func (s *BadgeService) Revoke(userID, badgeID uint) error {
|
||||
res := model.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&model.UserBadge{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("用户未拥有该徽章")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUserBadges 用户已获徽章(含定义)
|
||||
func (s *BadgeService) ListUserBadges(userID uint) ([]model.UserBadge, error) {
|
||||
var rows []model.UserBadge
|
||||
err := model.DB.Preload("Badge").Where("user_id = ?", userID).
|
||||
Order("awarded_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// BadgeViews 转为展示视图(最多 limit 枚,0=全部)
|
||||
func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView {
|
||||
out := make([]model.UserBadgeView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Badge.ID == 0 || !r.Badge.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.UserBadgeView{
|
||||
Code: r.Badge.Code,
|
||||
Name: r.Badge.Name,
|
||||
Description: r.Badge.Description,
|
||||
Icon: r.Badge.Icon,
|
||||
Kind: r.Badge.Kind,
|
||||
})
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EvaluateAuto 检查并授予符合条件的自动徽章
|
||||
func (s *BadgeService) EvaluateAuto(userID uint) error {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var defs []model.BadgeDef
|
||||
if err := model.DB.Where("kind = ? AND enabled = ?", model.BadgeKindAuto, true).Find(&defs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tenureDays := int(time.Since(user.CreatedAt).Hours() / 24)
|
||||
var likes int64
|
||||
_ = model.DB.Model(&model.Post{}).
|
||||
Select("COALESCE(SUM(like_count), 0)").
|
||||
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
|
||||
Scan(&likes).Error
|
||||
income := user.CreatorIncomeTotal
|
||||
|
||||
owned := map[uint]bool{}
|
||||
var existing []model.UserBadge
|
||||
_ = model.DB.Where("user_id = ?", userID).Find(&existing).Error
|
||||
for _, e := range existing {
|
||||
owned[e.BadgeID] = true
|
||||
}
|
||||
|
||||
for _, d := range defs {
|
||||
if owned[d.ID] {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
switch d.Metric {
|
||||
case model.BadgeMetricTenureDays:
|
||||
ok = tenureDays >= d.Threshold
|
||||
case model.BadgeMetricLikesReceived:
|
||||
ok = int(likes) >= d.Threshold
|
||||
case model.BadgeMetricCreatorIncome:
|
||||
ok = income >= d.Threshold
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_ = model.DB.Create(&model.UserBadge{
|
||||
UserID: userID,
|
||||
BadgeID: d.ID,
|
||||
AwardedAt: time.Now(),
|
||||
AwardedBy: 0,
|
||||
}).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚)
|
||||
func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
|
||||
if len(users) == 0 {
|
||||
return
|
||||
}
|
||||
if perUser <= 0 {
|
||||
perUser = 3
|
||||
}
|
||||
ids := make([]uint, 0, len(users))
|
||||
seen := map[uint]bool{}
|
||||
for _, u := range users {
|
||||
if u == nil || u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
u.Level = model.LevelFromExp(u.Exp)
|
||||
if !seen[u.ID] {
|
||||
seen[u.ID] = true
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
var rows []model.UserBadge
|
||||
_ = model.DB.Preload("Badge").Where("user_id IN ?", ids).
|
||||
Order("awarded_at desc").Find(&rows).Error
|
||||
grouped := map[uint][]model.UserBadgeView{}
|
||||
for _, r := range rows {
|
||||
if r.Badge.ID == 0 || !r.Badge.Enabled {
|
||||
continue
|
||||
}
|
||||
list := grouped[r.UserID]
|
||||
if len(list) >= perUser {
|
||||
continue
|
||||
}
|
||||
list = append(list, model.UserBadgeView{
|
||||
Code: r.Badge.Code,
|
||||
Name: r.Badge.Name,
|
||||
Description: r.Badge.Description,
|
||||
Icon: r.Badge.Icon,
|
||||
Kind: r.Badge.Kind,
|
||||
})
|
||||
grouped[r.UserID] = list
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == nil || u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
u.Badges = grouped[u.ID]
|
||||
}
|
||||
}
|
||||
|
||||
// AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要
|
||||
func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser int) {
|
||||
users := make([]*model.User, 0, len(posts))
|
||||
for i := range posts {
|
||||
if posts[i].User.ID > 0 {
|
||||
users = append(users, &posts[i].User)
|
||||
}
|
||||
}
|
||||
s.AttachBadgeSummaries(users, perUser)
|
||||
}
|
||||
|
||||
// AttachBadgeSummariesOnComments 给评论作者填充徽章摘要
|
||||
func (s *BadgeService) AttachBadgeSummariesOnComments(comments []model.Comment, perUser int) {
|
||||
users := make([]*model.User, 0, len(comments))
|
||||
for i := range comments {
|
||||
if comments[i].User.ID > 0 {
|
||||
users = append(users, &comments[i].User)
|
||||
}
|
||||
}
|
||||
s.AttachBadgeSummaries(users, perUser)
|
||||
}
|
||||
|
||||
// AddExp 增加经验(不可为负消耗;delta<=0 忽略)
|
||||
func AddExp(userID uint, delta int) {
|
||||
if userID == 0 || delta <= 0 {
|
||||
return
|
||||
}
|
||||
_ = model.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
|
||||
}
|
||||
|
||||
// SetUserLevel 站长设等级(调整 Exp 到门槛)
|
||||
func SetUserLevel(userID uint, level int) error {
|
||||
if level < 1 || level > model.MaxLevel() {
|
||||
return errors.New("等级须在 1–10")
|
||||
}
|
||||
exp := model.ExpForLevel(level)
|
||||
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("exp", exp).Error
|
||||
}
|
||||
|
||||
// SetVerified 设置认证
|
||||
func SetVerified(userID uint, verified bool) error {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
return model.DB.Model(&user).Update("verified", verified).Error
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
}
|
||||
|
||||
status := model.ContentStatusPending
|
||||
if user.Role == model.RoleAdmin {
|
||||
if user.SkipsModeration() {
|
||||
status = model.ContentStatusPublished
|
||||
}
|
||||
|
||||
@@ -231,7 +231,13 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
IsPrivate: in.IsPrivate,
|
||||
Status: status,
|
||||
}
|
||||
return comment, model.DB.Create(comment).Error
|
||||
if err := model.DB.Create(comment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == model.ContentStatusPublished {
|
||||
AddExp(in.UserID, 2)
|
||||
}
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// SetStatus 设置评论审核状态
|
||||
@@ -241,6 +247,11 @@ func (s *CommentService) SetStatus(commentID uint, status string) error {
|
||||
default:
|
||||
return errors.New("无效的审核状态")
|
||||
}
|
||||
var comment model.Comment
|
||||
if err := model.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
prev := comment.Status
|
||||
res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -248,6 +259,9 @@ func (s *CommentService) SetStatus(commentID uint, status string) error {
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
if status == model.ContentStatusPublished && prev != model.ContentStatusPublished && comment.UserID > 0 {
|
||||
AddExp(comment.UserID, 2)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,7 +346,7 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
||||
return s.AdminDelete(commentID)
|
||||
}
|
||||
|
||||
func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, bool, error) {
|
||||
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
|
||||
var comment model.Comment
|
||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||
return "", false, ErrCommentNotFound
|
||||
@@ -369,7 +383,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"content": content}
|
||||
if !isAdmin {
|
||||
if !skipModeration {
|
||||
updates["status"] = model.ContentStatusPending
|
||||
enteredPending = true
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ func RedactReplyOnlyHTML(html string) string {
|
||||
return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only")
|
||||
}
|
||||
|
||||
// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见与回复可见正文
|
||||
// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见、回复可见与积分解锁正文
|
||||
func RedactGatedPostHTML(html string) string {
|
||||
return RedactReplyOnlyHTML(RedactMembersOnlyHTML(html))
|
||||
return RedactPointsOnlyHTML(RedactReplyOnlyHTML(RedactMembersOnlyHTML(html)), nil)
|
||||
}
|
||||
|
||||
func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
|
||||
|
||||
272
service/points.go
Normal file
272
service/points.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientPoints = errors.New("积分不足")
|
||||
ErrAlreadyCheckedIn = errors.New("今日已签到")
|
||||
ErrAlreadyLottery = errors.New("今日已抽奖")
|
||||
ErrInvalidPointsDelta = errors.New("无效的积分变动")
|
||||
)
|
||||
|
||||
// PointsService 积分钱包、签到、抽奖
|
||||
type PointsService struct{}
|
||||
|
||||
func NewPointsService() *PointsService { return &PointsService{} }
|
||||
|
||||
func todayLocal() string {
|
||||
return time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额
|
||||
func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
if delta == 0 {
|
||||
var u model.User
|
||||
if err := tx.Select("points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.Points, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
newBal := user.Points + delta
|
||||
if newBal < 0 {
|
||||
return 0, ErrInsufficientPoints
|
||||
}
|
||||
if err := tx.Model(&user).Update("points", newBal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
led := model.PointLedger{
|
||||
UserID: userID,
|
||||
Delta: delta,
|
||||
Balance: newBal,
|
||||
Reason: reason,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Note: note,
|
||||
}
|
||||
if err := tx.Create(&led).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return newBal, nil
|
||||
}
|
||||
|
||||
// AdjustPoints 独立事务调整积分
|
||||
func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
var bal int
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note)
|
||||
return e
|
||||
})
|
||||
return bal, err
|
||||
}
|
||||
|
||||
// AdminAdjust 站长调账
|
||||
func (s *PointsService) AdminAdjust(userID uint, delta int, note string) (int, error) {
|
||||
if delta == 0 {
|
||||
return 0, ErrInvalidPointsDelta
|
||||
}
|
||||
return s.AdjustPoints(userID, delta, model.PointReasonAdminAdjust, "admin", 0, note)
|
||||
}
|
||||
|
||||
// CheckInStatus 今日签到状态
|
||||
type CheckInStatus struct {
|
||||
CheckedIn bool `json:"checked_in"`
|
||||
Streak int `json:"streak"`
|
||||
TodayPoints int `json:"today_points"` // 若已签到为实得;否则为预计可得
|
||||
Day string `json:"day"`
|
||||
}
|
||||
|
||||
func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) {
|
||||
day := todayLocal()
|
||||
st := CheckInStatus{Day: day}
|
||||
var row model.CheckIn
|
||||
err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if row.ID > 0 {
|
||||
st.CheckedIn = true
|
||||
st.Streak = row.Streak
|
||||
st.TodayPoints = row.Points
|
||||
return st, nil
|
||||
}
|
||||
streak := s.computeNextStreak(userID, day)
|
||||
st.Streak = streak
|
||||
st.TodayPoints = checkInReward(streak)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *PointsService) computeNextStreak(userID uint, today string) int {
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
var prev model.CheckIn
|
||||
model.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev)
|
||||
if prev.ID > 0 {
|
||||
return prev.Streak + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func checkInReward(streak int) int {
|
||||
// 基础 5,连续每日 +1,封顶 15
|
||||
pts := 5 + (streak - 1)
|
||||
if pts > 15 {
|
||||
pts = 15
|
||||
}
|
||||
if pts < 5 {
|
||||
pts = 5
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// CheckIn 每日签到
|
||||
func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) {
|
||||
day := todayLocal()
|
||||
var out CheckInStatus
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.CheckIn
|
||||
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
return ErrAlreadyCheckedIn
|
||||
}
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
var prev model.CheckIn
|
||||
_ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error
|
||||
streak := 1
|
||||
if prev.ID > 0 {
|
||||
streak = prev.Streak + 1
|
||||
}
|
||||
pts := checkInReward(streak)
|
||||
row := model.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil {
|
||||
return err
|
||||
}
|
||||
out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// LotteryPrize 奖池项
|
||||
type LotteryPrize struct {
|
||||
Points int `json:"points"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
var defaultLotteryPool = []LotteryPrize{
|
||||
{Points: 0, Weight: 40},
|
||||
{Points: 2, Weight: 30},
|
||||
{Points: 5, Weight: 18},
|
||||
{Points: 10, Weight: 10},
|
||||
{Points: 20, Weight: 2},
|
||||
}
|
||||
|
||||
// LotteryStatus 抽奖状态
|
||||
type LotteryStatus struct {
|
||||
Drawn bool `json:"drawn"`
|
||||
Points int `json:"points"` // 今日已抽中
|
||||
Day string `json:"day"`
|
||||
Pool []LotteryPrize `json:"pool"`
|
||||
Cost int `json:"cost"` // 抽奖消耗,首版 0
|
||||
}
|
||||
|
||||
func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) {
|
||||
day := todayLocal()
|
||||
st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0}
|
||||
var row model.LotteryDraw
|
||||
if err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
if row.ID > 0 {
|
||||
st.Drawn = true
|
||||
st.Points = row.Points
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func pickLottery(pool []LotteryPrize) (int, error) {
|
||||
total := 0
|
||||
for _, p := range pool {
|
||||
total += p.Weight
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0, errors.New("奖池无效")
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := int(n.Int64())
|
||||
for _, p := range pool {
|
||||
if v < p.Weight {
|
||||
return p.Points, nil
|
||||
}
|
||||
v -= p.Weight
|
||||
}
|
||||
return pool[len(pool)-1].Points, nil
|
||||
}
|
||||
|
||||
// DrawLottery 每日抽奖
|
||||
func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
|
||||
day := todayLocal()
|
||||
var out LotteryStatus
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.LotteryDraw
|
||||
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
return ErrAlreadyLottery
|
||||
}
|
||||
pts, err := pickLottery(defaultLotteryPool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := model.LotteryDraw{UserID: userID, Day: day, Points: pts}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pts > 0 {
|
||||
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out = LotteryStatus{Drawn: true, Points: pts, Day: day, Pool: defaultLotteryPool, Cost: 0}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListLedger 积分流水
|
||||
func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLedger, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.PointLedger{}).Where("user_id = ?", userID).Count(&total)
|
||||
var rows []model.PointLedger
|
||||
err := model.DB.Where("user_id = ?", userID).Order("id desc").
|
||||
Offset((page - 1) * size).Limit(size).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
@@ -330,7 +330,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) {
|
||||
func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*model.Post, error) {
|
||||
title = s.filter.Filter(strings.TrimSpace(title))
|
||||
content = s.filter.Filter(SanitizePostHTML(content))
|
||||
tags = s.filter.Filter(strings.TrimSpace(tags))
|
||||
@@ -351,7 +351,7 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
|
||||
return nil, err
|
||||
}
|
||||
status := model.ContentStatusPending
|
||||
if isAdmin {
|
||||
if skipModeration {
|
||||
status = model.ContentStatusPublished
|
||||
}
|
||||
post := &model.Post{
|
||||
@@ -365,12 +365,18 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
|
||||
QuestionResolved: false,
|
||||
Status: status,
|
||||
}
|
||||
return post, model.DB.Create(post).Error
|
||||
if err := model.DB.Create(post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == model.ContentStatusPublished {
|
||||
AddExp(userID, 10)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
|
||||
// postType 为空时保持原类型;改为非 question 时清除已解决标记。
|
||||
func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags, postType string, boardID uint) error {
|
||||
func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error {
|
||||
var post model.Post
|
||||
if err := model.DB.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
@@ -425,8 +431,8 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
"post_type": nextType,
|
||||
"question_resolved": nextResolved,
|
||||
}
|
||||
// 普通用户修改后重新进入审核
|
||||
if !isAdmin {
|
||||
// 非免审用户修改后重新进入审核
|
||||
if !skipModeration {
|
||||
updates["status"] = model.ContentStatusPending
|
||||
}
|
||||
return tx.Model(&post).Updates(updates).Error
|
||||
@@ -440,6 +446,11 @@ func (s *PostService) SetStatus(postID uint, status string) error {
|
||||
default:
|
||||
return errors.New("无效的审核状态")
|
||||
}
|
||||
var post model.Post
|
||||
if err := model.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
prev := post.Status
|
||||
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -447,6 +458,10 @@ func (s *PostService) SetStatus(postID uint, status string) error {
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
// 首次变为已发布时加经验
|
||||
if status == model.ContentStatusPublished && prev != model.ContentStatusPublished {
|
||||
AddExp(post.UserID, 10)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -698,6 +713,10 @@ func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, res
|
||||
}
|
||||
|
||||
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
|
||||
var post model.Post
|
||||
if err := model.DB.Select("id", "user_id").First(&post, postID).Error; err != nil {
|
||||
return false, ErrPostNotFound
|
||||
}
|
||||
var like model.PostLike
|
||||
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
|
||||
if result.Error != nil {
|
||||
@@ -713,6 +732,13 @@ func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
|
||||
// 他人点赞给作者加经验;自赞不计
|
||||
if userID != post.UserID {
|
||||
AddExp(post.UserID, 1)
|
||||
go func() {
|
||||
_ = NewBadgeService().EvaluateAuto(post.UserID)
|
||||
}()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ func postContentHTMLPolicy() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
|
||||
// TipTap / Markdown 转换会用到的结构
|
||||
p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only")
|
||||
p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only", "points-only")
|
||||
p.AllowAttrs("class").OnElements(
|
||||
"p", "div", "span", "pre", "code", "img", "a",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
|
||||
"members-only", "reply-only",
|
||||
"members-only", "reply-only", "points-only",
|
||||
)
|
||||
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
|
||||
p.AllowAttrs(
|
||||
"data-locked", "data-length", "data-gate",
|
||||
"data-locked", "data-length", "data-gate", "data-cost", "data-block-key",
|
||||
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
||||
"data-code-style", "data-line-numbers", "data-collapsed",
|
||||
"data-line-count", "data-lineno-digits",
|
||||
|
||||
248
service/unlock.go
Normal file
248
service/unlock.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreatorSharePercent 作者分成比例(读者支付的百分比)
|
||||
CreatorSharePercent = 70
|
||||
// SockpuppetAccountAgeDays 短龄号判定天数(同 IP 互刷拒绝分成)
|
||||
SockpuppetAccountAgeDays = 7
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBlockNotFound = errors.New("付费块不存在")
|
||||
ErrAlreadyUnlocked = errors.New("已解锁")
|
||||
ErrSuspiciousTrade = errors.New("检测到异常关联账号,无法完成解锁分成")
|
||||
)
|
||||
|
||||
var pointsOnlyBlockRe = regexp.MustCompile(`(?is)<points-only\b([^>]*)>([\s\S]*?)</points-only>`)
|
||||
|
||||
// PointsOnlyBlock 解析出的付费块
|
||||
type PointsOnlyBlock struct {
|
||||
Key string
|
||||
Cost int
|
||||
Inner string
|
||||
AttrRaw string
|
||||
}
|
||||
|
||||
// ParsePointsOnlyBlocks 按出现顺序解析付费块;block_key = sha256(inner)[:16]
|
||||
func ParsePointsOnlyBlocks(html string) []PointsOnlyBlock {
|
||||
matches := pointsOnlyBlockRe.FindAllStringSubmatch(html, -1)
|
||||
out := make([]PointsOnlyBlock, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
attrs := m[1]
|
||||
inner := m[2]
|
||||
cost := parseDataCost(attrs)
|
||||
if cost < 1 {
|
||||
cost = 1
|
||||
}
|
||||
sum := sha256.Sum256([]byte(inner))
|
||||
key := hex.EncodeToString(sum[:])[:16]
|
||||
out = append(out, PointsOnlyBlock{Key: key, Cost: cost, Inner: inner, AttrRaw: attrs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDataCost(attrs string) int {
|
||||
re := regexp.MustCompile(`(?i)data-cost\s*=\s*["']?(\d+)`)
|
||||
m := re.FindStringSubmatch(attrs)
|
||||
if len(m) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// FindPointsBlock 按 key 查找块
|
||||
func FindPointsBlock(html, blockKey string) (PointsOnlyBlock, bool) {
|
||||
for _, b := range ParsePointsOnlyBlocks(html) {
|
||||
if b.Key == blockKey {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
return PointsOnlyBlock{}, false
|
||||
}
|
||||
|
||||
// RedactPointsOnlyHTML 遮盖未解锁付费块;unlocked 为已解锁的 block_key 集合
|
||||
func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string {
|
||||
if html == "" {
|
||||
return html
|
||||
}
|
||||
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
|
||||
m := pointsOnlyBlockRe.FindStringSubmatch(full)
|
||||
if len(m) < 3 {
|
||||
return full
|
||||
}
|
||||
attrs, inner := m[1], m[2]
|
||||
sum := sha256.Sum256([]byte(inner))
|
||||
key := hex.EncodeToString(sum[:])[:16]
|
||||
if unlocked != nil && unlocked[key] {
|
||||
cost := parseDataCost(attrs)
|
||||
if cost < 1 {
|
||||
cost = 1
|
||||
}
|
||||
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
|
||||
}
|
||||
cost := parseDataCost(attrs)
|
||||
if cost < 1 {
|
||||
cost = 1
|
||||
}
|
||||
length := gatedContentLength(inner)
|
||||
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"></points-only>`, cost, key, length)
|
||||
})
|
||||
}
|
||||
|
||||
// ListUnlockedKeys 用户在某帖已解锁的 block_key
|
||||
func ListUnlockedKeys(userID, postID uint) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
if userID == 0 || postID == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []model.PostContentUnlock
|
||||
if err := model.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.BlockKey] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnlockResult 解锁结果
|
||||
type UnlockResult struct {
|
||||
BlockKey string `json:"block_key"`
|
||||
Cost int `json:"cost"`
|
||||
AuthorShare int `json:"author_share"`
|
||||
PointsBalance int `json:"points_balance"`
|
||||
InnerHTML string `json:"inner_html"`
|
||||
}
|
||||
|
||||
// UnlockPointsBlock 积分解锁付费块
|
||||
func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) {
|
||||
var post model.Post
|
||||
if err := model.DB.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
}
|
||||
block, ok := FindPointsBlock(post.Content, blockKey)
|
||||
if !ok {
|
||||
return nil, ErrBlockNotFound
|
||||
}
|
||||
|
||||
// 作者自己免费解锁记录(无分成)
|
||||
if readerID == post.UserID {
|
||||
var n int64
|
||||
model.DB.Model(&model.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n)
|
||||
if n == 0 {
|
||||
_ = model.DB.Create(&model.PostContentUnlock{
|
||||
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0,
|
||||
}).Error
|
||||
}
|
||||
return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil
|
||||
}
|
||||
|
||||
var existing model.PostContentUnlock
|
||||
model.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing)
|
||||
if existing.ID > 0 {
|
||||
return nil, ErrAlreadyUnlocked
|
||||
}
|
||||
|
||||
var reader, author model.User
|
||||
if err := model.DB.First(&reader, readerID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := model.DB.First(&author, post.UserID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 短龄号 + 同登录 IP:拒绝整单(防互刷套现)
|
||||
if suspiciousUnlockPair(&reader, &author) {
|
||||
return nil, ErrSuspiciousTrade
|
||||
}
|
||||
|
||||
cost := block.Cost
|
||||
authorShare := cost * CreatorSharePercent / 100
|
||||
var bal int
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var again model.PostContentUnlock
|
||||
if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if again.ID > 0 {
|
||||
return ErrAlreadyUnlocked
|
||||
}
|
||||
var e error
|
||||
bal, e = AdjustPointsTx(tx, readerID, -cost, model.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if authorShare > 0 {
|
||||
if _, e = AdjustPointsTx(tx, author.ID, authorShare, model.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Model(&model.User{}).Where("id = ?", author.ID).
|
||||
UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.PostContentUnlock{
|
||||
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 异步检查作者徽章
|
||||
go func() {
|
||||
_ = NewBadgeService().EvaluateAuto(author.ID)
|
||||
}()
|
||||
return &UnlockResult{
|
||||
BlockKey: blockKey, Cost: cost, AuthorShare: authorShare,
|
||||
PointsBalance: bal, InnerHTML: block.Inner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func suspiciousUnlockPair(reader, author *model.User) bool {
|
||||
if reader == nil || author == nil {
|
||||
return false
|
||||
}
|
||||
ipR := strings.TrimSpace(reader.LastLoginIP)
|
||||
ipA := strings.TrimSpace(author.LastLoginIP)
|
||||
if ipR == "" || ipA == "" || ipR != ipA {
|
||||
return false
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -SockpuppetAccountAgeDays)
|
||||
return reader.CreatedAt.After(cutoff) && author.CreatedAt.After(cutoff)
|
||||
}
|
||||
|
||||
// RevealAllPointsOnly 作者/站长:保留正文并写入 block-key,标记未锁定
|
||||
func RevealAllPointsOnly(html string) string {
|
||||
if html == "" {
|
||||
return html
|
||||
}
|
||||
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
|
||||
m := pointsOnlyBlockRe.FindStringSubmatch(full)
|
||||
if len(m) < 3 {
|
||||
return full
|
||||
}
|
||||
attrs, inner := m[1], m[2]
|
||||
sum := sha256.Sum256([]byte(inner))
|
||||
key := hex.EncodeToString(sum[:])[:16]
|
||||
cost := parseDataCost(attrs)
|
||||
if cost < 1 {
|
||||
cost = 1
|
||||
}
|
||||
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -136,12 +137,51 @@ func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, stor
|
||||
}
|
||||
|
||||
// ListUsers 管理员列出用户
|
||||
func (s *UserService) ListUsers(page, size int) ([]model.User, int64, error) {
|
||||
var users []model.User
|
||||
// UserListQuery 后台用户列表筛选
|
||||
type UserListQuery struct {
|
||||
Page int
|
||||
Size int
|
||||
Keyword string // 匹配用户名/昵称/邮箱
|
||||
Filter string // all | verified | banned | admin
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.Size < 1 {
|
||||
q.Size = 20
|
||||
}
|
||||
if q.Size > 100 {
|
||||
q.Size = 100
|
||||
}
|
||||
|
||||
db := model.DB.Model(&model.User{})
|
||||
kw := strings.TrimSpace(q.Keyword)
|
||||
if kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
if id, err := strconv.ParseUint(kw, 10, 64); err == nil {
|
||||
db = db.Where("id = ? OR username LIKE ? OR nickname LIKE ? OR email LIKE ?", id, like, like, like)
|
||||
} else {
|
||||
db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(q.Filter) {
|
||||
case "verified":
|
||||
db = db.Where("verified = ? AND role <> ?", true, model.RoleAdmin)
|
||||
case "banned":
|
||||
db = db.Where("banned = ?", true)
|
||||
case "admin":
|
||||
db = db.Where("role = ?", model.RoleAdmin)
|
||||
}
|
||||
|
||||
var total int64
|
||||
model.DB.Model(&model.User{}).Count(&total)
|
||||
offset := (page - 1) * size
|
||||
err := model.DB.Order("id desc").Offset(offset).Limit(size).Find(&users).Error
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var users []model.User
|
||||
offset := (q.Page - 1) * q.Size
|
||||
err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user