This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/internal/service/energy_service.go
Victor_Jay 4815060839 fix: 修复域能管理操作无响应及公户账务错误
- 修复前端 energy.js api.post/get 回调调用为 Promise 链式调用,解决操作无响应
- 修复 AdminAdjust system_operation 模式错误扣减公户余额,现不碰公户
- 修复 AdminAdjust admin_transfer 模式公户按用户数量扣款(amount * len(userIDs))
- 修复 AdminAdjust 余额检查移入事务内并加行锁(SELECT FOR UPDATE),防 TOCTOU 竞态
- FundStore 接口及 FundRepo 新增 LockAndGetBalance 方法
2026-06-01 15:26:25 +08:00

539 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
fundRepo FundStore
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
}
// SetFundRepo 链式注入公户仓储
func (s *EnergyService) SetFundRepo(repo FundStore) {
s.fundRepo = repo
}
// EnergyInfo 域能信息(含每日赋能经验状态)
type EnergyInfo struct {
Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限
}
// 域能相关常量乘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
var needExpUpdate bool
var expToday time.Time
var expTotal int
err := s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
var txFundRepo FundStore
if s.fundRepo != nil {
txFundRepo = s.fundRepo.WithTx(tx)
}
// 1. 检查帖子存在,获取作者
post, err := txRepo.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 := txRepo.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 := txRepo.SumUserPostEnergy(energizerID, postID)
if err != nil {
return fmt.Errorf("查询赋能记录失败: %w", err)
}
if total+amount > MaxEnergizePerPost {
return common.ErrEnergizeLimitReached
}
// 5. 扣赋能者域能
if err := txRepo.AddEnergy(energizerID, -amount); err != nil {
return fmt.Errorf("扣减域能失败: %w", err)
}
// 6. 增加被赋能者(文章作者)域能(赋能量的十分之一)
receivedAmount := amount / 10 // 1 域能→0.12 域能→0.2
if err := txRepo.AddEnergy(post.UserID, receivedAmount); err != nil {
return fmt.Errorf("增加被赋能者域能失败: %w", err)
}
// 7. 更新 Post.total_energy_received
if err := txRepo.IncrPostEnergy(postID, amount); err != nil {
return fmt.Errorf("更新文章赋能计数失败: %w", err)
}
// 8. 插入赋能记录
eLog := &model.PostEnergizeLog{
UserID: energizerID,
PostID: postID,
Amount: amount,
}
if err := txRepo.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 := txRepo.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 := txRepo.CreateEnergyLog(energizedLog); err != nil {
return fmt.Errorf("创建被赋能者流水失败: %w", err)
}
// 10. 赋能税收入公户(差价 = amount - receivedAmount
if txFundRepo != nil {
tax := amount - receivedAmount
if tax > 0 {
if err := txFundRepo.IncrBalance(tax); err != nil {
return fmt.Errorf("公户入账失败: %w", err)
}
taxDisplay := float64(tax) / 10
fundLog := &model.FundLog{
Amount: tax,
Type: model.FundTypeEnergizeTax,
RelatedType: "post",
RelatedID: postID,
Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID),
}
if err := txFundRepo.CreateLog(fundLog); err != nil {
return fmt.Errorf("创建公户流水失败: %w", err)
}
}
}
// 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性
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 := txRepo.GetDailyEnergizeExp(energizerID, today)
currentDaily := 0
if err == nil && summary != 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
}
expGained = expGain
if expGain > 0 {
needExpUpdate = true
expToday = today
expTotal = currentDaily + expGain
}
return nil
})
if err != nil {
return 0, err
}
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
if needExpUpdate {
if s.expSvc != nil {
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
}
}
newSummary := &model.DailyExpSummary{
UserID: energizerID,
Date: expToday,
ExpEarned: expTotal,
}
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
}
}
return expGained, nil
}
// 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 {
return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
// 检查域能余额(非 owner 需 ≥ 0
user, err := txRepo.FindUserByID(userID)
if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
return common.ErrInsufficientEnergy
}
if err := txRepo.AddEnergy(userID, EnergyRename); err != nil {
return fmt.Errorf("扣减改名域能失败: %w", err)
}
// 公户入账
if s.fundRepo != nil {
txFundRepo := s.fundRepo.WithTx(tx)
if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60入公户 +60
return fmt.Errorf("公户入账失败: %w", err)
}
fundLog := &model.FundLog{
Amount: -EnergyRename,
Type: model.FundTypeRenameDeduction,
RelatedType: "user",
RelatedID: userID,
Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID),
}
if err := txFundRepo.CreateLog(fundLog); err != nil {
return fmt.Errorf("创建公户流水失败: %w", err)
}
}
log := &model.EnergyLog{
UserID: userID,
Amount: EnergyRename,
Type: model.EnergyTypeRename,
Description: "改名消耗 6.0 域能",
}
return txRepo.CreateEnergyLog(log)
})
}
// RefundOnRenameReject 改名审核被拒,从公户返还域能
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
// 从公户支出(允许透支)
if s.fundRepo != nil {
txFundRepo := s.fundRepo.WithTx(tx)
if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil {
return fmt.Errorf("公户出账失败: %w", err)
}
fundLog := &model.FundLog{
Amount: -EnergyRenameRefund,
Type: model.FundTypeRenameRefund,
RelatedType: "user",
RelatedID: userID,
Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID),
}
if err := txFundRepo.CreateLog(fundLog); err != nil {
return fmt.Errorf("创建公户流水失败: %w", err)
}
}
if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil {
return fmt.Errorf("返还改名域能失败: %w", err)
}
log := &model.EnergyLog{
UserID: userID,
Amount: EnergyRenameRefund,
Type: model.EnergyTypeRenameRefund,
Description: "改名审核被拒,返还 6.0 域能",
}
return txRepo.CreateEnergyLog(log)
})
}
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
// 事务包裹:扣用户域能 + 入公户 + 写流水
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
if err := txRepo.AddEnergy(authorUserID, EnergyDeletePost); err != nil {
return fmt.Errorf("扣减删稿域能失败: %w", err)
}
// 公户入账
if s.fundRepo != nil {
txFundRepo := s.fundRepo.WithTx(tx)
if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20入公户 +20
return fmt.Errorf("公户入账失败: %w", err)
}
fundLog := &model.FundLog{
Amount: -EnergyDeletePost,
Type: model.FundTypeDeletePost,
RelatedType: "post",
RelatedID: postID,
Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID),
}
if err := txFundRepo.CreateLog(fundLog); 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 txRepo.CreateEnergyLog(log)
})
}
// AdminAdjust 后台调整用户域能
// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束,不碰公户)
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error {
if mode == "" {
mode = model.FundTypeAdminTransfer
}
return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
// admin_transfer公户出账需检查余额行锁防 TOCTOU 竞态)
isAdminTransfer := mode == model.FundTypeAdminTransfer
if isAdminTransfer && amount > 0 && s.fundRepo != nil {
txFundRepo := s.fundRepo.WithTx(tx)
totalCost := amount * len(userIDs)
balance, err := txFundRepo.LockAndGetBalance()
if err != nil {
return fmt.Errorf("查询公户余额失败: %w", err)
}
if balance < totalCost {
return common.ErrInsufficientFund
}
}
for _, uid := range userIDs {
if err := txRepo.AddEnergy(uid, amount); err != nil {
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
}
opUID := operatorUID
energyLog := &model.EnergyLog{
UserID: uid,
Amount: amount,
Type: model.EnergyTypeAdminAdjust,
Description: description,
OperatorUID: &opUID,
}
if err := txRepo.CreateEnergyLog(energyLog); err != nil {
return fmt.Errorf("创建调整流水失败: %w", err)
}
}
// admin_transfer公户扣款 + 公户流水system_operation 不碰公户)
if isAdminTransfer && s.fundRepo != nil {
txFundRepo := s.fundRepo.WithTx(tx)
totalCost := amount * len(userIDs)
if err := txFundRepo.IncrBalance(-totalCost); err != nil {
return fmt.Errorf("公户余额调整失败: %w", err)
}
totalDisplay := float64(totalCost) / 10
fundLog := &model.FundLog{
Amount: -totalCost,
Type: mode,
OperatorUID: &operatorUID,
Description: fmt.Sprintf("%s调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description),
}
if len(userIDs) > 0 {
fundLog.RelatedType = "user"
fundLog.RelatedID = userIDs[0]
}
if err := txFundRepo.CreateLog(fundLog); err != nil {
return fmt.Errorf("创建公户流水失败: %w", err)
}
}
return nil
})
}
// getFundModeName 返回操作模式中文名
func getFundModeName(mode string) string {
switch mode {
case model.FundTypeAdminTransfer:
return "管理转出"
case model.FundTypeSystemOperation:
return "系统操作"
default:
return "后台调整"
}
}
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
user, err := s.repo.FindUserByID(userID)
if err != nil {
return nil, err
}
loc, _ := time.LoadLocation("Asia/Shanghai")
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
dailyExp := 0
summary, err := s.repo.GetDailyEnergizeExp(userID, today)
if err == nil && summary != nil {
dailyExp = summary.ExpEarned
}
return &EnergyInfo{
Energy: user.Energy,
DailyEnergizeExp: dailyExp,
DailyExpCap: DailyEnergizeExpCap,
DailyExpCapReached: dailyExp >= DailyEnergizeExpCap,
}, 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)
}
// GetFundBalance 获取公户余额
func (s *EnergyService) GetFundBalance() (int, error) {
if s.fundRepo == nil {
return 0, nil
}
return s.fundRepo.GetBalance()
}
// GetFundLogs 分页查询公户流水
func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
if s.fundRepo == nil {
return []model.FundLog{}, 0, nil
}
logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize)
if err != nil {
return nil, 0, err
}
if logs == nil {
logs = []model.FundLog{}
}
return logs, total, nil
}