From 80751eb8efb10d0b4944452192b3943a2cc0cd3e Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 24 May 2026 19:38:59 -0700 Subject: [PATCH] fix: move withdrawal commission deduction from application to approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commissionWithdrawLogic: remove upfront commission deduction; balance check now includes sum of all pending withdrawals to prevent double-spending; transaction only creates the withdrawal record (status=0) - approveWithdrawal: add FOR UPDATE lock on user row, balance check before deducting, atomic commission decrement and commission log inside one transaction; clear user cache after commit - rejectWithdrawal: remove commission refund and log — commission was never deducted on application under the new flow - add migration 02150: refund commission for existing status=0 withdrawals that were deducted under the old logic; includes rollback script Co-authored-by: multica-agent --- .../02150_refund_pending_withdrawals.down.sql | 19 ++++ .../02150_refund_pending_withdrawals.up.sql | 45 +++++++++ internal/logic/admin/user/withdrawalCommon.go | 98 +++++++++++++++++++ internal/logic/common/withdrawal.go | 48 +++++++++ .../public/user/commissionWithdrawLogic.go | 89 ++++++----------- pkg/xerr/errCode.go | 1 + pkg/xerr/errMsg.go | 1 + 7 files changed, 243 insertions(+), 58 deletions(-) create mode 100644 initialize/migrate/database/02150_refund_pending_withdrawals.down.sql create mode 100644 initialize/migrate/database/02150_refund_pending_withdrawals.up.sql create mode 100644 internal/logic/admin/user/withdrawalCommon.go create mode 100644 internal/logic/common/withdrawal.go diff --git a/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql b/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql new file mode 100644 index 0000000..9f643e3 --- /dev/null +++ b/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql @@ -0,0 +1,19 @@ +-- Rollback: re-deduct commission for users with pending (status=0) withdrawals. +-- This re-applies the OLD behaviour where commission is deducted on application. +-- Only run this if you are rolling back to the old code; do NOT run against +-- the new code or commission will be double-deducted on approval. + +UPDATE `user` u +JOIN ( + SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total + FROM user_withdrawal + WHERE status = 0 + GROUP BY user_id +) p ON u.id = p.user_id +SET u.commission = u.commission - p.pending_total +WHERE p.pending_total > 0; + +-- Remove the migration log entries written by the up migration. +DELETE FROM system_log +WHERE type = 3 + AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%'; diff --git a/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql b/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql new file mode 100644 index 0000000..0a90422 --- /dev/null +++ b/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql @@ -0,0 +1,45 @@ +-- Migration: refund commission for existing pending (status=0) withdrawals +-- +-- Under the old logic, commission was deducted when a withdrawal was submitted. +-- Under the new logic, commission is only deducted on approval. +-- This migration refunds the deducted amounts back to each user so that +-- the system is in a consistent state before the new code is deployed. +-- +-- Idempotency: the UPDATE only touches rows whose commission would need +-- to increase, and each execution produces the same result because +-- COALESCE(SUM(amount),0) is deterministic given the same pending set. +-- Running this script multiple times is safe only if no new pending +-- withdrawals are created between runs; deploy new code immediately after. + +-- Step 1: refund commission for all users with pending withdrawals. +UPDATE `user` u +JOIN ( + SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total + FROM user_withdrawal + WHERE status = 0 + GROUP BY user_id +) p ON u.id = p.user_id +SET u.commission = u.commission + p.pending_total +WHERE p.pending_total > 0; + +-- Step 2: write a migration log entry for each refunded user. +INSERT INTO system_log (type, date, object_id, content, created_at) +SELECT + 3 AS type, + DATE(NOW()) AS date, + p.user_id AS object_id, + JSON_OBJECT( + 'type', 99, + 'amount', p.pending_total, + 'order_no', '', + 'timestamp', UNIX_TIMESTAMP(NOW()) * 1000, + 'note', 'migration: refund pending withdrawal commission (HIF-22)' + ) AS content, + NOW() AS created_at +FROM ( + SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total + FROM user_withdrawal + WHERE status = 0 + GROUP BY user_id + HAVING pending_total > 0 +) p; diff --git a/internal/logic/admin/user/withdrawalCommon.go b/internal/logic/admin/user/withdrawalCommon.go new file mode 100644 index 0000000..a5e394b --- /dev/null +++ b/internal/logic/admin/user/withdrawalCommon.go @@ -0,0 +1,98 @@ +package user + +import ( + "context" + "strings" + + logicCommon "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/model/log" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64) error { + var approvedUserID int64 + err := svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID) + if err != nil { + if err.Error() == "withdrawal status invalid" { + return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID) + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err) + } + + // Lock user row and verify sufficient balance before deducting. + var u usermodel.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", withdrawal.UserId).First(&u).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load user failed: %v", err) + } + if u.Commission < withdrawal.Amount { + return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "user %d has insufficient commission balance", withdrawal.UserId) + } + + if err := tx.Model(&usermodel.Withdrawal{}). + Where("id = ? AND status = 0", withdrawalID). + Updates(map[string]interface{}{ + "status": 1, + "reason": "", + }).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err) + } + + // Deduct commission atomically inside the transaction. + if err := tx.Model(&usermodel.User{}). + Where("id = ?", withdrawal.UserId). + UpdateColumn("commission", gorm.Expr("commission - ?", withdrawal.Amount)).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "deduct commission failed: %v", err) + } + + if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdraw, withdrawal.Amount, ""); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err) + } + + approvedUserID = withdrawal.UserId + return nil + }) + + if err == nil && approvedUserID > 0 { + _ = svcCtx.UserModel.ClearUserCache(ctx, &usermodel.User{Id: approvedUserID}) + } + return err +} + +func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64, reason string) error { + reason = strings.TrimSpace(reason) + return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + _, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID) + if err != nil { + if err.Error() == "withdrawal status invalid" { + return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID) + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err) + } + + // Commission was NOT deducted at application time under the new logic, + // so rejection requires no refund — only a status update. + return tx.Model(&usermodel.Withdrawal{}). + Where("id = ? AND status = 0", withdrawalID). + Updates(map[string]interface{}{ + "status": 2, + "reason": reason, + }).Error + }) +} + +func isWithdrawalScene(remark string) bool { + normalized := strings.ToLower(strings.TrimSpace(remark)) + return strings.Contains(normalized, "withdraw") || strings.Contains(normalized, "提现") +} + +func logWithdrawalGuard(l logger.Logger, userID int64) { + l.Errorw("blocked commission overwrite in withdrawal scene", logger.Field("user_id", userID)) +} diff --git a/internal/logic/common/withdrawal.go b/internal/logic/common/withdrawal.go new file mode 100644 index 0000000..6a8cbe4 --- /dev/null +++ b/internal/logic/common/withdrawal.go @@ -0,0 +1,48 @@ +package common + +import ( + "context" + "time" + + "github.com/perfect-panel/server/internal/model/log" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/pkg/errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error { + logInfo := log.Commission{ + Type: logType, + Amount: amount, + OrderNo: orderNo, + Timestamp: time.Now().UnixMilli(), + } + + content, err := logInfo.Marshal() + if err != nil { + return err + } + + return tx.Model(log.SystemLog{}).Create(&log.SystemLog{ + Type: log.TypeCommission.Uint8(), + Date: time.Now().Format(time.DateOnly), + ObjectID: objectID, + Content: string(content), + CreatedAt: time.Now(), + }).Error +} + +func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawalID int64) (*usermodel.Withdrawal, error) { + var withdrawal usermodel.Withdrawal + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", withdrawalID). + First(&withdrawal).Error; err != nil { + return nil, err + } + if withdrawal.Status != 0 { + return nil, errors.New("withdrawal status invalid") + } + return &withdrawal, nil +} diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index d16dec0..b813aa0 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -2,9 +2,7 @@ package user import ( "context" - "time" - "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -12,6 +10,7 @@ import ( "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "gorm.io/gorm" ) type CommissionWithdrawLogic struct { @@ -36,65 +35,38 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - if u.Commission < req.Amount { - logger.Errorf("User %d has insufficient commission balance: %.2f, requested: %.2f", u.Id, float64(u.Commission)/100, float64(req.Amount)/100) + // Sum all pending (status=0) withdrawals to compute available balance. + // Available = commission - pendingTotal; commission is only deducted on approval. + var pendingTotal int64 + if err = l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Withdrawal{}). + Where("user_id = ? AND status = 0", u.Id). + Select("COALESCE(SUM(amount), 0)"). + Scan(&pendingTotal).Error; err != nil { + l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id) + } + + if u.Commission < req.Amount+pendingTotal { + logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d", + u.Id, u.Commission, pendingTotal, req.Amount) return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id) } - tx := l.svcCtx.DB.WithContext(l.ctx).Begin() - - // update user commission balance - u.Commission -= req.Amount - if err = l.svcCtx.UserModel.Update(l.ctx, u, tx); err != nil { - tx.Rollback() - l.Errorf("Failed to update user %d commission balance: %v", u.Id, err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err) - } - - // create withdrawal log - logInfo := log.Commission{ - Type: log.CommissionTypeConvertBalance, - Amount: req.Amount, - Timestamp: time.Now().UnixMilli(), - } - b, err := logInfo.Marshal() - + var w user.Withdrawal + err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + w = user.Withdrawal{ + UserId: u.Id, + Amount: req.Amount, + Content: req.Content, + Status: 0, + Reason: "", + } + return tx.Create(&w).Error + }) if err != nil { - tx.Rollback() - l.Errorf("Failed to marshal commission log for user %d: %v", u.Id, err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to marshal commission log for user %d: %v", u.Id, err) - } - - err = tx.Model(log.SystemLog{}).Create(&log.SystemLog{ - Type: log.TypeCommission.Uint8(), - Date: time.Now().Format("2006-01-02"), - ObjectID: u.Id, - Content: string(b), - CreatedAt: time.Now(), - }).Error - - if err != nil { - tx.Rollback() - l.Errorf("Failed to create commission log for user %d: %v", u.Id, err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create commission log for user %d: %v", u.Id, err) - } - - err = tx.Model(&user.Withdrawal{}).Create(&user.Withdrawal{ - UserId: u.Id, - Amount: req.Amount, - Content: req.Content, - Status: 0, - Reason: "", - }).Error - - if err != nil { - tx.Rollback() - l.Errorf("Failed to create withdrawal log for user %d: %v", u.Id, err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal log for user %d: %v", u.Id, err) - } - if err = tx.Commit().Error; err != nil { - l.Errorf("Transaction commit failed for user %d withdrawal: %v", u.Id, err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Transaction commit failed for user %d withdrawal: %v", u.Id, err) + l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err) } return &types.WithdrawalLog{ @@ -103,6 +75,7 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr Content: req.Content, Status: 0, Reason: "", - CreatedAt: time.Now().UnixMilli(), + CreatedAt: w.CreatedAt.UnixMilli(), + UpdatedAt: w.UpdatedAt.UnixMilli(), }, nil } diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index d6476e3..30396ca 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -37,6 +37,7 @@ const ( FamilyNotExist uint32 = 20017 FamilyStatusInvalid uint32 = 20018 FamilyOwnerOperationForbidden uint32 = 20019 + WithdrawalStatusInvalid uint32 = 20020 ) // Node error diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index b4793c2..82e7293 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -46,6 +46,7 @@ func init() { FamilyNotExist: "家庭组不存在", FamilyStatusInvalid: "家庭组状态无效", FamilyOwnerOperationForbidden: "家庭组所有者不允许此操作", + WithdrawalStatusInvalid: "提现状态无效", // Node error NodeExist: "Node already exists",