diff --git a/cmd/server/main.go b/cmd/server/main.go index 485e1e2..91e6f75 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,10 +27,15 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } + // 初始化公户行(id=1, balance=0,如已存在则忽略) + if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil { + log.Fatalf("初始化公户失败: %v", err) + } + // 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行) // 支持软删除用户邮箱复用 migrations := []string{ diff --git a/internal/common/errors.go b/internal/common/errors.go index 82a10ba..4964f8e 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -52,6 +52,7 @@ var ( ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取") ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限") ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿") + ErrInsufficientFund = errors.New("公户余额不足,无法执行操作") // 评论相关 ErrCommentNotFound = errors.New("评论不存在") diff --git a/internal/controller/admin/admin_energy_controller.go b/internal/controller/admin/admin_energy_controller.go index b70bbfa..2dd7784 100644 --- a/internal/controller/admin/admin_energy_controller.go +++ b/internal/controller/admin/admin_energy_controller.go @@ -10,10 +10,12 @@ import ( "github.com/gin-gonic/gin" ) -// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:2 个方法) +// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法) type adminEnergyUseCase interface { - AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error + AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) + GetFundBalance() (int, error) + GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) } // AdminEnergyController 后台域能管理控制器 @@ -26,17 +28,38 @@ func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyControll return &AdminEnergyController{energySvc: energySvc} } -// EnergyPage 域能管理页面 +// EnergyPage 域能管理页面(公户余额 + 用户域能调整) func (ac *AdminEnergyController) EnergyPage(c *gin.Context) { + balance := 0 + if b, err := ac.energySvc.GetFundBalance(); err == nil { + balance = b + } c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{ - "Title": "域能管理", + "Title": "域能管理", + "ExtraCSS": "/admin/static/css/energy.css", + "ExtraJS": "/admin/static/js/energy.js", + "FundBalance": balance, + "FundBalanceDisplay": float64(balance) / 10, })) } // EnergyLogPage 域能日志页面 func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) { c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{ - "Title": "域能日志", + "Title": "域能日志", + "ExtraCSS": "/admin/static/css/energy.css", + "ExtraJS": "/admin/static/js/energy.js", + "LogTypes": model.EnergyLogDisplayNames, + })) +} + +// FundLogPage 公户流水页面 +func (ac *AdminEnergyController) FundLogPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/energy/fund_logs.html", common.BuildAdminPageData(c, gin.H{ + "Title": "公户流水", + "ExtraCSS": "/admin/static/css/energy.css", + "ExtraJS": "/admin/static/js/energy.js", + "LogTypes": model.FundLogDisplayNames, })) } @@ -52,13 +75,32 @@ func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) { 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"` + Mode string `json:"mode"` } 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 { + // 默认模式为 admin_transfer + if req.Mode == "" { + req.Mode = model.FundTypeAdminTransfer + } + + // system_operation 仅 owner 可用 + if req.Mode == model.FundTypeSystemOperation { + role, _ := c.Get("role") + if !model.HasMinRole(role.(string), model.RoleOwner) { + common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式") + return + } + } + + if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil { + if err == common.ErrInsufficientFund { + common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作") + return + } common.Error(c, http.StatusInternalServerError, "调整失败") return } @@ -101,3 +143,51 @@ func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) { "total_pages": common.PageCount(total, pageSize), }) } + +// GetFundBalance 获取公户余额 +func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) { + balance, err := ac.energySvc.GetFundBalance() + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + common.Ok(c, gin.H{ + "balance": balance, + "balance_display": float64(balance) / 10, + }) +} + +// ListFundLogs 查询公户流水 +func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) { + logType := c.Query("type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + type logView struct { + model.FundLog + AmountDisplay float64 `json:"amount_display"` + TypeName string `json:"type_name"` + } + views := make([]logView, len(logs)) + for i, l := range logs { + views[i] = logView{ + FundLog: l, + AmountDisplay: float64(l.Amount) / 10, + TypeName: model.FundLogDisplayNames[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/interfaces.go b/internal/controller/interfaces.go index a6341fe..8ee26a2 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -86,10 +86,12 @@ type energyRenameHandler interface { DeductOnRename(userID uint) error } -// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:2 个方法) +// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法) type energyAdminUseCase interface { - AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error + AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) + GetFundBalance() (int, error) + GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) } // commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法) diff --git a/internal/model/community_fund.go b/internal/model/community_fund.go new file mode 100644 index 0000000..228406d --- /dev/null +++ b/internal/model/community_fund.go @@ -0,0 +1,10 @@ +package model + +import "time" + +// CommunityFund 公户余额(单行表,id=1) +type CommunityFund struct { + ID uint `gorm:"primaryKey" json:"id"` + Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数) + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/model/fund_log.go b/internal/model/fund_log.go new file mode 100644 index 0000000..91a81ea --- /dev/null +++ b/internal/model/fund_log.go @@ -0,0 +1,35 @@ +package model + +import "time" + +// 公户流水类型常量 +const ( + FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户) + FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户) + FundTypeRenameRefund = "rename_refund" // 改名退款(出公户) + FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户) + FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束) + FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束) +) + +// FundLog 公户流水记录 +type FundLog struct { + ID uint `gorm:"primarykey" json:"id"` + Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10) + Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型 + RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"` + RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` + OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` + Description string `gorm:"type:text;not null" json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +// FundLogDisplayNames 公户流水类型 → 中文名 +var FundLogDisplayNames = map[string]string{ + FundTypeEnergizeTax: "赋能税收", + FundTypeRenameDeduction: "改名扣费", + FundTypeRenameRefund: "改名退款", + FundTypeDeletePost: "删稿扣费", + FundTypeAdminTransfer: "管理转出", + FundTypeSystemOperation: "系统操作", +} diff --git a/internal/repository/fund_repo.go b/internal/repository/fund_repo.go new file mode 100644 index 0000000..31f447d --- /dev/null +++ b/internal/repository/fund_repo.go @@ -0,0 +1,60 @@ +package repository + +import ( + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" + + "gorm.io/gorm" +) + +// FundRepo 公户数据访问 +type FundRepo struct { + db *gorm.DB +} + +// NewFundRepo 构造函数 +func NewFundRepo(db *gorm.DB) *FundRepo { + return &FundRepo{db: db} +} + +// WithTx 基于给定事务连接创建新的 FundRepo +func (r *FundRepo) WithTx(tx *gorm.DB) service.FundStore { + return &FundRepo{db: tx} +} + +// GetBalance 查询公户余额 +func (r *FundRepo) GetBalance() (int, error) { + var fund model.CommunityFund + err := r.db.First(&fund, 1).Error + if err != nil { + return 0, err + } + return fund.Balance, nil +} + +// IncrBalance 原子增减公户余额 +func (r *FundRepo) IncrBalance(amount int) error { + return r.db.Model(&model.CommunityFund{}). + Where("id = 1"). + UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error +} + +// CreateLog 创建公户流水 +func (r *FundRepo) CreateLog(log *model.FundLog) error { + return r.db.Create(log).Error +} + +// ListLogs 分页查询公户流水 +func (r *FundRepo) ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) { + query := r.db.Model(&model.FundLog{}) + if logType != "" { + query = query.Where("type = ?", logType) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var logs []model.FundLog + err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error + return logs, total, err +} diff --git a/internal/router/admin.go b/internal/router/admin.go index e055673..7a5ea45 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -30,6 +30,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { middleware.RequirePageRole(model.RoleOwner)) adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage) adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage) + adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage) adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage) } @@ -75,16 +76,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore) } - // --- 域能管理 API(admin+,调整需要 owner) --- + // --- 域能管理 API(admin+,system_operation 仅 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.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy) adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs) + adminEnergyAPI.GET("/fund-balance", d.adminEnergyCtrl.GetFundBalance) + adminEnergyAPI.GET("/fund-logs", d.adminEnergyCtrl.ListFundLogs) } // --- 评论管理 API(admin+) --- diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 755ddff..c330ab7 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -21,7 +21,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting // 域能系统 energyRepo := repository.NewEnergyRepo(db) + fundRepo := repository.NewFundRepo(db) energyService := service.NewEnergyService(db, energyRepo, nil, notificationService) + energyService.SetFundRepo(fundRepo) // 等级/签到/任务系统(链式注入域能服务) _, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw) diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go index 2a37a54..b0850eb 100644 --- a/internal/service/energy_service.go +++ b/internal/service/energy_service.go @@ -19,6 +19,7 @@ type expAdder interface { type EnergyService struct { db *gorm.DB repo EnergyStore + fundRepo FundStore expSvc expAdder notifSvc energyNotifier } @@ -28,6 +29,16 @@ func NewEnergyService(db *gorm.DB, repo EnergyStore, expSvc expAdder, notifSvc e 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存储) @@ -36,11 +47,6 @@ type EnergyInfo struct { DailyExpCapReached bool // 是否已达上限 } -// SetExpAdder 链式注入经验服务(用于赋能获得经验) -func (s *EnergyService) SetExpAdder(svc expAdder) { - s.expSvc = svc -} - // 域能相关常量(乘10存储,DB值) const ( EnergyCheckIn = 10 // +1.0 域能 @@ -69,6 +75,10 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (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) @@ -156,7 +166,28 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int return fmt.Errorf("创建被赋能者流水失败: %w", err) } - // 10. 计算经验值(溢出截断)—— 在事务内读取以保证一致性 + // 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) @@ -188,9 +219,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int return 0, err } - // 11. 事务提交后,在事务外增加经验 + 更新每日汇总 - // 必须在事务外执行:LevelService.AddExp 也操作 users 表, - // 若在事务内调用会造成连接池不同连接间行锁死锁。 + // 12. 事务提交后,在事务外增加经验 + 更新每日汇总 if needExpUpdate { if s.expSvc != nil { if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil { @@ -226,46 +255,91 @@ func (s *EnergyService) CheckInEnergy(userID uint) error { } // 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 - } + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) - if err := s.repo.AddEnergy(userID, EnergyRename); err != nil { - return fmt.Errorf("扣减改名域能失败: %w", err) - } + // 检查域能余额(非 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 + } - log := &model.EnergyLog{ - UserID: userID, - Amount: EnergyRename, - Type: model.EnergyTypeRename, - Description: "改名消耗 6.0 域能", - } - return s.repo.CreateEnergyLog(log) + 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 改名审核被拒,返还域能 +// RefundOnRenameReject 改名审核被拒,从公户返还域能 func (s *EnergyService) RefundOnRenameReject(userID uint) error { - if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil { - return fmt.Errorf("返还改名域能失败: %w", err) - } + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) - log := &model.EnergyLog{ - UserID: userID, - Amount: EnergyRenameRefund, - Type: model.EnergyTypeRenameRefund, - Description: "改名审核被拒,返还 6.0 域能", - } - return s.repo.CreateEnergyLog(log) + // 从公户支出(允许透支) + 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) @@ -274,6 +348,24 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error 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, @@ -287,25 +379,87 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error } // 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) - } +// 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 + } - opUID := operatorUID - log := &model.EnergyLog{ - UserID: uid, - Amount: amount, - Type: model.EnergyTypeAdminAdjust, - Description: description, - OperatorUID: &opUID, + // admin_transfer 出账时检查公户余额 + if mode == model.FundTypeAdminTransfer && amount > 0 && s.fundRepo != nil { + balance, err := s.fundRepo.GetBalance() + if err != nil { + return fmt.Errorf("查询公户余额失败: %w", err) } - if err := s.repo.CreateEnergyLog(log); err != nil { - return fmt.Errorf("创建调整流水失败: %w", err) + if balance < amount { + return common.ErrInsufficientFund } } - return nil + + return 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) + } + + // 公户余额调整(给用户 energy=+amount → 公户 -amount,扣用户 energy=-amount → 公户 +amount) + if txFundRepo != nil { + if err := txFundRepo.IncrBalance(-amount); err != nil { + return fmt.Errorf("公户余额调整失败: %w", err) + } + } + + 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) + } + } + + // 公户流水 + if txFundRepo != nil { + amountDisplay := float64(amount) / 10 + fundLog := &model.FundLog{ + Amount: -amount, + Type: mode, + OperatorUID: &operatorUID, + Description: fmt.Sprintf("%s:调整用户域能 %.1f,说明:%s", getFundModeName(mode), amountDisplay, 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 获取用户域能余额和每日赋能经验状态 @@ -359,3 +513,28 @@ func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int 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 +} diff --git a/internal/service/repository.go b/internal/service/repository.go index 4389d2e..3b15341 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -111,6 +111,15 @@ type EnergyStore interface { // energyStore 向后兼容别名 type energyStore = EnergyStore +// FundStore 公户仓储接口(ISP) +type FundStore interface { + GetBalance() (int, error) + IncrBalance(amount int) error + CreateLog(log *model.FundLog) error + ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) + WithTx(tx *gorm.DB) FundStore +} + // energyNotifier EnergyService 所需的通知接口(ISP) type energyNotifier interface { Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error diff --git a/templates/admin/html/energy/fund_logs.html b/templates/admin/html/energy/fund_logs.html new file mode 100644 index 0000000..2ed742c --- /dev/null +++ b/templates/admin/html/energy/fund_logs.html @@ -0,0 +1,30 @@ +{{template "admin/layout/base.html" .}} + + +
+ + +
+ + + + + + + + + + + + + +
ID时间变动类型说明
+ + +{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/energy/index.html b/templates/admin/html/energy/index.html index 7b62aaf..f8011e5 100644 --- a/templates/admin/html/energy/index.html +++ b/templates/admin/html/energy/index.html @@ -1,2 +1,44 @@ {{template "admin/layout/base.html" .}} + + +
+
+

公户余额

+

{{printf "%.1f" .FundBalanceDisplay}}

+
+
+ +
+
+ +
+

调整用户域能

+
+
+ + +
+
+ + +
存储单位 ×10:输入 10 = +1.0 域能,输入 -5 = -0.5 域能
+
+
+ + +
+
+ + +
+ +
+
+
{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/energy/logs.html b/templates/admin/html/energy/logs.html index 7b62aaf..98e353f 100644 --- a/templates/admin/html/energy/logs.html +++ b/templates/admin/html/energy/logs.html @@ -1,2 +1,31 @@ {{template "admin/layout/base.html" .}} + + +
+ + +
+ + + + + + + + + + + + + + +
ID时间用户UID变动类型说明
+ + {{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html index c50a1df..a4b3464 100644 --- a/templates/admin/html/layout/base.html +++ b/templates/admin/html/layout/base.html @@ -48,9 +48,13 @@ 域能管理 - + 域能日志 + + + 公户流水 + 评论管理 diff --git a/templates/admin/static/css/energy.css b/templates/admin/static/css/energy.css new file mode 100644 index 0000000..b4e782d --- /dev/null +++ b/templates/admin/static/css/energy.css @@ -0,0 +1,259 @@ +/* 域能管理页面样式 */ + +.fund-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 8px; + padding: 1.5rem; + color: #fff; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.fund-card .fund-info h3 { + margin: 0 0 0.25rem 0; + font-size: 0.9rem; + opacity: 0.9; + font-weight: 500; +} + +.fund-card .fund-info .fund-balance { + font-size: 2rem; + font-weight: 700; + margin: 0; +} + +.fund-card .fund-info .fund-balance.negative { + color: #fdd; +} + +.fund-card .fund-icon { + font-size: 2.5rem; + opacity: 0.3; +} + +/* 调整表单 */ +.adjust-form { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.adjust-form h3 { + margin: 0 0 1rem 0; + font-size: 1.1rem; + color: #1a202c; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.85rem; + font-weight: 500; + color: #4a5568; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #cbd5e0; + border-radius: 6px; + font-size: 0.9rem; + color: #2d3748; + background: #fff; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +.form-hint { + font-size: 0.78rem; + color: #a0aec0; + margin-top: 0.25rem; +} + +.btn-primary { + background: #667eea; + color: #fff; + border: none; + padding: 0.6rem 1.5rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: #5a67d8; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 日志表格 */ +.log-table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; +} + +.log-table th { + background: #f7fafc; + padding: 0.75rem 1rem; + text-align: left; + font-size: 0.82rem; + font-weight: 600; + color: #4a5568; + border-bottom: 1px solid #e2e8f0; +} + +.log-table td { + padding: 0.7rem 1rem; + font-size: 0.85rem; + color: #2d3748; + border-bottom: 1px solid #edf2f7; +} + +.log-table tr:last-child td { + border-bottom: none; +} + +.log-table .amount-positive { + color: #38a169; + font-weight: 600; +} + +.log-table .amount-negative { + color: #e53e3e; + font-weight: 600; +} + +.type-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.78rem; + font-weight: 500; + background: #ebf8ff; + color: #2b6cb0; +} + +/* 筛选栏 */ +.filter-bar { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; + align-items: center; +} + +.filter-bar select { + padding: 0.4rem 0.75rem; + border: 1px solid #cbd5e0; + border-radius: 6px; + font-size: 0.85rem; + color: #4a5568; + background: #fff; +} + +.filter-bar select:focus { + outline: none; + border-color: #667eea; +} + +/* 分页 */ +.pagination { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + justify-content: center; +} + +.pagination button { + padding: 0.4rem 0.85rem; + border: 1px solid #cbd5e0; + border-radius: 6px; + background: #fff; + font-size: 0.85rem; + color: #4a5568; + cursor: pointer; +} + +.pagination button:hover:not(:disabled) { + background: #f7fafc; + border-color: #a0aec0; +} + +.pagination button.active { + background: #667eea; + color: #fff; + border-color: #667eea; +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 操作结果消息 */ +.form-message { + margin-top: 0.75rem; + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + display: none; +} + +.form-message.success { + background: #f0fff4; + color: #38a169; + border: 1px solid #c6f6d5; + display: block; +} + +.form-message.error { + background: #fff5f5; + color: #e53e3e; + border: 1px solid #fed7d7; + display: block; +} + +/* 日志时间 */ +.log-time { + font-size: 0.8rem; + color: #a0aec0; + white-space: nowrap; +} + +/* 空状态 */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: #a0aec0; + font-size: 0.9rem; +} diff --git a/templates/admin/static/js/energy.js b/templates/admin/static/js/energy.js new file mode 100644 index 0000000..c67d1a6 --- /dev/null +++ b/templates/admin/static/js/energy.js @@ -0,0 +1,207 @@ +// 域能管理页面 JS +(function () { + const path = window.location.pathname; + + if (path === '/admin/energy') { + initEnergyPage(); + } else if (path === '/admin/energy/logs') { + initEnergyLogPage(); + } else if (path === '/admin/energy/fund-logs') { + initFundLogPage(); + } + + // ----- 域能管理首页:公户余额 + 调整表单 ----- + function initEnergyPage() { + var adjustForm = document.getElementById('adjust-form'); + if (!adjustForm) return; + + adjustForm.addEventListener('submit', function (e) { + e.preventDefault(); + var form = e.target; + var submitBtn = form.querySelector('button[type="submit"]'); + var msgEl = document.getElementById('adjust-message'); + if (msgEl) msgEl.className = 'form-message'; + + var userIDsStr = form.querySelector('[name="user_ids"]').value.trim(); + var amountStr = form.querySelector('[name="amount"]').value.trim(); + var desc = form.querySelector('[name="description"]').value.trim(); + var mode = form.querySelector('[name="mode"]').value; + + if (!userIDsStr || !amountStr || !desc) { + showMsg(msgEl, '请填写完整信息', 'error'); + return; + } + + var userIDs = userIDsStr.split(',').map(function (s) { return parseInt(s.trim(), 10); }).filter(function (n) { return n > 0; }); + var amount = parseInt(amountStr, 10); + if (userIDs.length === 0 || isNaN(amount) || amount === 0) { + showMsg(msgEl, '用户ID和金额格式不正确', 'error'); + return; + } + + if (submitBtn) submitBtn.disabled = true; + + api.post('/api/admin/energy/adjust', { + user_ids: userIDs, + amount: amount, + description: desc, + mode: mode + }, function (res) { + if (submitBtn) submitBtn.disabled = false; + if (res.success) { + showMsg(msgEl, res.message || '调整成功', 'success'); + // 刷新公户余额 + setTimeout(function () { + api.get('/api/admin/energy/fund-balance', function (r) { + if (r.success && r.data) { + var balEl = document.getElementById('fund-balance'); + if (balEl) { + balEl.textContent = r.data.balance_display.toFixed(1); + balEl.className = 'fund-balance' + (r.data.balance < 0 ? ' negative' : ''); + } + } + }); + }, 300); + } else { + showMsg(msgEl, res.message || '调整失败', 'error'); + } + }); + }); + } + + // ----- 域能日志页 ----- + function initEnergyLogPage() { + var page = 1; + var typeFilter = document.getElementById('log-type-filter'); + + function loadLogs() { + var logType = typeFilter ? typeFilter.value : ''; + api.get('/api/admin/energy/logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) { + if (!res.success) return; + renderLogTable('energy-log-tbody', res.data.items, function (item) { + return [ + item.id, + formatTime(item.created_at), + item.user_id, + '' + item.energy_display.toFixed(1) + '', + '' + (item.type_name || item.type) + '', + escapeHtml(item.description || '') + ]; + }, 6); + renderPagination('energy-log-pagination', page, res.data.total_pages, function (p) { + page = p; + loadLogs(); + }); + }); + } + + if (typeFilter) { + typeFilter.addEventListener('change', function () { + page = 1; + loadLogs(); + }); + } + + loadLogs(); + } + + // ----- 公户流水页 ----- + function initFundLogPage() { + var page = 1; + var typeFilter = document.getElementById('fund-log-type-filter'); + + function loadLogs() { + var logType = typeFilter ? typeFilter.value : ''; + api.get('/api/admin/energy/fund-logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) { + if (!res.success) return; + renderLogTable('fund-log-tbody', res.data.items, function (item) { + return [ + item.id, + formatTime(item.created_at), + '' + item.amount_display.toFixed(1) + '', + '' + (item.type_name || item.type) + '', + escapeHtml(item.description || '') + ]; + }, 5); + renderPagination('fund-log-pagination', page, res.data.total_pages, function (p) { + page = p; + loadLogs(); + }); + }); + } + + if (typeFilter) { + typeFilter.addEventListener('change', function () { + page = 1; + loadLogs(); + }); + } + + loadLogs(); + } + + // ----- 通用工具函数 ----- + function renderLogTable(tbodyId, items, rowFn, colCount) { + var tbody = document.getElementById(tbodyId); + if (!tbody) return; + if (!items || items.length === 0) { + tbody.innerHTML = '暂无记录'; + return; + } + var html = ''; + for (var i = 0; i < items.length; i++) { + var cells = rowFn(items[i]); + html += ''; + for (var j = 0; j < cells.length; j++) { + html += '' + cells[j] + ''; + } + html += ''; + } + tbody.innerHTML = html; + } + + function renderPagination(paginationId, current, total, onChange) { + var container = document.getElementById(paginationId); + if (!container || total <= 1) { if (container) container.innerHTML = ''; return; } + var html = ''; + html += '‹ 上一页'; + var start = Math.max(1, current - 2); + var end = Math.min(total, current + 2); + for (var p = start; p <= end; p++) { + html += ''; + } + html += '= total ? ' disabled' : '') + ' data-page="' + (current + 1) + '">下一页 ›'; + container.innerHTML = html; + + var buttons = container.querySelectorAll('button'); + for (var i = 0; i < buttons.length; i++) { + (function (btn) { + btn.addEventListener('click', function () { + var p = parseInt(this.getAttribute('data-page'), 10); + if (p > 0 && p <= total && p !== current) { + onChange(p); + } + }); + })(buttons[i]); + } + } + + function formatTime(isoStr) { + if (!isoStr) return ''; + var d = new Date(isoStr); + var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + } + + function escapeHtml(text) { + if (!text) return ''; + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function showMsg(msgEl, text, type) { + if (!msgEl) return; + msgEl.textContent = text; + msgEl.className = 'form-message ' + type; + setTimeout(function () { msgEl.className = 'form-message'; }, 4000); + } +})();