From 4815060839d1aa057ef8cb3844063c94b3b7f560 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Mon, 1 Jun 2026 15:26:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9F=9F=E8=83=BD?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=93=8D=E4=BD=9C=E6=97=A0=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=8F=8A=E5=85=AC=E6=88=B7=E8=B4=A6=E5=8A=A1=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复前端 energy.js api.post/get 回调调用为 Promise 链式调用,解决操作无响应 - 修复 AdminAdjust system_operation 模式错误扣减公户余额,现不碰公户 - 修复 AdminAdjust admin_transfer 模式公户按用户数量扣款(amount * len(userIDs)) - 修复 AdminAdjust 余额检查移入事务内并加行锁(SELECT FOR UPDATE),防 TOCTOU 竞态 - FundStore 接口及 FundRepo 新增 LockAndGetBalance 方法 --- internal/repository/fund_repo.go | 11 +++++++ internal/service/energy_service.go | 48 ++++++++++++++--------------- internal/service/repository.go | 1 + templates/admin/static/js/energy.js | 9 +++--- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/internal/repository/fund_repo.go b/internal/repository/fund_repo.go index 31f447d..a7c091a 100644 --- a/internal/repository/fund_repo.go +++ b/internal/repository/fund_repo.go @@ -5,6 +5,7 @@ import ( "metazone.cc/metalab/internal/service" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // FundRepo 公户数据访问 @@ -32,6 +33,16 @@ func (r *FundRepo) GetBalance() (int, error) { return fund.Balance, nil } +// LockAndGetBalance 加行锁查询公户余额(事务内使用,防止 TOCTOU 竞态) +func (r *FundRepo) LockAndGetBalance() (int, error) { + var fund model.CommunityFund + err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).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{}). diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go index b0850eb..c94559e 100644 --- a/internal/service/energy_service.go +++ b/internal/service/energy_service.go @@ -379,34 +379,26 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error } // AdminAdjust 后台调整用户域能 -// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束) +// 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) + // 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 } } @@ -428,14 +420,20 @@ func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int } } - // 公户流水 - if txFundRepo != nil { - amountDisplay := float64(amount) / 10 + // 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: -amount, + Amount: -totalCost, Type: mode, OperatorUID: &operatorUID, - Description: fmt.Sprintf("%s:调整用户域能 %.1f,说明:%s", getFundModeName(mode), amountDisplay, description), + Description: fmt.Sprintf("%s:调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description), } if len(userIDs) > 0 { fundLog.RelatedType = "user" diff --git a/internal/service/repository.go b/internal/service/repository.go index 3b15341..d56498b 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -114,6 +114,7 @@ type energyStore = EnergyStore // FundStore 公户仓储接口(ISP) type FundStore interface { GetBalance() (int, error) + LockAndGetBalance() (int, error) IncrBalance(amount int) error CreateLog(log *model.FundLog) error ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) diff --git a/templates/admin/static/js/energy.js b/templates/admin/static/js/energy.js index c67d1a6..f36920f 100644 --- a/templates/admin/static/js/energy.js +++ b/templates/admin/static/js/energy.js @@ -46,13 +46,12 @@ amount: amount, description: desc, mode: mode - }, function (res) { + }).then(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) { + api.get('/api/admin/energy/fund-balance').then(function (r) { if (r.success && r.data) { var balEl = document.getElementById('fund-balance'); if (balEl) { @@ -76,7 +75,7 @@ function loadLogs() { var logType = typeFilter ? typeFilter.value : ''; - api.get('/api/admin/energy/logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) { + api.get('/api/admin/energy/logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType)).then(function (res) { if (!res.success) return; renderLogTable('energy-log-tbody', res.data.items, function (item) { return [ @@ -112,7 +111,7 @@ function loadLogs() { var logType = typeFilter ? typeFilter.value : ''; - api.get('/api/admin/energy/fund-logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) { + api.get('/api/admin/energy/fund-logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType)).then(function (res) { if (!res.success) return; renderLogTable('fund-log-tbody', res.data.items, function (item) { return [