diff --git a/internal/common/errors.go b/internal/common/errors.go index 0f6bd5e..43088db 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -45,4 +45,11 @@ var ( ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotLock = errors.New("待审核帖子不允许锁定") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") + + // 域能相关 + ErrInvalidEnergyAmount = errors.New("无效的赋能数量") + ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能") + ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取") + ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限") + ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿") ) diff --git a/internal/controller/admin/admin_energy_controller.go b/internal/controller/admin/admin_energy_controller.go new file mode 100644 index 0000000..b70bbfa --- /dev/null +++ b/internal/controller/admin/admin_energy_controller.go @@ -0,0 +1,103 @@ +package admin + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:2 个方法) +type adminEnergyUseCase interface { + AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error + GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) +} + +// AdminEnergyController 后台域能管理控制器 +type AdminEnergyController struct { + energySvc adminEnergyUseCase +} + +// NewAdminEnergyController 构造函数 +func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyController { + return &AdminEnergyController{energySvc: energySvc} +} + +// EnergyPage 域能管理页面 +func (ac *AdminEnergyController) EnergyPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{ + "Title": "域能管理", + })) +} + +// EnergyLogPage 域能日志页面 +func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{ + "Title": "域能日志", + })) +} + +// AdjustEnergy 调整用户域能 +func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) { + operatorUID, ok := c.Get("uid") + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + UserIDs []uint `json:"user_ids" binding:"required,min=1"` + Amount int `json:"amount" binding:"required"` + Description string `json:"description" binding:"required,min=1,max=500"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description") + return + } + + if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description); err != nil { + common.Error(c, http.StatusInternalServerError, "调整失败") + return + } + + common.OkMessage(c, "域能调整成功") +} + +// ListEnergyLogs 查询域能日志 +func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) { + energyType := c.Query("type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + // 转换为展示友好的视图 + type logView struct { + model.EnergyLog + EnergyDisplay float64 `json:"energy_display"` + TypeName string `json:"type_name"` + } + views := make([]logView, len(logs)) + for i, l := range logs { + views[i] = logView{ + EnergyLog: l, + EnergyDisplay: float64(l.Amount) / 10, + TypeName: model.EnergyLogDisplayNames[l.Type], + } + } + + common.Ok(c, gin.H{ + "items": views, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} diff --git a/internal/controller/energy_controller.go b/internal/controller/energy_controller.go new file mode 100644 index 0000000..de7b10a --- /dev/null +++ b/internal/controller/energy_controller.go @@ -0,0 +1,146 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// EnergyController 域能 API 控制器 +type EnergyController struct { + energySvc energyUseCase +} + +// NewEnergyController 构造函数 +func NewEnergyController(energySvc energyUseCase) *EnergyController { + return &EnergyController{energySvc: energySvc} +} + +// Energize 赋能文章 +func (ec *EnergyController) Energize(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + PostID uint `json:"post_id" binding:"required"` + Amount int `json:"amount" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + expGained, err := ec.energySvc.Energize(uid, req.PostID, req.Amount) + if err != nil { + switch err { + case common.ErrPostNotFound: + common.Error(c, http.StatusNotFound, "文章不存在") + case common.ErrCannotEnergizeSelf: + common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能") + case common.ErrInsufficientEnergy: + common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取") + case common.ErrEnergizeLimitReached: + common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限") + case common.ErrInvalidEnergyAmount: + common.Error(c, http.StatusBadRequest, "无效的赋能数量") + default: + common.Error(c, http.StatusInternalServerError, "赋能失败") + } + return + } + + common.Ok(c, gin.H{ + "exp_gained": expGained, + "message": buildEnergizeMessage(expGained), + }) +} + +// buildEnergizeMessage 根据获得的经验生成提示消息 +func buildEnergizeMessage(expGained int) string { + if expGained > 0 { + return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。" + } + return "赋能成功!今日赋能经验奖励已达上限,对方仍会获得激励。" +} + +// GetEnergyInfo 获取域能余额和经验上限状态 +func (ec *EnergyController) GetEnergyInfo(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + energy, err := ec.energySvc.GetEnergyInfo(uid) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取域能信息失败") + return + } + + energyDisplay := float64(energy) / 10 + common.Ok(c, gin.H{ + "energy": energy, + "energy_display": energyDisplay, + }) +} + +// GetEnergyLogs 获取我的域能流水 +func (ec *EnergyController) GetEnergyLogs(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + logs, total, err := ec.energySvc.GetEnergyLogs(uid, days, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取流水失败") + return + } + + common.Ok(c, gin.H{ + "items": toEnergyLogViews(logs), + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} + +// EnergyLogView 前端展示用的域能流水视图 +type EnergyLogView struct { + ID uint `json:"id"` + Amount int `json:"amount"` + AmountDisplay float64 `json:"amount_display"` + Type string `json:"type"` + TypeName string `json:"type_name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView { + views := make([]EnergyLogView, len(logs)) + for i, l := range logs { + views[i] = EnergyLogView{ + ID: l.ID, + Amount: l.Amount, + AmountDisplay: float64(l.Amount) / 10, + Type: l.Type, + TypeName: model.EnergyLogDisplayNames[l.Type], + Description: l.Description, + CreatedAt: l.CreatedAt.Format(common.TimeFormat), + } + } + return views +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 4dd3008..e739cee 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -68,3 +68,26 @@ type levelUseCase interface { GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) } + +// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法) +type energyUseCase interface { + Energize(energizerID uint, postID uint, amount int) (int, error) + GetEnergyInfo(userID uint) (int, error) + GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) +} + +// energyDeducter 删稿/改名时域能扣减接口(ISP) +type energyDeducter interface { + DeductOnDeletePost(authorUserID uint, postID uint) error +} + +// energyRenameHandler 改名域能操作接口(ISP) +type energyRenameHandler interface { + DeductOnRename(userID uint) error +} + +// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:2 个方法) +type energyAdminUseCase interface { + AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error + GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) +} diff --git a/internal/controller/level_controller.go b/internal/controller/level_controller.go index d9403aa..090ebfb 100644 --- a/internal/controller/level_controller.go +++ b/internal/controller/level_controller.go @@ -18,7 +18,7 @@ func NewLevelController(levelSvc levelUseCase) *LevelController { return &LevelController{levelSvc: levelSvc} } -// CheckIn 手动签到 API +// CheckIn 手动签到 API(已废弃前端手动按钮,保留后端接口) func (lc *LevelController) CheckIn(c *gin.Context) { uidVal, exists := c.Get("uid") if !exists { diff --git a/internal/controller/settings_api_profile.go b/internal/controller/settings_api_profile.go index f45e55a..9bd6af8 100644 --- a/internal/controller/settings_api_profile.go +++ b/internal/controller/settings_api_profile.go @@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) { common.Error(c, http.StatusInternalServerError, "操作失败") return } + + // 改名:首次免费,非首次扣 6 域能 + if req.Username != "" && req.Username != user.Username && sc.energySvc != nil { + hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username") + if hasCompleted { + if err := sc.energySvc.DeductOnRename(userID); err != nil { + switch err { + case common.ErrInsufficientEnergy: + common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取") + default: + common.Error(c, http.StatusInternalServerError, "操作失败") + } + return + } + } + } + if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil { handleAuditSubmitError(c, err) return diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index 9d408c7..7b07d63 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -40,9 +40,10 @@ type sessionConfig interface { GetRememberTimeout() int // 分钟 } -// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励) +// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励 + 检查任务) type taskCompleter interface { CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) + HasCompletedTask(userID uint, taskType string) (bool, error) } // SettingsController 个人设置控制器 @@ -53,6 +54,7 @@ type SettingsController struct { sessionMgr sessionManager sessionCfg sessionConfig levelSvc taskCompleter + energySvc energyRenameHandler } // NewSettingsController 构造函数 @@ -60,6 +62,12 @@ func NewSettingsController(authService profileProvider, avatarService avatarProv return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc} } +// WithEnergyService 链式注入域能服务 +func (sc *SettingsController) WithEnergyService(svc energyRenameHandler) *SettingsController { + sc.energySvc = svc + return sc +} + // SettingsPage 个人设置页面(需登录) func (sc *SettingsController) SettingsPage(c *gin.Context) { uidVal, exists := c.Get("uid") @@ -70,7 +78,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { uid := uidVal.(uint) tab := c.Param("tab") - if tab != "profile" && tab != "account" && tab != "sessions" { + if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" { c.String(http.StatusNotFound, "页面不存在") return } diff --git a/internal/controller/studio_controller.go b/internal/controller/studio_controller.go index 4ffe6da..ae9f593 100644 --- a/internal/controller/studio_controller.go +++ b/internal/controller/studio_controller.go @@ -12,7 +12,8 @@ import ( // StudioController 创作中心控制器(SSR 页面 + API) type StudioController struct { - postService studioUseCase + postService studioUseCase + energySvc energyDeducter } // NewStudioController 构造函数 @@ -20,6 +21,12 @@ func NewStudioController(ps studioUseCase) *StudioController { return &StudioController{postService: ps} } +// WithEnergyService 链式注入域能服务 +func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController { + ctrl.energySvc = svc + return ctrl +} + // ============================== // SSR 页面方法 // ============================== @@ -250,6 +257,14 @@ func (ctrl *StudioController) Create(c *gin.Context) { return } + // LV0 用户不允许投稿 + if expVal, exists := c.Get("exp"); exists { + if exp, ok := expVal.(int); ok && exp < 1 { + common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿") + return + } + } + var req model.PostCreateRequest if err := c.ShouldBindJSON(&req); err != nil { common.Error(c, http.StatusBadRequest, "标题和正文不能为空") @@ -327,11 +342,18 @@ func (ctrl *StudioController) Delete(c *gin.Context) { return } + authorID := post.UserID + if err := ctrl.postService.Delete(uint(id)); err != nil { common.Error(c, http.StatusInternalServerError, "删除失败") return } + // 删稿扣作者域能(无视负数,静默失败) + if ctrl.energySvc != nil { + _ = ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)) + } + common.OkMessage(c, "删除成功") } diff --git a/internal/model/daily_exp_summary.go b/internal/model/daily_exp_summary.go new file mode 100644 index 0000000..8e000f7 --- /dev/null +++ b/internal/model/daily_exp_summary.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// DailyExpSummary 每日通过赋能获得的经验汇总(判断是否达 50 上限) +type DailyExpSummary struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_date;not null" json:"user_id"` + Date time.Time `gorm:"type:date;uniqueIndex:idx_user_date;not null" json:"date"` // Asia/Shanghai 自然日 + ExpEarned int `gorm:"default:0" json:"exp_earned"` // 当日通过赋能获得的经验值 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/model/energy_log.go b/internal/model/energy_log.go new file mode 100644 index 0000000..744c94a --- /dev/null +++ b/internal/model/energy_log.go @@ -0,0 +1,38 @@ +package model + +import "time" + +// 域能流水类型常量 +const ( + EnergyTypeSignIn = "sign_in" + EnergyTypeRename = "rename" + EnergyTypeRenameRefund = "rename_refund" + EnergyTypeEnergize = "energize" + EnergyTypeEnergized = "energized" + EnergyTypeDeletePost = "delete_post" + EnergyTypeAdminAdjust = "admin_adjust" +) + +// EnergyLog 域能流水记录 +type EnergyLog struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减) + Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust + RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"` + RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` + Description string `gorm:"type:varchar(500);default:''" json:"description,omitempty"` + OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` // 操作者UID(后台操作时必填) + CreatedAt time.Time `json:"created_at"` +} + +// EnergyLogDisplayNames 流水类型 → 中文名 +var EnergyLogDisplayNames = map[string]string{ + EnergyTypeSignIn: "每日签到", + EnergyTypeRename: "改名消耗", + EnergyTypeRenameRefund: "改名退款", + EnergyTypeEnergize: "赋能消耗", + EnergyTypeEnergized: "被赋能", + EnergyTypeDeletePost: "删稿消耗", + EnergyTypeAdminAdjust: "后台调整", +} diff --git a/internal/model/post.go b/internal/model/post.go index 8103e80..7184593 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -20,9 +20,10 @@ type Post struct { PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` AllowComment bool `gorm:"default:true" json:"allow_comment"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数) + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 非数据库字段(联表查询填充) // gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许 diff --git a/internal/model/post_energize_log.go b/internal/model/post_energize_log.go new file mode 100644 index 0000000..d34d318 --- /dev/null +++ b/internal/model/post_energize_log.go @@ -0,0 +1,12 @@ +package model + +import "time" + +// PostEnergizeLog 赋能操作记录(校验单用户单文章赋能总量 ≤ 2 域能) +type PostEnergizeLog struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` // 赋能者 + PostID uint `gorm:"index;not null" json:"post_id"` + Amount int `gorm:"not null" json:"amount"` // 赋能域能量(乘10,10或20) + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/model/user.go b/internal/model/user.go index 0cb62ea..d4459a0 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -15,8 +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"` // 经验值(只增不减) + // 经验值与域能 + Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减) + Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储) // 安全与审计 RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP diff --git a/internal/repository/energy_repo.go b/internal/repository/energy_repo.go new file mode 100644 index 0000000..8f2b260 --- /dev/null +++ b/internal/repository/energy_repo.go @@ -0,0 +1,138 @@ +package repository + +import ( + "time" + + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// EnergyRepo 域能数据访问 +type EnergyRepo struct { + db *gorm.DB +} + +// NewEnergyRepo 构造函数 +func NewEnergyRepo(db *gorm.DB) *EnergyRepo { + return &EnergyRepo{db: db} +} + +// FindUserByID 查找用户(获取能量余额等) +func (r *EnergyRepo) FindUserByID(userID uint) (*model.User, error) { + var user model.User + err := r.db.First(&user, userID).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// FindPostByID 查找帖子 +func (r *EnergyRepo) FindPostByID(postID uint) (*model.Post, error) { + var post model.Post + err := r.db.First(&post, postID).Error + if err != nil { + return nil, err + } + return &post, nil +} + +// AddEnergy 原子增减用户域能余额(amount 可为正负) +func (r *EnergyRepo) AddEnergy(userID uint, amount int) error { + return r.db.Model(&model.User{}). + Where("uid = ?", userID). + UpdateColumn("energy", gorm.Expr("energy + ?", amount)).Error +} + +// IncrPostEnergy 原子增减文章累计被赋能域能 +func (r *EnergyRepo) IncrPostEnergy(postID uint, amount int) error { + return r.db.Model(&model.Post{}). + Where("id = ?", postID). + UpdateColumn("total_energy_received", gorm.Expr("total_energy_received + ?", amount)).Error +} + +// CreateEnergyLog 创建域能流水 +func (r *EnergyRepo) CreateEnergyLog(log *model.EnergyLog) error { + return r.db.Create(log).Error +} + +// SumUserPostEnergy 统计某用户对某文章累计赋能总量(用于 ≤ 20 检查) +func (r *EnergyRepo) SumUserPostEnergy(userID, postID uint) (int, error) { + var total int + err := r.db.Model(&model.PostEnergizeLog{}). + Where("user_id = ? AND post_id = ?", userID, postID). + Select("COALESCE(SUM(amount), 0)"). + Scan(&total).Error + return total, err +} + +// CreateEnergizeLog 创建赋能记录 +func (r *EnergyRepo) CreateEnergizeLog(log *model.PostEnergizeLog) error { + return r.db.Create(log).Error +} + +// GetDailyEnergizeExp 获取用户当日通过赋能获得的经验值 +func (r *EnergyRepo) GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error) { + var s model.DailyExpSummary + err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&s).Error + if err != nil { + return nil, err + } + return &s, nil +} + +// UpsertDailyExp 创建或更新每日经验汇总 +func (r *EnergyRepo) UpsertDailyExp(summary *model.DailyExpSummary) error { + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}}, + DoUpdates: clause.AssignmentColumns([]string{"exp_earned", "updated_at"}), + }).Create(summary).Error +} + +// AddDailyExp 原子增加每日赋能经验值 +func (r *EnergyRepo) AddDailyExp(userID uint, date time.Time, delta int) error { + return r.db.Model(&model.DailyExpSummary{}). + Where("user_id = ? AND date = ?", userID, date). + UpdateColumn("exp_earned", gorm.Expr("exp_earned + ?", delta)).Error +} + +// ListEnergyLogsByUser 分页查询用户域能流水(近 N 天) +func (r *EnergyRepo) ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error) { + var logs []model.EnergyLog + query := r.db.Where("user_id = ?", userID).Order("created_at DESC") + if days > 0 { + since := time.Now().AddDate(0, 0, -days) + query = query.Where("created_at >= ?", since) + } + err := query.Offset(offset).Limit(limit).Find(&logs).Error + return logs, err +} + +// CountEnergyLogsByUser 统计用户域能流水总数 +func (r *EnergyRepo) CountEnergyLogsByUser(userID uint, days int) (int64, error) { + var count int64 + query := r.db.Model(&model.EnergyLog{}).Where("user_id = ?", userID) + if days > 0 { + since := time.Now().AddDate(0, 0, -days) + query = query.Where("created_at >= ?", since) + } + err := query.Count(&count).Error + return count, err +} + +// ListAllEnergyLogs 查询全部域能流水(后台,支持筛选) +func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error) { + query := r.db.Model(&model.EnergyLog{}) + if energyType != "" { + query = query.Where("type = ?", energyType) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var logs []model.EnergyLog + err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error + return logs, total, err +} diff --git a/internal/repository/level_repo.go b/internal/repository/level_repo.go index 8f4b693..7183b52 100644 --- a/internal/repository/level_repo.go +++ b/internal/repository/level_repo.go @@ -64,3 +64,12 @@ func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, err func (r *LevelRepo) CreateTask(task *model.UserTask) error { return r.db.Create(task).Error } + +// HasTask 检查用户是否已完成过指定任务(无副作用查询) +func (r *LevelRepo) HasTask(userID uint, taskType string) (bool, error) { + var count int64 + err := r.db.Model(&model.UserTask{}). + Where("user_id = ? AND task_type = ?", userID, taskType). + Count(&count).Error + return count > 0, err +} diff --git a/internal/router/admin.go b/internal/router/admin.go index ada6258..124f446 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -28,6 +28,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPages.GET("/posts", d.adminPostCtrl.PostsPage) adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage, middleware.RequirePageRole(model.RoleOwner)) + adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage) + adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage) } // --- 管理后台 API --- @@ -71,4 +73,16 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock) adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore) } + + // --- 域能管理 API(admin+,调整需要 owner) --- + adminEnergyAPI := r.Group("/api/admin/energy") + adminEnergyAPI.Use(d.authMdw.Required()) + adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) + adminEnergyAPI.Use(d.authMdw.BannedWriteGuard()) + adminEnergyAPI.Use(middleware.CSRF(cfg)) + { + adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy, + middleware.RequireMinRole(model.RoleOwner)) + adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs) + } } diff --git a/internal/router/api.go b/internal/router/api.go index 64211e1..bbe22f7 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -87,4 +87,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete) studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit) } + + // --- 域能 API(需登录) --- + energyAPI := r.Group("/api/energy") + energyAPI.Use(d.authMdw.Required()) + energyAPI.Use(d.authMdw.BannedWriteGuard()) + energyAPI.Use(middleware.CSRF(cfg)) + { + energyAPI.POST("/energize", d.energyCtrl.Energize) + energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo) + energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 6e75b7f..652793e 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -31,6 +31,9 @@ type dependencies struct { auditCtrl *adminCtrl.AuditController siteSettingCtrl *adminCtrl.SiteSettingController levelCtrl *controller.LevelController + energyCtrl *controller.EnergyController + adminEnergyCtrl *adminCtrl.AdminEnergyController + energyService *service.EnergyService } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 42aa142..9730b3c 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -19,15 +19,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting notificationRepo := repository.NewNotificationRepo(db) notificationService := service.NewNotificationService(notificationRepo) - // 等级/签到/任务系统 + // 域能系统 + energyRepo := repository.NewEnergyRepo(db) + energyService := service.NewEnergyService(db, energyRepo, nil, notificationService) + + // 等级/签到/任务系统(链式注入域能服务) _, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw) + levelSvc.WithEnergyService(energyService) + + // EnergyService 也需要 LevelService(用于赋能获得经验),通过 setter 注入 + energyService.SetExpAdder(levelSvc) auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc) + auditService.WithEnergyRefundService(energyService) auditController := adminCtrl.NewAuditController(auditService, userRepo) messageController := controller.NewMessageController(notificationService) settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc) + settingsCtrl.WithEnergyService(energyService) siteSettingController := adminCtrl.NewSiteSettingController( service.NewSiteSettingService(siteSettings), &service.AuditDefaults{ @@ -43,6 +53,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting adminPostCtrl := adminCtrl.NewAdminPostController(postService) adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr)) studioCtrl := controller.NewStudioController(postService) + studioCtrl.WithEnergyService(energyService) + + // 域能控制器 + energyCtrl := controller.NewEnergyController(energyService) + adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService) // 用户空间 spaceService := service.NewSpaceService(userRepo, postRepo) @@ -59,5 +74,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting adminCtrl: adminController, auditCtrl: auditController, siteSettingCtrl: siteSettingController, levelCtrl: levelCtrl, + energyCtrl: energyCtrl, + adminEnergyCtrl: adminEnergyCtrl, + energyService: energyService, } } diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index 1726ba3..d1a4f3a 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -44,14 +44,20 @@ type auditTaskCompleter interface { CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) } +// energyRenameRefunder 改名审核被拒时返还域能的接口(ISP) +type energyRenameRefunder interface { + RefundOnRenameReject(userID uint) error +} + // AuditService 审核业务逻辑 type AuditService struct { - auditRepo auditRepo - userRepo userProfileStore - settings siteSettingsChecker - defaultCfg config.AuditConfig - notifier auditNotifier - levelSvc auditTaskCompleter + auditRepo auditRepo + userRepo userProfileStore + settings siteSettingsChecker + defaultCfg config.AuditConfig + notifier auditNotifier + levelSvc auditTaskCompleter + energyRefundSvc energyRenameRefunder } // NewAuditService 构造函数 @@ -66,6 +72,12 @@ func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings si } } +// WithEnergyRefundService 链式注入域能退款服务 +func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditService { + s.energyRefundSvc = svc + return s +} + // ShouldAudit 判断指定用户是否需要走审核流程 // 规则:admin 及以上角色免审 func (s *AuditService) ShouldAudit(userID uint) (bool, error) { @@ -195,11 +207,18 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, } else { submission.Status = model.AuditStatusRejected submission.RejectReason = &reason - // 通知用户审核被拒 + // 通知用户审核被拒(改名审核追加域能返还提示) if s.notifier != nil { reasonMsg := reason + if submission.AuditType == model.AuditTypeUsername { + reasonMsg += "。扣除的域能已被返还" + } s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID) } + // 改名审核被拒 → 返还域能 + if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil { + _ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID) + } } return s.auditRepo.Update(submission) } diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go new file mode 100644 index 0000000..17aae7e --- /dev/null +++ b/internal/service/energy_service.go @@ -0,0 +1,319 @@ +package service + +import ( + "fmt" + "time" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// expAdder EnergyService 对经验增加的最小依赖(ISP) +type expAdder interface { + AddExp(userID uint, delta int) (newExp int, levelUp bool, err error) +} + +// EnergyService 域能业务逻辑 +type EnergyService struct { + db *gorm.DB + repo energyStore + expSvc expAdder + notifSvc energyNotifier +} + +// NewEnergyService 构造函数 +func NewEnergyService(db *gorm.DB, repo energyStore, expSvc expAdder, notifSvc energyNotifier) *EnergyService { + return &EnergyService{db: db, repo: repo, expSvc: expSvc, notifSvc: notifSvc} +} + +// SetExpAdder 链式注入经验服务(用于赋能获得经验) +func (s *EnergyService) SetExpAdder(svc expAdder) { + s.expSvc = svc +} + +// 域能相关常量(乘10存储,DB值) +const ( + EnergyCheckIn = 10 // +1.0 域能 + EnergyRename = -60 // -6.0 域能 + EnergyRenameRefund = 60 // +6.0 域能 + EnergyDeletePost = -20 // -2.0 域能 + EnergyEnergize1 = 10 // 轻赋 1 域能 + EnergyEnergize2 = 20 // 重赋 2 域能 + EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验 + DailyEnergizeExpCap = 50 // 每日赋能经验上限 + MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能(×10) +) + +// Energize 赋能文章 +// 返回:获得的经验值、错误 +func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) { + // 验证赋能量 + if amount != EnergyEnergize1 && amount != EnergyEnergize2 { + return 0, common.ErrInvalidEnergyAmount + } + + var expGained int + + err := s.db.Transaction(func(tx *gorm.DB) error { + // 1. 检查帖子存在,获取作者 + post, err := s.repo.FindPostByID(postID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return common.ErrPostNotFound + } + return fmt.Errorf("查询帖子失败: %w", err) + } + + // 2. 不能给自己赋能 + if post.UserID == energizerID { + return common.ErrCannotEnergizeSelf + } + + // 3. 检查赋能者域能余额(owner 跳过) + energizer, err := s.repo.FindUserByID(energizerID) + if err != nil { + return fmt.Errorf("查询用户失败: %w", err) + } + if energizer.Role != model.RoleOwner && energizer.Energy < amount { + return common.ErrInsufficientEnergy + } + + // 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost + total, err := s.repo.SumUserPostEnergy(energizerID, postID) + if err != nil { + return fmt.Errorf("查询赋能记录失败: %w", err) + } + if total+amount > MaxEnergizePerPost { + return common.ErrEnergizeLimitReached + } + + // 5. 扣赋能者域能 + if err := s.repo.AddEnergy(energizerID, -amount); err != nil { + return fmt.Errorf("扣减域能失败: %w", err) + } + + // 6. 增加被赋能者(文章作者)域能(赋能量的十分之一) + receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2 + if err := s.repo.AddEnergy(post.UserID, receivedAmount); err != nil { + return fmt.Errorf("增加被赋能者域能失败: %w", err) + } + + // 7. 更新 Post.total_energy_received + if err := s.repo.IncrPostEnergy(postID, amount); err != nil { + return fmt.Errorf("更新文章赋能计数失败: %w", err) + } + + // 8. 插入赋能记录 + eLog := &model.PostEnergizeLog{ + UserID: energizerID, + PostID: postID, + Amount: amount, + } + if err := s.repo.CreateEnergizeLog(eLog); err != nil { + return fmt.Errorf("创建赋能记录失败: %w", err) + } + + // 9. 写入域能流水 ×2 + amountDisplay := float64(amount) / 10 + receivedDisplay := float64(receivedAmount) / 10 + + energizerLog := &model.EnergyLog{ + UserID: energizerID, + Amount: -amount, + Type: model.EnergyTypeEnergize, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay), + } + if err := s.repo.CreateEnergyLog(energizerLog); err != nil { + return fmt.Errorf("创建赋能者流水失败: %w", err) + } + + energizedLog := &model.EnergyLog{ + UserID: post.UserID, + Amount: receivedAmount, + Type: model.EnergyTypeEnergized, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay), + } + if err := s.repo.CreateEnergyLog(energizedLog); err != nil { + return fmt.Errorf("创建被赋能者流水失败: %w", err) + } + + // 10. 计算经验值(溢出截断) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Now().In(loc) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + + summary, err := s.repo.GetDailyEnergizeExp(energizerID, today) + currentDaily := 0 + if err == nil { + currentDaily = summary.ExpEarned + } + + expGain := amount // 1域能(DB10) → 10经验, 2域能(DB20) → 20经验 + expRoom := DailyEnergizeExpCap - currentDaily + if expRoom < 0 { + expRoom = 0 + } + if expGain > expRoom { + expGain = expRoom + } + + // 11. 增加经验 + 更新每日经验汇总 + if expGain > 0 { + if s.expSvc != nil { + _, _, err = s.expSvc.AddExp(energizerID, expGain) + if err != nil { + return fmt.Errorf("增加经验失败: %w", err) + } + } + + newSummary := &model.DailyExpSummary{ + UserID: energizerID, + Date: today, + ExpEarned: currentDaily + expGain, + } + if err := s.repo.UpsertDailyExp(newSummary); err != nil { + return fmt.Errorf("更新每日经验汇总失败: %w", err) + } + } + + expGained = expGain + return nil + }) + + return expGained, err +} + +// CheckInEnergy 签到获得域能(LV0 用户不调用此方法) +func (s *EnergyService) CheckInEnergy(userID uint) error { + if err := s.repo.AddEnergy(userID, EnergyCheckIn); err != nil { + return fmt.Errorf("增加签到域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyCheckIn, + Type: model.EnergyTypeSignIn, + Description: "每日签到 +1.0 域能", + } + return s.repo.CreateEnergyLog(log) +} + +// DeductOnRename 改名扣域能(首次改名免费,不调用此方法) +func (s *EnergyService) DeductOnRename(userID uint) error { + // 检查域能余额(非 owner 需 ≥ 0) + user, err := s.repo.FindUserByID(userID) + if err != nil { + return fmt.Errorf("查询用户失败: %w", err) + } + if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 { + return common.ErrInsufficientEnergy + } + + if err := s.repo.AddEnergy(userID, EnergyRename); err != nil { + return fmt.Errorf("扣减改名域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyRename, + Type: model.EnergyTypeRename, + Description: "改名消耗 6.0 域能", + } + return s.repo.CreateEnergyLog(log) +} + +// RefundOnRenameReject 改名审核被拒,返还域能 +func (s *EnergyService) RefundOnRenameReject(userID uint) error { + if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil { + return fmt.Errorf("返还改名域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyRenameRefund, + Type: model.EnergyTypeRenameRefund, + Description: "改名审核被拒,返还 6.0 域能", + } + return s.repo.CreateEnergyLog(log) +} + +// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数) +func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error { + if err := s.repo.AddEnergy(authorUserID, EnergyDeletePost); err != nil { + return fmt.Errorf("扣减删稿域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: authorUserID, + Amount: EnergyDeletePost, + Type: model.EnergyTypeDeletePost, + RelatedType: "post", + RelatedID: postID, + Description: "删除文章消耗 2.0 域能", + } + return s.repo.CreateEnergyLog(log) +} + +// AdminAdjust 后台调整用户域能 +func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error { + for _, uid := range userIDs { + if err := s.repo.AddEnergy(uid, amount); err != nil { + return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err) + } + + opUID := operatorUID + log := &model.EnergyLog{ + UserID: uid, + Amount: amount, + Type: model.EnergyTypeAdminAdjust, + Description: description, + OperatorUID: &opUID, + } + if err := s.repo.CreateEnergyLog(log); err != nil { + return fmt.Errorf("创建调整流水失败: %w", err) + } + } + return nil +} + +// GetEnergyInfo 获取用户域能余额 +func (s *EnergyService) GetEnergyInfo(userID uint) (int, error) { + user, err := s.repo.FindUserByID(userID) + if err != nil { + return 0, err + } + return user.Energy, nil +} + +// GetEnergyLogs 分页查询用户域能流水(近 N 天) +func (s *EnergyService) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + + total, err := s.repo.CountEnergyLogsByUser(userID, days) + if err != nil { + return nil, 0, err + } + + logs, err := s.repo.ListEnergyLogsByUser(userID, days, p.Offset(), p.PageSize) + if err != nil { + return nil, 0, err + } + if logs == nil { + logs = []model.EnergyLog{} + } + return logs, total, nil +} + +// GetAdminEnergyLogs 后台查询全部域能流水 +func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize) +} diff --git a/internal/service/level_service.go b/internal/service/level_service.go index d2f3ece..9f84e3b 100644 --- a/internal/service/level_service.go +++ b/internal/service/level_service.go @@ -9,10 +9,16 @@ import ( // LevelService 积分与等级业务逻辑 type LevelService struct { - userRepo userLevelStore - checkRepo checkInStore - taskRepo taskStore - notifSvc levelNotifier + userRepo userLevelStore + checkRepo checkInStore + taskRepo taskStore + notifSvc levelNotifier + energyCheckInSvc energyCheckIner +} + +// energyCheckIner LevelService 对域能签到的最小依赖(ISP) +type energyCheckIner interface { + CheckInEnergy(userID uint) error } // NewLevelService 构造函数 @@ -20,6 +26,12 @@ func NewLevelService(userRepo userLevelStore, checkRepo checkInStore, taskRepo t return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc} } +// WithEnergyService 链式注入域能服务(用于签到域能奖励) +func (s *LevelService) WithEnergyService(svc energyCheckIner) *LevelService { + s.energyCheckInSvc = svc + return s +} + // GetLevel 获取用户当前等级、经验值和今日签到状态 func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) { user, err := s.userRepo.FindByID(userID) @@ -81,7 +93,17 @@ func (s *LevelService) AddExp(userID uint, delta int) (newExp int, levelUp bool, // CheckIn 执行签到(基于 Asia/Shanghai 自然日严格防重) // 返回:是否成功签到、新经验值、是否升级、错误 +// LV0 用户不触发签到 func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error) { + // LV0 用户不触发签到 + user, err := s.userRepo.FindByID(userID) + if err != nil { + return false, 0, false, err + } + if user.Exp < 1 { + return false, user.Exp, false, nil + } + loc, _ := time.LoadLocation("Asia/Shanghai") now := time.Now().In(loc) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) @@ -90,11 +112,7 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp _, 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 + return false, user.Exp, false, nil } // 创建签到记录 @@ -104,11 +122,12 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp } 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 + return false, user.Exp, false, nil + } + + // 增加域能(静默失败不影响签到经验) + if s.energyCheckInSvc != nil { + _ = s.energyCheckInSvc.CheckInEnergy(userID) } // 增加经验值 @@ -116,6 +135,11 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp return true, newExp, levelUp, err } +// HasCompletedTask 检查用户是否已完成过指定任务(无副作用查询) +func (s *LevelService) HasCompletedTask(userID uint, taskType string) (bool, error) { + return s.taskRepo.HasTask(userID, taskType) +} + // CompleteTask 完成首次任务(严格防重) // 返回:新经验值、是否升级、错误 func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) { diff --git a/internal/service/repository.go b/internal/service/repository.go index e2571de..9986524 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -79,9 +79,32 @@ type checkInStore interface { type taskStore interface { FindTask(userID uint, taskType string) (*model.UserTask, error) CreateTask(task *model.UserTask) error + HasTask(userID uint, taskType string) (bool, error) } // levelNotifier 等级升级通知接口 type levelNotifier interface { Create(userID uint, notifyType, title, content string, relatedID *uint) error } + +// energyStore EnergyService 所需的完整仓储接口 +type energyStore interface { + FindUserByID(userID uint) (*model.User, error) + FindPostByID(postID uint) (*model.Post, error) + AddEnergy(userID uint, amount int) error + IncrPostEnergy(postID uint, amount int) error + CreateEnergyLog(log *model.EnergyLog) error + SumUserPostEnergy(userID, postID uint) (int, error) + CreateEnergizeLog(log *model.PostEnergizeLog) error + GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error) + UpsertDailyExp(summary *model.DailyExpSummary) error + AddDailyExp(userID uint, date time.Time, delta int) error + ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error) + CountEnergyLogsByUser(userID uint, days int) (int64, error) + ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error) +} + +// energyNotifier EnergyService 所需的通知接口(ISP) +type energyNotifier interface { + Create(userID uint, notifyType, title, content string, relatedID *uint) error +} diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index e245fd6..0e809ec 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -43,10 +43,6 @@ {{end}} - - - 签到 - 个人中心 diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index fc0b6db..cc64640 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -39,6 +39,9 @@ {{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}} {{end}} + {{if and $.Post (ne $.Post.UserID $.UID)}} + + {{end}} {{end}} @@ -107,5 +110,49 @@ }); })(); + + diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index 31fb0ff..4be9ea1 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -33,6 +33,13 @@ 登录管理 + + + + + 我的域能 + @@ -147,6 +154,25 @@ 将强制退出除当前设备外的所有登录会话 + {{else if eq .ActiveTab "energy"}} + +
+
+

我的域能

+

域能流水和余额

+
+
+
+
+ 域能余额 + 加载中... +
+
+
+
加载中...
+
+
+
{{else}}
@@ -869,6 +895,61 @@ }, 500); } })(); + + // ===== 域能 Tab ===== + (function () { + var balanceEl = document.getElementById('energyBalance'); + var logListEl = document.getElementById('energyLogList'); + if (!balanceEl || !logListEl) return; + + // 获取域能信息 + fetch('/api/energy/info') + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.success && d.data) { + balanceEl.textContent = d.data.energy_display + ' 域能'; + } else { + balanceEl.textContent = '获取失败'; + } + }) + .catch(function () { balanceEl.textContent = '获取失败'; }); + + // 获取域能流水 + fetch('/api/energy/logs?days=7&page=1&page_size=50') + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success || !d.data || !d.data.items || d.data.items.length === 0) { + logListEl.innerHTML = '
近7天无变更记录
'; + return; + } + + var html = ''; + d.data.items.forEach(function (item) { + var cls = item.amount > 0 ? 'energy-log-plus' : 'energy-log-minus'; + var sign = item.amount > 0 ? '+' : ''; + html += '
' + + '
' + + '' + escapeHTML(item.type_name) + '' + + '' + escapeHTML(item.created_at) + '' + + '
' + + '
' + + '' + escapeHTML(item.description) + '' + + '' + sign + item.amount_display + ' 域能' + + '
' + + '
'; + }); + logListEl.innerHTML = html; + }) + .catch(function () { + logListEl.innerHTML = '
加载失败
'; + }); + + function escapeHTML(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + })(); })(); diff --git a/templates/MetaLab-2026/static/css/settings.css b/templates/MetaLab-2026/static/css/settings.css index cd021f3..3e86aff 100644 --- a/templates/MetaLab-2026/static/css/settings.css +++ b/templates/MetaLab-2026/static/css/settings.css @@ -783,3 +783,97 @@ body { max-width: 100%; } } + +/* ---------- Energy Tab ---------- */ +.energy-summary { + display: flex; + gap: 1.5rem; + padding: 1rem 0; + border-bottom: 1px solid #f0f0f0; + margin-bottom: 1rem; +} + +.energy-stat { + display: flex; + flex-direction: column; + gap: .25rem; +} + +.energy-label { + font-size: .82rem; + color: var(--color-text-light); + font-weight: 500; +} + +.energy-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-accent); +} + +.energy-log-list { + display: flex; + flex-direction: column; +} + +.energy-log-empty { + text-align: center; + padding: 2rem 0; + color: #bbb; + font-size: .9rem; +} + +.energy-log-item { + display: flex; + flex-direction: column; + gap: .3rem; + padding: .85rem 0; + border-bottom: 1px solid #f5f5f5; +} + +.energy-log-item:last-child { + border-bottom: none; +} + +.energy-log-meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +.energy-log-type { + font-size: .85rem; + font-weight: 600; + color: var(--color-text); +} + +.energy-log-time { + font-size: .78rem; + color: #aaa; +} + +.energy-log-info { + display: flex; + justify-content: space-between; + align-items: center; +} + +.energy-log-desc { + font-size: .82rem; + color: var(--color-text-light); +} + +.energy-log-amount { + font-size: .88rem; + font-weight: 600; + white-space: nowrap; + margin-left: 1rem; +} + +.energy-log-plus .energy-log-amount { + color: #2ecc71; +} + +.energy-log-minus .energy-log-amount { + color: #e74c3c; +} diff --git a/templates/MetaLab-2026/static/js/common.js b/templates/MetaLab-2026/static/js/common.js index bb9ac52..a4f9b0d 100644 --- a/templates/MetaLab-2026/static/js/common.js +++ b/templates/MetaLab-2026/static/js/common.js @@ -283,59 +283,6 @@ 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 仍有效 diff --git a/templates/admin/html/energy/index.html b/templates/admin/html/energy/index.html new file mode 100644 index 0000000..7b62aaf --- /dev/null +++ b/templates/admin/html/energy/index.html @@ -0,0 +1,2 @@ +{{template "admin/layout/base.html" .}} +{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/energy/logs.html b/templates/admin/html/energy/logs.html new file mode 100644 index 0000000..7b62aaf --- /dev/null +++ b/templates/admin/html/energy/logs.html @@ -0,0 +1,2 @@ +{{template "admin/layout/base.html" .}} +{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html index f949fe8..7bf1b5a 100644 --- a/templates/admin/html/layout/base.html +++ b/templates/admin/html/layout/base.html @@ -43,6 +43,14 @@ 内容管理 + + + 域能管理 + + + + 域能日志 + {{if .CanManageSettings}}