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 index 2cb8578..a5e394b 100644 --- a/internal/logic/admin/user/withdrawalCommon.go +++ b/internal/logic/admin/user/withdrawalCommon.go @@ -12,10 +12,12 @@ import ( "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 { - return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) 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" { @@ -24,6 +26,16 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw 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{}{ @@ -33,17 +45,31 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw 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 { - withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID) + _, 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) @@ -51,23 +77,14 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err) } - if err := tx.Model(&usermodel.Withdrawal{}). + // 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; err != nil { - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "reject withdrawal failed: %v", err) - } - - if err := svcCtx.UserModel.UpdateCommission(ctx, withdrawal.UserId, withdrawal.Amount, tx); err != nil { - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", err) - } - - if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawReject, withdrawal.Amount, ""); err != nil { - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err) - } - return nil + }).Error }) } diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index bbe9541..739a064 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -4,8 +4,6 @@ import ( "context" "time" - logicCommon "github.com/perfect-panel/server/internal/logic/common" - "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" @@ -38,48 +36,39 @@ 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() now := time.Now() - - // Atomically deduct the requested amount so concurrent commission growth is preserved. - if err = l.svcCtx.DB.WithContext(l.ctx). - Model(&user.User{}). - Where("id = ? AND commission >= ?", u.Id, req.Amount). - UpdateColumn("commission", gorm.Expr("commission - ?", req.Amount)).Error; 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) - } - _ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u) - - // create withdrawal log - if err = logicCommon.WriteCommissionLog(tx, u.Id, log.CommissionTypeConvertBalance, req.Amount, ""); 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 - + 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 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{