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

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

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 积分与等级业务逻辑
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) {

View File

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