From fdb636f98fd4d053d7be07084797a29c61d36a6f Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Sun, 31 May 2026 20:58:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E4=B8=8E=E7=AD=89=E7=BA=A7=E7=B3=BB=E7=BB=9F=20+=20=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E5=8A=9F=E8=83=BD=20+=20=E4=B8=8B=E6=8B=89=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User 模型增加 exp 字段,等级规则 Lv0-Lv6 - 新增签到记录(UserCheckIn)与任务记录(UserTask)模型,唯一索引防重 - 自动签到:AuthMiddleware 验证会话后,基于 Asia/Shanghai 自然日自动签到 +5 经验 - 手动签到:下拉菜单"签到"按钮兜底,已签到显示"今日已签到"并禁用 - 首次任务经验:上传头像/设置用户名/设置个性签名各 +10,审核通过与直接更新均触发 - 经验值变动后自动比对等级,升级时通过通知系统发送"恭喜提升至 LvX" - 下拉菜单重构:顶部头像+用户名+LvX 等级标签,菜单项增加左侧图标,优化布局样式 - 新增 API:POST /api/level/checkin、GET /api/level/info --- cmd/server/main.go | 2 +- internal/common/helper.go | 6 + internal/controller/interfaces.go | 7 + internal/controller/level_controller.go | 61 ++++++++ internal/controller/settings_api_profile.go | 11 ++ internal/controller/settings_controller.go | 10 +- internal/middleware/auth.go | 33 +++- internal/model/level.go | 25 ++++ internal/model/notification.go | 2 + internal/model/user.go | 9 +- internal/model/user_checkin.go | 11 ++ internal/model/user_task.go | 23 +++ internal/repository/level_repo.go | 66 ++++++++ internal/router/api.go | 9 ++ internal/router/deps_core.go | 12 ++ internal/router/deps_extra.go | 15 +- internal/service/audit_service.go | 39 ++++- internal/service/level_service.go | 149 +++++++++++++++++++ internal/service/repository.go | 29 +++- internal/session/manager.go | 9 +- internal/session/session.go | 1 + templates/MetaLab-2026/html/layout/nav.html | 31 +++- templates/MetaLab-2026/static/css/common.css | 82 +++++++++- templates/MetaLab-2026/static/js/common.js | 53 +++++++ 24 files changed, 663 insertions(+), 32 deletions(-) create mode 100644 internal/controller/level_controller.go create mode 100644 internal/model/level.go create mode 100644 internal/model/user_checkin.go create mode 100644 internal/model/user_task.go create mode 100644 internal/repository/level_repo.go create mode 100644 internal/service/level_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 88152a3..e1eb9e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/common/helper.go b/internal/common/helper.go index 654f2e6..63ee0df 100644 --- a/internal/common/helper.go +++ b/internal/common/helper.go @@ -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 diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index f783392..4dd3008 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -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) +} diff --git a/internal/controller/level_controller.go b/internal/controller/level_controller.go new file mode 100644 index 0000000..d9403aa --- /dev/null +++ b/internal/controller/level_controller.go @@ -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}) +} diff --git a/internal/controller/settings_api_profile.go b/internal/controller/settings_api_profile.go index 54faf1f..f45e55a 100644 --- a/internal/controller/settings_api_profile.go +++ b/internal/controller/settings_api_profile.go @@ -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}) } diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index b7d50ee..9d408c7 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -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 个人设置页面(需登录) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 650beef..b9c02f3 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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) } diff --git a/internal/model/level.go b/internal/model/level.go new file mode 100644 index 0000000..af50c62 --- /dev/null +++ b/internal/model/level.go @@ -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) +} diff --git a/internal/model/notification.go b/internal/model/notification.go index f96ada3..09f4f0f 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -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 消息列表查询结果 diff --git a/internal/model/user.go b/internal/model/user.go index cc081d9..0cb62ea 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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:"-"` // 注销原因 } // 角色常量(仅用作字符串标识符,层级和权限由配置驱动) diff --git a/internal/model/user_checkin.go b/internal/model/user_checkin.go new file mode 100644 index 0000000..7a645bd --- /dev/null +++ b/internal/model/user_checkin.go @@ -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 自然日 +} diff --git a/internal/model/user_task.go b/internal/model/user_task.go new file mode 100644 index 0000000..e2ee8a6 --- /dev/null +++ b/internal/model/user_task.go @@ -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"` +} diff --git a/internal/repository/level_repo.go b/internal/repository/level_repo.go new file mode 100644 index 0000000..8f4b693 --- /dev/null +++ b/internal/repository/level_repo.go @@ -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 +} diff --git a/internal/router/api.go b/internal/router/api.go index c9cc151..edd7cc3 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -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)) diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 350c202..6e75b7f 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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 +} diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index b456ae8..42aa142 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } } diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index 155da22..1726ba3 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -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 分页查询审核列表 diff --git a/internal/service/level_service.go b/internal/service/level_service.go new file mode 100644 index 0000000..d2f3ece --- /dev/null +++ b/internal/service/level_service.go @@ -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) +} diff --git a/internal/service/repository.go b/internal/service/repository.go index 2257ffb..e2571de 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -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 +} diff --git a/internal/session/manager.go b/internal/session/manager.go index f36a7fe..6acbd13 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -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() diff --git a/internal/session/session.go b/internal/session/session.go index 878dbc0..a674698 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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 diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index b0f0384..ec3da87 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -27,9 +27,34 @@ {{end}} {{else}} diff --git a/templates/MetaLab-2026/static/css/common.css b/templates/MetaLab-2026/static/css/common.css index e5a2818..1a73276 100644 --- a/templates/MetaLab-2026/static/css/common.css +++ b/templates/MetaLab-2026/static/css/common.css @@ -262,17 +262,65 @@ header { } .nav-dropdown-header { - padding: .75rem 1rem; - font-weight: 600; - font-size: .9rem; - color: var(--color-text); + display: flex; + align-items: center; + gap: .75rem; + padding: 1rem; border-bottom: 1px solid var(--color-border); +} + +.nav-dropdown-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background: var(--color-accent); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.1rem; + font-weight: 600; +} + +.nav-dropdown-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.nav-dropdown-meta { + min-width: 0; + overflow: hidden; +} + +.nav-dropdown-username { + font-weight: 600; + font-size: .95rem; + color: var(--color-text); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-dropdown-level { + display: inline-block; + margin-top: .2rem; + padding: .1rem .5rem; + border-radius: 4px; + background: linear-gradient(135deg, #ff6b6b, #feca57); + color: #fff; + font-size: .75rem; + font-weight: 700; + line-height: 1.4; } .nav-dropdown-item { - display: block !important; - padding: .6rem 1rem !important; + display: flex !important; + align-items: center; + gap: .6rem; + padding: .65rem 1rem !important; font-size: .9rem; color: var(--color-text-light) !important; transition: background .15s ease, color .15s ease; @@ -291,6 +339,28 @@ header { color: var(--color-danger) !important; } +.nav-dropdown-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + opacity: .7; +} + +.nav-dropdown-item:hover .nav-dropdown-icon { + opacity: 1; +} + +.nav-dropdown-item.disabled { + opacity: .5; + cursor: not-allowed; + pointer-events: none; +} + +.nav-dropdown-item.disabled:hover { + background: transparent; + color: var(--color-text-light) !important; +} + .mobile-menu-btn { display: none; background: none; diff --git a/templates/MetaLab-2026/static/js/common.js b/templates/MetaLab-2026/static/js/common.js index a4f9b0d..bb9ac52 100644 --- a/templates/MetaLab-2026/static/js/common.js +++ b/templates/MetaLab-2026/static/js/common.js @@ -283,6 +283,59 @@ setInterval(pollUnread, POLL_INTERVAL); } + // ---- Level / Check-in ---- + var checkInBtn = document.getElementById('checkInBtn'); + if (checkInBtn) { + // 页面加载时查询等级和签到状态 + fetch('/api/level/info') + .then(function (res) { + if (!res.ok) return; + return res.json(); + }) + .then(function (data) { + if (!data || !data.success) return; + var d = data.data || {}; + if (d.checked_in) { + setCheckInDone(); + } + }) + .catch(function () {}); + + function setCheckInDone() { + checkInBtn.classList.add('disabled'); + var span = checkInBtn.querySelector('span'); + if (span) span.textContent = '今日已签到'; + } + + checkInBtn.addEventListener('click', function () { + if (checkInBtn.classList.contains('disabled')) return; + + fetch('/api/level/checkin', { method: 'POST' }) + .then(function (res) { + if (!res.ok) return; + return res.json(); + }) + .then(function (data) { + if (!data || !data.success) return; + var d = data.data || {}; + if (!d.checked_in) { + setCheckInDone(); + return; + } + setCheckInDone(); + if (d.level_up) { + window.MetaLab.toast(document.querySelector('.toast'), '恭喜您!您的等级已提升至 Lv' + d.level, false); + } + // 刷新等级展示 + var levelEl = document.querySelector('.nav-dropdown-level'); + if (levelEl) { + levelEl.textContent = 'Lv' + d.level; + } + }) + .catch(function () {}); + }); + } + // ---- Auto-refresh token (silent) ---- // 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期) // 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效