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:
2026-05-31 20:58:14 +08:00
parent 91115447e8
commit fdb636f98f
24 changed files with 663 additions and 32 deletions

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View 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})
}

View File

@ -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})
}

View File

@ -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 个人设置页面(需登录)

View File

@ -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
View 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)
}

View File

@ -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 消息列表查询结果

View File

@ -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:"-"` // 注销原因
}
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)

View 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 自然日
}

View 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"`
}

View 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
}

View File

@ -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))

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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 分页查询审核列表

View 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)
}

View File

@ -1,6 +1,10 @@
package service
import "metazone.cc/metalab/internal/model"
import (
"time"
"metazone.cc/metalab/internal/model"
)
// userAuthStore AuthService 所需的最小仓储接口ISP7 个方法)
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
}

View File

@ -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()

View File

@ -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

View File

@ -27,9 +27,34 @@
{{end}}
</a>
<div class="nav-dropdown">
<div class="nav-dropdown-header">{{.Username}}</div>
<a href="/settings/profile" class="nav-dropdown-item">个人中心</a>
<a href="javascript:void(0)" class="nav-dropdown-item nav-logout" id="logoutBtn">退出登录</a>
<div class="nav-dropdown-header">
<div class="nav-dropdown-avatar">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span>{{substr .Username 0 1}}</span>
{{end}}
</div>
<div class="nav-dropdown-meta">
<div class="nav-dropdown-username">{{.Username}}</div>
{{if .IsLoggedIn}}
<div class="nav-dropdown-level">Lv{{.Level}}</div>
{{end}}
</div>
</div>
<a href="javascript:void(0)" class="nav-dropdown-item" id="checkInBtn">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>签到</span>
</a>
<a href="/settings/profile" class="nav-dropdown-item">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>个人中心</span>
</a>
<a href="javascript:void(0)" class="nav-dropdown-item nav-logout" id="logoutBtn">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>退出登录</span>
</a>
</div>
</li>
{{else}}

View File

@ -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;

View File

@ -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 仍有效