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 @@
});
})();
+
+