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:
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
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,6 +15,9 @@ 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
|
||||
|
||||
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{
|
||||
@ -54,5 +58,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
adminPostCtrl: adminPostCtrl,
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
siteSettingCtrl: siteSettingController,
|
||||
levelCtrl: levelCtrl,
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,12 +33,17 @@ 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
|
||||
@ -46,16 +51,18 @@ type AuditService struct {
|
||||
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
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 仍有效
|
||||
|
||||
Reference in New Issue
Block a user