feat: 实现社区激励金(公户)系统,经济闭环

- 新增 CommunityFund / FundLog 模型(公户余额单行表 + 6种流水类型)
- 新增 FundRepo 仓储(GetBalance / IncrBalance / CreateLog / ListLogs / WithTx)
- 改造 EnergyService 五个方法:Energize 税收入公户、DeductOnRename/DeductOnDeletePost 入公户、RefundOnRenameReject 从公户支出、AdminAdjust 拆分为 admin_transfer/system_operation
- 新增 ErrInsufficientFund 错误哨兵
- 管理后台新增公户余额展示 + 公户流水页面 + 域能调整资金来源选择
- 新增 energy.css / energy.js 前端交互
- 侧边栏新增公户流水入口
This commit is contained in:
2026-06-01 15:10:47 +08:00
parent 683c9e02d8
commit 26e557de8a
17 changed files with 1033 additions and 67 deletions

View File

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

View File

@ -52,6 +52,7 @@ var (
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
// 评论相关
ErrCommentNotFound = errors.New("评论不存在")

View File

@ -10,10 +10,12 @@ import (
"github.com/gin-gonic/gin"
)
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖ISP2 个方法)
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖ISP4 个方法)
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,10 +28,18 @@ 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": "域能管理",
"ExtraCSS": "/admin/static/css/energy.css",
"ExtraJS": "/admin/static/js/energy.js",
"FundBalance": balance,
"FundBalanceDisplay": float64(balance) / 10,
}))
}
@ -37,6 +47,19 @@ func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
"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),
})
}

View File

@ -86,10 +86,12 @@ type energyRenameHandler interface {
DeductOnRename(userID uint) error
}
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖ISP2 个方法)
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖ISP4 个方法)
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 的最小依赖ISP6 个方法)

View File

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

View File

@ -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: "系统操作",
}

View File

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

View File

@ -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)
}
// --- 域能管理 APIadmin+调整需要 owner ---
// --- 域能管理 APIadmin+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)
}
// --- 评论管理 APIadmin+ ---

View File

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

View File

@ -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,9 +255,13 @@ func (s *EnergyService) CheckInEnergy(userID uint) error {
}
// 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 := s.repo.FindUserByID(userID)
user, err := txRepo.FindUserByID(userID)
if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
@ -236,22 +269,62 @@ func (s *EnergyService) DeductOnRename(userID uint) error {
return common.ErrInsufficientEnergy
}
if err := s.repo.AddEnergy(userID, EnergyRename); err != nil {
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 s.repo.CreateEnergyLog(log)
return txRepo.CreateEnergyLog(log)
})
}
// RefundOnRenameReject 改名审核被拒,返还域能
// RefundOnRenameReject 改名审核被拒,从公户返还域能
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil {
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)
}
@ -261,11 +334,12 @@ func (s *EnergyService) RefundOnRenameReject(userID uint) error {
Type: model.EnergyTypeRenameRefund,
Description: "改名审核被拒,返还 6.0 域能",
}
return s.repo.CreateEnergyLog(log)
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 {
// 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
}
// 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 balance < amount {
return common.ErrInsufficientFund
}
}
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 := s.repo.AddEnergy(uid, amount); err != nil {
if err := txRepo.AddEnergy(uid, amount); err != nil {
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
}
opUID := operatorUID
log := &model.EnergyLog{
energyLog := &model.EnergyLog{
UserID: uid,
Amount: amount,
Type: model.EnergyTypeAdminAdjust,
Description: description,
OperatorUID: &opUID,
}
if err := s.repo.CreateEnergyLog(log); err != nil {
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
}

View File

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

View File

@ -0,0 +1,30 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>公户流水</h1>
<p class="page-desc">查看社区激励金(公户)的全部收支记录</p>
</div>
<div class="filter-bar">
<label for="fund-log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
<select id="fund-log-type-filter">
<option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
</select>
</div>
<table class="log-table">
<thead>
<tr>
<th>ID</th>
<th>时间</th>
<th>变动</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody id="fund-log-tbody">
</tbody>
</table>
<div id="fund-log-pagination" class="pagination"></div>
{{template "admin/layout/footer.html" .}}

View File

@ -1,2 +1,44 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>域能管理</h1>
<p class="page-desc">管理公户余额与用户域能调整</p>
</div>
<div class="fund-card">
<div class="fund-info">
<h3>公户余额</h3>
<p id="fund-balance" class="fund-balance{{if lt .FundBalance 0}} negative{{end}}">{{printf "%.1f" .FundBalanceDisplay}}</p>
</div>
<div class="fund-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
</div>
</div>
<div class="adjust-form">
<h3>调整用户域能</h3>
<form id="adjust-form">
<div class="form-group">
<label for="user_ids">目标用户 UID多个用英文逗号分隔</label>
<input type="text" id="user_ids" name="user_ids" placeholder="例如1,2,3" required>
</div>
<div class="form-group">
<label for="amount">调整金额正数为增加负数为扣减×10存储</label>
<input type="number" id="amount" name="amount" placeholder="例如10 表示 +1.0 域能" required>
<div class="form-hint">存储单位 ×10输入 10 = +1.0 域能,输入 -5 = -0.5 域能</div>
</div>
<div class="form-group">
<label for="adjust-mode">资金来源</label>
<select id="adjust-mode" name="mode">
<option value="admin_transfer">公户转出(受余额约束)</option>
{{if .IsOwner}}<option value="system_operation">系统操作(不受余额约束,仅站长可用)</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="description">操作说明</label>
<textarea id="description" name="description" placeholder="简要说明调整原因" required maxlength="500"></textarea>
</div>
<button type="submit" class="btn-primary">确认调整</button>
<div id="adjust-message" class="form-message"></div>
</form>
</div>
{{template "admin/layout/footer.html" .}}

View File

@ -1,2 +1,31 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>域能日志</h1>
<p class="page-desc">查看所有用户的域能流水记录</p>
</div>
<div class="filter-bar">
<label for="log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
<select id="log-type-filter">
<option value="">全部类型</option>
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
</select>
</div>
<table class="log-table">
<thead>
<tr>
<th>ID</th>
<th>时间</th>
<th>用户UID</th>
<th>变动</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody id="energy-log-tbody">
</tbody>
</table>
<div id="energy-log-pagination" class="pagination"></div>
{{template "admin/layout/footer.html" .}}

View File

@ -48,9 +48,13 @@
域能管理
</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>
<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"/></svg></span>
域能日志
</a>
<a href="/admin/energy/fund-logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/fund-logs"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></span>
公户流水
</a>
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
评论管理

View File

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

View File

@ -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,
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.energy_display.toFixed(1) + '</span>',
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
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),
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.amount_display.toFixed(1) + '</span>',
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
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 = '<tr><td colspan="' + colCount + '" class="empty-state">暂无记录</td></tr>';
return;
}
var html = '';
for (var i = 0; i < items.length; i++) {
var cells = rowFn(items[i]);
html += '<tr>';
for (var j = 0; j < cells.length; j++) {
html += '<td>' + cells[j] + '</td>';
}
html += '</tr>';
}
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 += '<button' + (current <= 1 ? ' disabled' : '') + ' data-page="' + (current - 1) + '"> 上一页</button>';
var start = Math.max(1, current - 2);
var end = Math.min(total, current + 2);
for (var p = start; p <= end; p++) {
html += '<button class="' + (p === current ? 'active' : '') + '" data-page="' + p + '">' + p + '</button>';
}
html += '<button' + (current >= total ? ' disabled' : '') + ' data-page="' + (current + 1) + '">下一页 </button>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showMsg(msgEl, text, type) {
if (!msgEl) return;
msgEl.textContent = text;
msgEl.className = 'form-message ' + type;
setTimeout(function () { msgEl.className = 'form-message'; }, 4000);
}
})();