Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 878d5006f0 |
@@ -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)%';
|
||||||
@@ -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;
|
||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64) error {
|
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)
|
withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "withdrawal status invalid" {
|
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)
|
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{}).
|
if err := tx.Model(&usermodel.Withdrawal{}).
|
||||||
Where("id = ? AND status = 0", withdrawalID).
|
Where("id = ? AND status = 0", withdrawalID).
|
||||||
Updates(map[string]interface{}{
|
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)
|
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 {
|
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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
approvedUserID = withdrawal.UserId
|
||||||
return nil
|
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 {
|
func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64, reason string) error {
|
||||||
reason = strings.TrimSpace(reason)
|
reason = strings.TrimSpace(reason)
|
||||||
return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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 != nil {
|
||||||
if err.Error() == "withdrawal status invalid" {
|
if err.Error() == "withdrawal status invalid" {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID)
|
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)
|
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).
|
Where("id = ? AND status = 0", withdrawalID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"status": 2,
|
"status": 2,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
}).Error; err != nil {
|
}).Error
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"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/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"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")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Commission < req.Amount {
|
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||||
logger.Errorf("User %d has insufficient commission balance: %.2f, requested: %.2f", u.Id, float64(u.Commission)/100, float64(req.Amount)/100)
|
// 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)
|
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()
|
now := time.Now()
|
||||||
|
var w user.Withdrawal
|
||||||
// Atomically deduct the requested amount so concurrent commission growth is preserved.
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
w = user.Withdrawal{
|
||||||
Model(&user.User{}).
|
UserId: u.Id,
|
||||||
Where("id = ? AND commission >= ?", u.Id, req.Amount).
|
Amount: req.Amount,
|
||||||
UpdateColumn("commission", gorm.Expr("commission - ?", req.Amount)).Error; err != nil {
|
Content: req.Content,
|
||||||
tx.Rollback()
|
Status: 0,
|
||||||
l.Errorf("Failed to update user %d commission balance: %v", u.Id, err)
|
Reason: "",
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err)
|
}
|
||||||
}
|
return tx.Create(&w).Error
|
||||||
_ = 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
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||||
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 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.WithdrawalLog{
|
return &types.WithdrawalLog{
|
||||||
|
|||||||
Reference in New Issue
Block a user