feat: 实现积分与等级系统 + 签到功能 + 下拉菜单优化
- User 模型增加 exp 字段,等级规则 Lv0-Lv6 - 新增签到记录(UserCheckIn)与任务记录(UserTask)模型,唯一索引防重 - 自动签到:AuthMiddleware 验证会话后,基于 Asia/Shanghai 自然日自动签到 +5 经验 - 手动签到:下拉菜单"签到"按钮兜底,已签到显示"今日已签到"并禁用 - 首次任务经验:上传头像/设置用户名/设置个性签名各 +10,审核通过与直接更新均触发 - 经验值变动后自动比对等级,升级时通过通知系统发送"恭喜提升至 LvX" - 下拉菜单重构:顶部头像+用户名+LvX 等级标签,菜单项增加左侧图标,优化布局样式 - 新增 API:POST /api/level/checkin、GET /api/level/info
This commit is contained in:
@ -89,6 +89,12 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
if avatar, exists := c.Get("avatar"); exists {
|
||||
data["Avatar"] = avatar
|
||||
}
|
||||
if expVal, exists := c.Get("exp"); exists {
|
||||
if exp, ok := expVal.(int); ok {
|
||||
data["Exp"] = exp
|
||||
data["Level"] = model.GetLevelByExp(exp)
|
||||
}
|
||||
}
|
||||
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
||||
if token, exists := c.Get("csrf_token"); exists {
|
||||
data["CSRFToken"] = token
|
||||
|
||||
@ -61,3 +61,10 @@ type sessionManager interface {
|
||||
DestroyOtherByUID(uid uint, currentSID string) error
|
||||
UpdateRemark(sid string, uid uint, remark string) error
|
||||
}
|
||||
|
||||
// levelUseCase LevelController 对 LevelService 的最小依赖
|
||||
type levelUseCase interface {
|
||||
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
61
internal/controller/level_controller.go
Normal file
61
internal/controller/level_controller.go
Normal file
@ -0,0 +1,61 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LevelController 积分/等级/签到 API
|
||||
type LevelController struct {
|
||||
levelSvc levelUseCase
|
||||
}
|
||||
|
||||
// NewLevelController 构造函数
|
||||
func NewLevelController(levelSvc levelUseCase) *LevelController {
|
||||
return &LevelController{levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// CheckIn 手动签到 API
|
||||
func (lc *LevelController) CheckIn(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
checkedIn, newExp, levelUp, err := lc.levelSvc.CheckIn(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "签到失败")
|
||||
return
|
||||
}
|
||||
|
||||
level, _, _, _ := lc.levelSvc.GetLevel(uid)
|
||||
common.Ok(c, gin.H{
|
||||
"checked_in": checkedIn,
|
||||
"exp": newExp,
|
||||
"level": level,
|
||||
"level_up": levelUp,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLevel 获取当前等级、经验值和签到状态 API
|
||||
func (lc *LevelController) GetLevel(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
level, exp, checkedIn, err := lc.levelSvc.GetLevel(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{"level": level, "exp": exp, "checked_in": checkedIn})
|
||||
}
|
||||
@ -57,6 +57,14 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 触发首次任务奖励(静默失败不影响主流程)
|
||||
if req.Username != "" {
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
|
||||
}
|
||||
if req.Bio != "" {
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
|
||||
}
|
||||
|
||||
common.OkMessage(c, "个人资料已更新")
|
||||
}
|
||||
|
||||
@ -112,5 +120,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 触发首次上传头像奖励
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
|
||||
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
@ -40,6 +40,11 @@ type sessionConfig interface {
|
||||
GetRememberTimeout() int // 分钟
|
||||
}
|
||||
|
||||
// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励)
|
||||
type taskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// SettingsController 个人设置控制器
|
||||
type SettingsController struct {
|
||||
authService profileProvider
|
||||
@ -47,11 +52,12 @@ type SettingsController struct {
|
||||
auditService auditSubmittable
|
||||
sessionMgr sessionManager
|
||||
sessionCfg sessionConfig
|
||||
levelSvc taskCompleter
|
||||
}
|
||||
|
||||
// NewSettingsController 构造函数
|
||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
|
||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
|
||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
|
||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// SettingsPage 个人设置页面(需登录)
|
||||
|
||||
@ -10,10 +10,16 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
|
||||
type autoCheckIner interface {
|
||||
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.Config
|
||||
sessionManager *session.Manager
|
||||
cfg *config.Config
|
||||
sessionManager *session.Manager
|
||||
levelSvc autoCheckIner
|
||||
}
|
||||
|
||||
// NewAuthMiddleware 构造函数
|
||||
@ -21,6 +27,12 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
|
||||
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
||||
}
|
||||
|
||||
// WithLevelService 链式注入等级服务(用于自动签到)
|
||||
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
||||
am.levelSvc = svc
|
||||
return am
|
||||
}
|
||||
|
||||
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
||||
// 验证失败 → 清除 cookie → 返回 401
|
||||
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||
@ -44,6 +56,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
injectSessionContext(c, s)
|
||||
am.tryAutoCheckIn(c, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@ -65,10 +78,25 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
injectSessionContext(c, s)
|
||||
am.tryAutoCheckIn(c, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// tryAutoCheckIn 尝试自动签到(静默失败,不影响主流程)
|
||||
// 若签到成功,同步更新 gin context 中的 exp,确保页面渲染获取最新等级
|
||||
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
|
||||
if am.levelSvc == nil || s == nil {
|
||||
return
|
||||
}
|
||||
checkedIn, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
|
||||
if checkedIn {
|
||||
s.Exp = newExp
|
||||
_ = am.sessionManager.UpdateSession(s)
|
||||
c.Set("exp", newExp)
|
||||
}
|
||||
}
|
||||
|
||||
// injectSessionContext 将会话中的用户信息注入 gin context
|
||||
func injectSessionContext(c *gin.Context, s *session.Session) {
|
||||
if s == nil {
|
||||
@ -79,5 +107,6 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
|
||||
c.Set("username", s.Username)
|
||||
c.Set("avatar", s.Avatar)
|
||||
c.Set("role", s.Role)
|
||||
c.Set("exp", s.Exp)
|
||||
c.Set("sid", s.ID)
|
||||
}
|
||||
|
||||
25
internal/model/level.go
Normal file
25
internal/model/level.go
Normal file
@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LevelThresholds 等级阈值表(经验值区间下限)
|
||||
// 索引即等级,如 thresholds[3] = 1500 表示 Lv3 至少需要 1500 经验值
|
||||
var LevelThresholds = []int{0, 1, 200, 1500, 4500, 10800, 28800}
|
||||
|
||||
// GetLevelByExp 根据经验值计算当前等级
|
||||
func GetLevelByExp(exp int) int {
|
||||
for i := len(LevelThresholds) - 1; i >= 0; i-- {
|
||||
if exp >= LevelThresholds[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetLevelName 根据等级返回展示文本(如 "Lv3")
|
||||
func GetLevelName(level int) string {
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
return fmt.Sprintf("Lv%d", level)
|
||||
}
|
||||
@ -6,6 +6,7 @@ const (
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyPostApproved = "post_approved"
|
||||
NotifyPostRejected = "post_rejected"
|
||||
NotifyLevelUp = "level_up"
|
||||
)
|
||||
|
||||
// Notification 消息/通知模型
|
||||
@ -26,6 +27,7 @@ var NotifyTypeNames = map[string]string{
|
||||
NotifyAuditRejected: "资料审核",
|
||||
NotifyPostApproved: "稿件审核",
|
||||
NotifyPostRejected: "稿件审核",
|
||||
NotifyLevelUp: "等级提升",
|
||||
}
|
||||
|
||||
// NotificationListResult 消息列表查询结果
|
||||
|
||||
@ -15,11 +15,14 @@ type User struct {
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
|
||||
// 积分与等级
|
||||
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||
|
||||
// 安全与审计
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||
LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间
|
||||
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
|
||||
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
|
||||
}
|
||||
|
||||
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
|
||||
|
||||
11
internal/model/user_checkin.go
Normal file
11
internal/model/user_checkin.go
Normal file
@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserCheckIn 用户签到记录(自然日维度,严格防重)
|
||||
type UserCheckIn struct {
|
||||
BaseModel
|
||||
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_checkin_date,priority:1;not null" json:"user_id"`
|
||||
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_checkin_date,priority:2;not null" json:"date"` // Asia/Shanghai 自然日
|
||||
}
|
||||
23
internal/model/user_task.go
Normal file
23
internal/model/user_task.go
Normal file
@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
// 任务类型常量
|
||||
const (
|
||||
TaskTypeAvatar = "avatar"
|
||||
TaskTypeUsername = "username"
|
||||
TaskTypeBio = "bio"
|
||||
)
|
||||
|
||||
// TaskTypeNames 任务类型 → 中文名
|
||||
var TaskTypeNames = map[string]string{
|
||||
TaskTypeAvatar: "上传头像",
|
||||
TaskTypeUsername: "设置用户名",
|
||||
TaskTypeBio: "设置个性签名",
|
||||
}
|
||||
|
||||
// UserTask 用户首次完成任务记录(严格防重)
|
||||
type UserTask struct {
|
||||
BaseModel
|
||||
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_task,priority:1;not null" json:"user_id"`
|
||||
TaskType string `gorm:"type:varchar(20);uniqueIndex:idx_user_task,priority:2;not null" json:"task_type"`
|
||||
}
|
||||
66
internal/repository/level_repo.go
Normal file
66
internal/repository/level_repo.go
Normal file
@ -0,0 +1,66 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LevelRepo 积分/等级/签到/任务数据访问
|
||||
type LevelRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLevelRepo 构造函数
|
||||
func NewLevelRepo(db *gorm.DB) *LevelRepo {
|
||||
return &LevelRepo{db: db}
|
||||
}
|
||||
|
||||
// FindByID 按 UID 查找用户(实现 userLevelStore 接口)
|
||||
func (r *LevelRepo) FindByID(id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// AddExp 原子增加用户经验值
|
||||
func (r *LevelRepo) AddExp(userID uint, delta int) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("uid = ?", userID).
|
||||
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
|
||||
}
|
||||
|
||||
// FindCheckIn 查询指定自然日的签到记录
|
||||
func (r *LevelRepo) FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error) {
|
||||
var ci model.UserCheckIn
|
||||
err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&ci).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ci, nil
|
||||
}
|
||||
|
||||
// CreateCheckIn 创建签到记录
|
||||
func (r *LevelRepo) CreateCheckIn(ci *model.UserCheckIn) error {
|
||||
return r.db.Create(ci).Error
|
||||
}
|
||||
|
||||
// FindTask 查询用户是否已完成指定任务
|
||||
func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, error) {
|
||||
var t model.UserTask
|
||||
err := r.db.Where("user_id = ? AND task_type = ?", userID, taskType).First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CreateTask 创建任务完成记录
|
||||
func (r *LevelRepo) CreateTask(task *model.UserTask) error {
|
||||
return r.db.Create(task).Error
|
||||
}
|
||||
@ -36,6 +36,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
||||
}
|
||||
|
||||
// --- 积分/等级 API(需登录) ---
|
||||
levelAPI := r.Group("/api/level")
|
||||
levelAPI.Use(d.authMdw.Required())
|
||||
levelAPI.Use(middleware.CSRF(cfg))
|
||||
{
|
||||
levelAPI.POST("/checkin", d.levelCtrl.CheckIn)
|
||||
levelAPI.GET("/info", d.levelCtrl.GetLevel)
|
||||
}
|
||||
|
||||
// --- 认证 API ---
|
||||
api := r.Group("/api")
|
||||
api.Use(middleware.CSRF(cfg))
|
||||
|
||||
@ -30,6 +30,7 @@ type dependencies struct {
|
||||
adminCtrl *adminCtrl.AdminController
|
||||
auditCtrl *adminCtrl.AuditController
|
||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||
levelCtrl *controller.LevelController
|
||||
}
|
||||
|
||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||
@ -68,3 +69,14 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
|
||||
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
||||
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw
|
||||
}
|
||||
|
||||
// buildDepsLevel 构建等级/签到相关依赖
|
||||
func buildDepsLevel(db *gorm.DB, notifSvc *service.NotificationService, authMdw *middleware.AuthMiddleware) (
|
||||
*repository.LevelRepo, *service.LevelService, *controller.LevelController,
|
||||
) {
|
||||
levelRepo := repository.NewLevelRepo(db)
|
||||
levelSvc := service.NewLevelService(levelRepo, levelRepo, levelRepo, notifSvc)
|
||||
levelCtrl := controller.NewLevelController(levelSvc)
|
||||
authMdw.WithLevelService(levelSvc)
|
||||
return levelRepo, levelSvc, levelCtrl
|
||||
}
|
||||
|
||||
@ -18,12 +18,16 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
auditRepo := repository.NewAuditRepo(db)
|
||||
notificationRepo := repository.NewNotificationRepo(db)
|
||||
notificationService := service.NewNotificationService(notificationRepo)
|
||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
|
||||
|
||||
// 等级/签到/任务系统
|
||||
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||
|
||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||
|
||||
messageController := controller.NewMessageController(notificationService)
|
||||
|
||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
|
||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
||||
siteSettingController := adminCtrl.NewSiteSettingController(
|
||||
service.NewSiteSettingService(siteSettings),
|
||||
&service.AuditDefaults{
|
||||
@ -49,10 +53,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
return &dependencies{
|
||||
authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw,
|
||||
settingsCtrl: settingsCtrl,
|
||||
messageCtrl: messageController, postCtrl: postCtrl, postService: postService, spaceCtrl: spaceCtrl,
|
||||
studioCtrl: studioCtrl,
|
||||
messageCtrl: messageController, postCtrl: postCtrl, postService: postService, spaceCtrl: spaceCtrl,
|
||||
studioCtrl: studioCtrl,
|
||||
adminPostCtrl: adminPostCtrl,
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
siteSettingCtrl: siteSettingController,
|
||||
levelCtrl: levelCtrl,
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,29 +33,36 @@ type siteSettingsChecker interface {
|
||||
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
|
||||
}
|
||||
|
||||
// auditNotifier 审核服务所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
||||
// auditNotifier AuditService 所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
||||
type auditNotifier interface {
|
||||
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
|
||||
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
|
||||
}
|
||||
|
||||
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
|
||||
type auditTaskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// AuditService 审核业务逻辑
|
||||
type AuditService struct {
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
}
|
||||
|
||||
// NewAuditService 构造函数
|
||||
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier) *AuditService {
|
||||
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier, levelSvc auditTaskCompleter) *AuditService {
|
||||
return &AuditService{
|
||||
auditRepo: auditRepo,
|
||||
userRepo: userRepo,
|
||||
settings: settings,
|
||||
defaultCfg: cfg,
|
||||
notifier: notifier,
|
||||
levelSvc: levelSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +227,23 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
||||
case model.AuditTypeBio:
|
||||
user.Bio = submission.NewValue
|
||||
}
|
||||
return s.userRepo.Update(user)
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发首次任务奖励(静默失败不影响主流程)
|
||||
if s.levelSvc != nil {
|
||||
switch submission.AuditType {
|
||||
case model.AuditTypeUsername:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
|
||||
case model.AuditTypeAvatar:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
|
||||
case model.AuditTypeBio:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 分页查询审核列表
|
||||
|
||||
149
internal/service/level_service.go
Normal file
149
internal/service/level_service.go
Normal file
@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// LevelService 积分与等级业务逻辑
|
||||
type LevelService struct {
|
||||
userRepo userLevelStore
|
||||
checkRepo checkInStore
|
||||
taskRepo taskStore
|
||||
notifSvc levelNotifier
|
||||
}
|
||||
|
||||
// NewLevelService 构造函数
|
||||
func NewLevelService(userRepo userLevelStore, checkRepo checkInStore, taskRepo taskStore, notifSvc levelNotifier) *LevelService {
|
||||
return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc}
|
||||
}
|
||||
|
||||
// GetLevel 获取用户当前等级、经验值和今日签到状态
|
||||
func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
_, err = s.checkRepo.FindCheckIn(userID, today)
|
||||
checkedIn = err == nil
|
||||
|
||||
return model.GetLevelByExp(user.Exp), user.Exp, checkedIn, nil
|
||||
}
|
||||
|
||||
// AddExp 增加经验值,自动检查升级并发送通知
|
||||
// 返回:新经验值、是否升级、错误
|
||||
func (s *LevelService) AddExp(userID uint, delta int) (newExp int, levelUp bool, err error) {
|
||||
if delta <= 0 {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return user.Exp, false, nil
|
||||
}
|
||||
|
||||
// 先查旧等级
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
oldLevel := model.GetLevelByExp(user.Exp)
|
||||
|
||||
// 原子增加经验值
|
||||
if err := s.userRepo.AddExp(userID, delta); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
// 查新等级
|
||||
user, err = s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
newLevel := model.GetLevelByExp(user.Exp)
|
||||
|
||||
if newLevel > oldLevel {
|
||||
levelUp = true
|
||||
if s.notifSvc != nil {
|
||||
_ = s.notifSvc.Create(userID, model.NotifyLevelUp,
|
||||
"等级提升",
|
||||
fmt.Sprintf("恭喜您!您的等级已提升至 %s", model.GetLevelName(newLevel)),
|
||||
nil)
|
||||
}
|
||||
}
|
||||
|
||||
return user.Exp, levelUp, nil
|
||||
}
|
||||
|
||||
// CheckIn 执行签到(基于 Asia/Shanghai 自然日严格防重)
|
||||
// 返回:是否成功签到、新经验值、是否升级、错误
|
||||
func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error) {
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
// 检查今日是否已签到
|
||||
_, err = s.checkRepo.FindCheckIn(userID, today)
|
||||
if err == nil {
|
||||
// 已签到
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
return false, 0, false, nil
|
||||
}
|
||||
|
||||
// 创建签到记录
|
||||
ci := &model.UserCheckIn{
|
||||
UserID: userID,
|
||||
Date: today,
|
||||
}
|
||||
if err := s.checkRepo.CreateCheckIn(ci); err != nil {
|
||||
// 唯一索引冲突 → 已签到
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
return false, 0, false, nil
|
||||
}
|
||||
|
||||
// 增加经验值
|
||||
newExp, levelUp, err = s.AddExp(userID, 5)
|
||||
return true, newExp, levelUp, err
|
||||
}
|
||||
|
||||
// CompleteTask 完成首次任务(严格防重)
|
||||
// 返回:新经验值、是否升级、错误
|
||||
func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) {
|
||||
// 检查是否已完成
|
||||
_, err = s.taskRepo.FindTask(userID, taskType)
|
||||
if err == nil {
|
||||
// 已完成过,不重复奖励
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return user.Exp, false, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// 创建任务记录
|
||||
task := &model.UserTask{
|
||||
UserID: userID,
|
||||
TaskType: taskType,
|
||||
}
|
||||
if err := s.taskRepo.CreateTask(task); err != nil {
|
||||
// 唯一索引冲突 → 已完成
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return user.Exp, false, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// 增加经验值
|
||||
return s.AddExp(userID, 10)
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
package service
|
||||
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法)
|
||||
type userAuthStore interface {
|
||||
@ -58,3 +62,26 @@ type postNotifier interface {
|
||||
NotifyPostApproved(userID uint, postTitle string, postID uint)
|
||||
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
|
||||
}
|
||||
|
||||
// userLevelStore LevelService 所需的用户仓储接口
|
||||
type userLevelStore interface {
|
||||
FindByID(id uint) (*model.User, error)
|
||||
AddExp(userID uint, delta int) error
|
||||
}
|
||||
|
||||
// checkInStore 签到记录仓储接口
|
||||
type checkInStore interface {
|
||||
FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error)
|
||||
CreateCheckIn(ci *model.UserCheckIn) error
|
||||
}
|
||||
|
||||
// taskStore 任务完成记录仓储接口
|
||||
type taskStore interface {
|
||||
FindTask(userID uint, taskType string) (*model.UserTask, error)
|
||||
CreateTask(task *model.UserTask) error
|
||||
}
|
||||
|
||||
// levelNotifier 等级升级通知接口
|
||||
type levelNotifier interface {
|
||||
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Role: user.Role,
|
||||
Exp: user.Exp,
|
||||
RememberMe: rememberMe,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
@ -79,10 +80,11 @@ func (m *Manager) Validate(sid string) (*Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 同步 DB 最新字段(头像/用户名/角色等可能已变更)
|
||||
// 同步 DB 最新字段(头像/用户名/角色/经验值等可能已变更)
|
||||
s.Avatar = user.Avatar
|
||||
s.Username = user.Username
|
||||
s.Role = user.Role
|
||||
s.Exp = user.Exp
|
||||
|
||||
// 滑动窗口续期
|
||||
s.Touch()
|
||||
@ -134,6 +136,11 @@ func (m *Manager) UpdateRemark(sid string, uid uint, remark string) error {
|
||||
return m.store.Set(s)
|
||||
}
|
||||
|
||||
// UpdateSession 直接更新会话存储(用于同步经验值等字段)
|
||||
func (m *Manager) UpdateSession(s *Session) error {
|
||||
return m.store.Set(s)
|
||||
}
|
||||
|
||||
// GetStoreMetrics 返回当前存储的状态信息
|
||||
func (m *Manager) GetStoreMetrics() StoreMetrics {
|
||||
return m.store.Metrics()
|
||||
|
||||
@ -14,6 +14,7 @@ type Session struct {
|
||||
Username string // 用户名
|
||||
Avatar string // 头像 URL
|
||||
Role string // 角色
|
||||
Exp int // 经验值(用于前端实时展示等级)
|
||||
RememberMe bool // 是否持久化(决定 cookie maxAge)
|
||||
IP string // 登录 IP
|
||||
UserAgent string // 登录设备 User-Agent
|
||||
|
||||
Reference in New Issue
Block a user