fix: 修复域能管理操作无响应及公户账务错误

- 修复前端 energy.js api.post/get 回调调用为 Promise 链式调用,解决操作无响应
- 修复 AdminAdjust system_operation 模式错误扣减公户余额,现不碰公户
- 修复 AdminAdjust admin_transfer 模式公户按用户数量扣款(amount * len(userIDs))
- 修复 AdminAdjust 余额检查移入事务内并加行锁(SELECT FOR UPDATE),防 TOCTOU 竞态
- FundStore 接口及 FundRepo 新增 LockAndGetBalance 方法
This commit is contained in:
2026-06-01 15:26:25 +08:00
parent 26e557de8a
commit 4815060839
4 changed files with 39 additions and 30 deletions

View File

@ -5,6 +5,7 @@ import (
"metazone.cc/metalab/internal/service" "metazone.cc/metalab/internal/service"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// FundRepo 公户数据访问 // FundRepo 公户数据访问
@ -32,6 +33,16 @@ func (r *FundRepo) GetBalance() (int, error) {
return fund.Balance, nil 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 原子增减公户余额 // IncrBalance 原子增减公户余额
func (r *FundRepo) IncrBalance(amount int) error { func (r *FundRepo) IncrBalance(amount int) error {
return r.db.Model(&model.CommunityFund{}). return r.db.Model(&model.CommunityFund{}).

View File

@ -379,37 +379,29 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error
} }
// AdminAdjust 后台调整用户域能 // 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 { func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error {
if mode == "" { if mode == "" {
mode = model.FundTypeAdminTransfer mode = model.FundTypeAdminTransfer
} }
// admin_transfer 出账时检查公户余额 return s.db.Transaction(func(tx *gorm.DB) error {
if mode == model.FundTypeAdminTransfer && amount > 0 && s.fundRepo != nil { txRepo := s.repo.WithTx(tx)
balance, err := s.fundRepo.GetBalance()
// 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 { if err != nil {
return fmt.Errorf("查询公户余额失败: %w", err) return fmt.Errorf("查询公户余额失败: %w", err)
} }
if balance < amount { if balance < totalCost {
return common.ErrInsufficientFund 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 { for _, uid := range userIDs {
if err := txRepo.AddEnergy(uid, amount); err != nil { if err := txRepo.AddEnergy(uid, amount); err != nil {
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err) return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
@ -428,14 +420,20 @@ func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int
} }
} }
// 公户流水 // admin_transfer公户扣款 + 公户流水system_operation 不碰公户)
if txFundRepo != nil { if isAdminTransfer && s.fundRepo != nil {
amountDisplay := float64(amount) / 10 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{ fundLog := &model.FundLog{
Amount: -amount, Amount: -totalCost,
Type: mode, Type: mode,
OperatorUID: &operatorUID, 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 { if len(userIDs) > 0 {
fundLog.RelatedType = "user" fundLog.RelatedType = "user"

View File

@ -114,6 +114,7 @@ type energyStore = EnergyStore
// FundStore 公户仓储接口ISP // FundStore 公户仓储接口ISP
type FundStore interface { type FundStore interface {
GetBalance() (int, error) GetBalance() (int, error)
LockAndGetBalance() (int, error)
IncrBalance(amount int) error IncrBalance(amount int) error
CreateLog(log *model.FundLog) error CreateLog(log *model.FundLog) error
ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error)

View File

@ -46,13 +46,12 @@
amount: amount, amount: amount,
description: desc, description: desc,
mode: mode mode: mode
}, function (res) { }).then(function (res) {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
if (res.success) { if (res.success) {
showMsg(msgEl, res.message || '调整成功', 'success'); showMsg(msgEl, res.message || '调整成功', 'success');
// 刷新公户余额
setTimeout(function () { 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) { if (r.success && r.data) {
var balEl = document.getElementById('fund-balance'); var balEl = document.getElementById('fund-balance');
if (balEl) { if (balEl) {
@ -76,7 +75,7 @@
function loadLogs() { function loadLogs() {
var logType = typeFilter ? typeFilter.value : ''; 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; if (!res.success) return;
renderLogTable('energy-log-tbody', res.data.items, function (item) { renderLogTable('energy-log-tbody', res.data.items, function (item) {
return [ return [
@ -112,7 +111,7 @@
function loadLogs() { function loadLogs() {
var logType = typeFilter ? typeFilter.value : ''; 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; if (!res.success) return;
renderLogTable('fund-log-tbody', res.data.items, function (item) { renderLogTable('fund-log-tbody', res.data.items, function (item) {
return [ return [