feat: 实现域能系统

- 新增域能完整分层:EnergyLog/PostEnergizeLog/DailyExpSummary 模型、EnergyRepo、EnergyService、EnergyController/AdminEnergyController
- 支持签到获取域能(+1)、帖子赋能(-1/-2)/被赋能(+1/+2)、改名扣能(-6)/退款(+6)、删帖扣能(-2)
- 赋能单人单帖上限20域能,每日赋能获得经验上限50
- 管理员支持批量调整域能(owner)和查询域能日志(admin)
- LV0用户(exp<1)禁止签到和发帖,余额为负可签到/被赋能/删帖但不能赋能/改名
- 首次改名免费(通过HasCompletedTask判重),改名扣能在提交审核时执行
- 移除导航栏手动签到按钮,签到融入域能系统(自动签到)
- 新增个人中心域能TAB展示余额和流水日志(近7天)
- 新增帖子详情页赋能按钮(轻赋/重赋)
- 管理后台新增域能管理和域能日志页面
This commit is contained in:
2026-06-01 03:32:50 +08:00
parent 394989b281
commit e1d7c318ab
31 changed files with 1234 additions and 88 deletions

View File

@ -45,4 +45,11 @@ var (
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = errors.New("待审核帖子不允许锁定") ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
// 域能相关
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
) )

View File

@ -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 的最小依赖ISP2 个方法)
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),
})
}

View File

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

View File

@ -68,3 +68,26 @@ type levelUseCase interface {
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
} }
// energyUseCase EnergyController 对 EnergyService 的最小依赖ISP3 个方法)
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 的依赖ISP2 个方法)
type energyAdminUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
}

View File

@ -18,7 +18,7 @@ func NewLevelController(levelSvc levelUseCase) *LevelController {
return &LevelController{levelSvc: levelSvc} return &LevelController{levelSvc: levelSvc}
} }
// CheckIn 手动签到 API // CheckIn 手动签到 API(已废弃前端手动按钮,保留后端接口)
func (lc *LevelController) CheckIn(c *gin.Context) { func (lc *LevelController) CheckIn(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
if !exists { if !exists {

View File

@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
return 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 { if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
handleAuditSubmitError(c, err) handleAuditSubmitError(c, err)
return return

View File

@ -40,9 +40,10 @@ type sessionConfig interface {
GetRememberTimeout() int // 分钟 GetRememberTimeout() int // 分钟
} }
// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励) // taskCompleter 设置页对等级服务的依赖(用于首次任务奖励 + 检查任务
type taskCompleter interface { type taskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
HasCompletedTask(userID uint, taskType string) (bool, error)
} }
// SettingsController 个人设置控制器 // SettingsController 个人设置控制器
@ -53,6 +54,7 @@ type SettingsController struct {
sessionMgr sessionManager sessionMgr sessionManager
sessionCfg sessionConfig sessionCfg sessionConfig
levelSvc taskCompleter levelSvc taskCompleter
energySvc energyRenameHandler
} }
// NewSettingsController 构造函数 // 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} 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 个人设置页面(需登录) // SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) { func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
@ -70,7 +78,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
uid := uidVal.(uint) uid := uidVal.(uint)
tab := c.Param("tab") tab := c.Param("tab")
if tab != "profile" && tab != "account" && tab != "sessions" { if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" {
c.String(http.StatusNotFound, "页面不存在") c.String(http.StatusNotFound, "页面不存在")
return return
} }

View File

@ -12,7 +12,8 @@ import (
// StudioController 创作中心控制器SSR 页面 + API // StudioController 创作中心控制器SSR 页面 + API
type StudioController struct { type StudioController struct {
postService studioUseCase postService studioUseCase
energySvc energyDeducter
} }
// NewStudioController 构造函数 // NewStudioController 构造函数
@ -20,6 +21,12 @@ func NewStudioController(ps studioUseCase) *StudioController {
return &StudioController{postService: ps} return &StudioController{postService: ps}
} }
// WithEnergyService 链式注入域能服务
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
ctrl.energySvc = svc
return ctrl
}
// ============================== // ==============================
// SSR 页面方法 // SSR 页面方法
// ============================== // ==============================
@ -250,6 +257,14 @@ func (ctrl *StudioController) Create(c *gin.Context) {
return 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 var req model.PostCreateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空") common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
@ -327,11 +342,18 @@ func (ctrl *StudioController) Delete(c *gin.Context) {
return return
} }
authorID := post.UserID
if err := ctrl.postService.Delete(uint(id)); err != nil { if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败") common.Error(c, http.StatusInternalServerError, "删除失败")
return return
} }
// 删稿扣作者域能(无视负数,静默失败)
if ctrl.energySvc != nil {
_ = ctrl.energySvc.DeductOnDeletePost(authorID, uint(id))
}
common.OkMessage(c, "删除成功") common.OkMessage(c, "删除成功")
} }

View File

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

View File

@ -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: "后台调整",
}

View File

@ -20,9 +20,10 @@ type Post struct {
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数
CreatedAt time.Time `json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充) // 非数据库字段(联表查询填充)
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许 // gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许

View File

@ -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"` // 赋能域能量乘1010或20
CreatedAt time.Time `json:"created_at"`
}

View File

@ -15,8 +15,9 @@ type User struct {
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"` 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"` 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 RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP

View File

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

View File

@ -64,3 +64,12 @@ func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, err
func (r *LevelRepo) CreateTask(task *model.UserTask) error { func (r *LevelRepo) CreateTask(task *model.UserTask) error {
return r.db.Create(task).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
}

View File

@ -28,6 +28,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/posts", d.adminPostCtrl.PostsPage) adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage, adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
middleware.RequirePageRole(model.RoleOwner)) middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
} }
// --- 管理后台 API --- // --- 管理后台 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/unlock", d.adminPostCtrl.Unlock)
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore) adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
} }
// --- 域能管理 APIadmin+,调整需要 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)
}
} }

View File

@ -87,4 +87,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete) studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit) 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)
}
} }

View File

@ -31,6 +31,9 @@ type dependencies struct {
auditCtrl *adminCtrl.AuditController auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController siteSettingCtrl *adminCtrl.SiteSettingController
levelCtrl *controller.LevelController 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) ( func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -19,15 +19,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
notificationRepo := repository.NewNotificationRepo(db) notificationRepo := repository.NewNotificationRepo(db)
notificationService := service.NewNotificationService(notificationRepo) notificationService := service.NewNotificationService(notificationRepo)
// 等级/签到/任务系统 // 域能系统
energyRepo := repository.NewEnergyRepo(db)
energyService := service.NewEnergyService(db, energyRepo, nil, notificationService)
// 等级/签到/任务系统(链式注入域能服务)
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw) _, 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 := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
auditService.WithEnergyRefundService(energyService)
auditController := adminCtrl.NewAuditController(auditService, userRepo) auditController := adminCtrl.NewAuditController(auditService, userRepo)
messageController := controller.NewMessageController(notificationService) messageController := controller.NewMessageController(notificationService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc) settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
settingsCtrl.WithEnergyService(energyService)
siteSettingController := adminCtrl.NewSiteSettingController( siteSettingController := adminCtrl.NewSiteSettingController(
service.NewSiteSettingService(siteSettings), service.NewSiteSettingService(siteSettings),
&service.AuditDefaults{ &service.AuditDefaults{
@ -43,6 +53,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
adminPostCtrl := adminCtrl.NewAdminPostController(postService) adminPostCtrl := adminCtrl.NewAdminPostController(postService)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr)) adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr))
studioCtrl := controller.NewStudioController(postService) studioCtrl := controller.NewStudioController(postService)
studioCtrl.WithEnergyService(energyService)
// 域能控制器
energyCtrl := controller.NewEnergyController(energyService)
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
// 用户空间 // 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo) spaceService := service.NewSpaceService(userRepo, postRepo)
@ -59,5 +74,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
adminCtrl: adminController, auditCtrl: auditController, adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, siteSettingCtrl: siteSettingController,
levelCtrl: levelCtrl, levelCtrl: levelCtrl,
energyCtrl: energyCtrl,
adminEnergyCtrl: adminEnergyCtrl,
energyService: energyService,
} }
} }

View File

@ -44,14 +44,20 @@ type auditTaskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
} }
// energyRenameRefunder 改名审核被拒时返还域能的接口ISP
type energyRenameRefunder interface {
RefundOnRenameReject(userID uint) error
}
// AuditService 审核业务逻辑 // AuditService 审核业务逻辑
type AuditService struct { type AuditService struct {
auditRepo auditRepo auditRepo auditRepo
userRepo userProfileStore userRepo userProfileStore
settings siteSettingsChecker settings siteSettingsChecker
defaultCfg config.AuditConfig defaultCfg config.AuditConfig
notifier auditNotifier notifier auditNotifier
levelSvc auditTaskCompleter levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder
} }
// NewAuditService 构造函数 // 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 判断指定用户是否需要走审核流程 // ShouldAudit 判断指定用户是否需要走审核流程
// 规则admin 及以上角色免审 // 规则admin 及以上角色免审
func (s *AuditService) ShouldAudit(userID uint) (bool, error) { func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
@ -195,11 +207,18 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
} else { } else {
submission.Status = model.AuditStatusRejected submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason submission.RejectReason = &reason
// 通知用户审核被拒 // 通知用户审核被拒(改名审核追加域能返还提示)
if s.notifier != nil { if s.notifier != nil {
reasonMsg := reason reasonMsg := reason
if submission.AuditType == model.AuditTypeUsername {
reasonMsg += "。扣除的域能已被返还"
}
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID) 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) return s.auditRepo.Update(submission)
} }

View File

@ -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.12 域能→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)
}

View File

@ -9,10 +9,16 @@ import (
// LevelService 积分与等级业务逻辑 // LevelService 积分与等级业务逻辑
type LevelService struct { type LevelService struct {
userRepo userLevelStore userRepo userLevelStore
checkRepo checkInStore checkRepo checkInStore
taskRepo taskStore taskRepo taskStore
notifSvc levelNotifier notifSvc levelNotifier
energyCheckInSvc energyCheckIner
}
// energyCheckIner LevelService 对域能签到的最小依赖ISP
type energyCheckIner interface {
CheckInEnergy(userID uint) error
} }
// NewLevelService 构造函数 // NewLevelService 构造函数
@ -20,6 +26,12 @@ func NewLevelService(userRepo userLevelStore, checkRepo checkInStore, taskRepo t
return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc} return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc}
} }
// WithEnergyService 链式注入域能服务(用于签到域能奖励)
func (s *LevelService) WithEnergyService(svc energyCheckIner) *LevelService {
s.energyCheckInSvc = svc
return s
}
// GetLevel 获取用户当前等级、经验值和今日签到状态 // GetLevel 获取用户当前等级、经验值和今日签到状态
func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) { func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) {
user, err := s.userRepo.FindByID(userID) 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 自然日严格防重) // CheckIn 执行签到(基于 Asia/Shanghai 自然日严格防重)
// 返回:是否成功签到、新经验值、是否升级、错误 // 返回:是否成功签到、新经验值、是否升级、错误
// LV0 用户不触发签到
func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error) { 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") loc, _ := time.LoadLocation("Asia/Shanghai")
now := time.Now().In(loc) now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, 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) _, err = s.checkRepo.FindCheckIn(userID, today)
if err == nil { if err == nil {
// 已签到 // 已签到
user, _ := s.userRepo.FindByID(userID) return false, user.Exp, false, nil
if user != nil {
return false, user.Exp, false, nil
}
return false, 0, 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 { if err := s.checkRepo.CreateCheckIn(ci); err != nil {
// 唯一索引冲突 → 已签到 // 唯一索引冲突 → 已签到
user, _ := s.userRepo.FindByID(userID) return false, user.Exp, false, nil
if user != nil { }
return false, user.Exp, false, nil
} // 增加域能(静默失败不影响签到经验)
return false, 0, 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 return true, newExp, levelUp, err
} }
// HasCompletedTask 检查用户是否已完成过指定任务(无副作用查询)
func (s *LevelService) HasCompletedTask(userID uint, taskType string) (bool, error) {
return s.taskRepo.HasTask(userID, taskType)
}
// CompleteTask 完成首次任务(严格防重) // CompleteTask 完成首次任务(严格防重)
// 返回:新经验值、是否升级、错误 // 返回:新经验值、是否升级、错误
func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) { func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) {

View File

@ -79,9 +79,32 @@ type checkInStore interface {
type taskStore interface { type taskStore interface {
FindTask(userID uint, taskType string) (*model.UserTask, error) FindTask(userID uint, taskType string) (*model.UserTask, error)
CreateTask(task *model.UserTask) error CreateTask(task *model.UserTask) error
HasTask(userID uint, taskType string) (bool, error)
} }
// levelNotifier 等级升级通知接口 // levelNotifier 等级升级通知接口
type levelNotifier interface { type levelNotifier interface {
Create(userID uint, notifyType, title, content string, relatedID *uint) error 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
}

View File

@ -43,10 +43,6 @@
{{end}} {{end}}
</div> </div>
</div> </div>
<a href="javascript:void(0)" class="nav-dropdown-item" id="checkInBtn">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>签到</span>
</a>
<a href="/settings/profile" class="nav-dropdown-item"> <a href="/settings/profile" class="nav-dropdown-item">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>个人中心</span> <span>个人中心</span>

View File

@ -39,6 +39,9 @@
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}} {{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}}
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button> <button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
{{end}} {{end}}
{{if and $.Post (ne $.Post.UserID $.UID)}}
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
@ -107,5 +110,49 @@
}); });
})(); })();
</script> </script>
<script>
// 赋能按钮
(function() {
var btn = document.getElementById('energizeBtn');
if (!btn) return;
btn.addEventListener('click', function() {
var postId = btn.dataset.id;
var choice = confirm('为这篇文章注入能量\n\n选择「确定」轻赋 1 域能,选择「取消」则打开重赋选项');
var amount = 20; // 重赋 2 域能
if (choice) {
amount = 10; // 轻赋 1 域能
}
var csrf = document.querySelector('meta[name="csrf-token"]');
btn.disabled = true;
btn.textContent = '赋能中...';
fetch('/api/energy/energize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf.getAttribute('content') : ''
},
body: JSON.stringify({ post_id: parseInt(postId), amount: amount })
})
.then(function(r) { return r.json(); })
.then(function(d) {
btn.disabled = false;
btn.textContent = '赋能';
if (d.success) {
alert(d.data.message || '赋能成功!');
} else {
alert(d.message || '赋能失败');
}
})
.catch(function() {
btn.disabled = false;
btn.textContent = '赋能';
alert('请求失败');
});
});
})();
</script>
</body> </body>
</html> </html>

View File

@ -33,6 +33,13 @@
</svg> </svg>
登录管理 登录管理
</a> </a>
<a href="/settings/energy"
class="settings-nav-item {{if eq .ActiveTab "energy"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
我的域能
</a>
</nav> </nav>
</aside> </aside>
@ -147,6 +154,25 @@
<span class="form-hint">将强制退出除当前设备外的所有登录会话</span> <span class="form-hint">将强制退出除当前设备外的所有登录会话</span>
</div> </div>
</div> </div>
{{else if eq .ActiveTab "energy"}}
<!-- 我的域能 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>我的域能</h2>
<p class="settings-card-desc">域能流水和余额</p>
</div>
<div class="settings-card-body">
<div class="energy-summary">
<div class="energy-stat">
<span class="energy-label">域能余额</span>
<span class="energy-value" id="energyBalance">加载中...</span>
</div>
</div>
<div class="energy-log-list" id="energyLogList">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
</div>
{{else}} {{else}}
<!-- 账号信息 --> <!-- 账号信息 -->
<div class="settings-card"> <div class="settings-card">
@ -869,6 +895,61 @@
}, 500); }, 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 = '<div class="energy-log-empty">近7天无变更记录</div>';
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 += '<div class="energy-log-item ' + cls + '">'
+ '<div class="energy-log-meta">'
+ '<span class="energy-log-type">' + escapeHTML(item.type_name) + '</span>'
+ '<span class="energy-log-time">' + escapeHTML(item.created_at) + '</span>'
+ '</div>'
+ '<div class="energy-log-info">'
+ '<span class="energy-log-desc">' + escapeHTML(item.description) + '</span>'
+ '<span class="energy-log-amount">' + sign + item.amount_display + ' 域能</span>'
+ '</div>'
+ '</div>';
});
logListEl.innerHTML = html;
})
.catch(function () {
logListEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
});
function escapeHTML(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
})();
})(); })();
</script> </script>
</body> </body>

View File

@ -783,3 +783,97 @@ body {
max-width: 100%; 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;
}

View File

@ -283,59 +283,6 @@
setInterval(pollUnread, POLL_INTERVAL); 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) ---- // ---- Auto-refresh token (silent) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期) // 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效 // 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效

View File

@ -0,0 +1,2 @@
{{template "admin/layout/base.html" .}}
{{template "admin/layout/footer.html" .}}

View File

@ -0,0 +1,2 @@
{{template "admin/layout/base.html" .}}
{{template "admin/layout/footer.html" .}}

View File

@ -43,6 +43,14 @@
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span> <span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
内容管理 内容管理
</a> </a>
<a href="/admin/energy" class="sidebar-item {{if eq .CurrentPath "/admin/energy"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
域能管理
</a>
<a href="/admin/energy/logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/logs"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
域能日志
</a>
{{if .CanManageSettings}} {{if .CanManageSettings}}
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}"> <a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span> <span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>