fix: move withdrawal commission deduction from application to approval
Build docker and publish / build (20.15.1) (push) Failing after 8m28s
Build docker and publish / build (20.15.1) (push) Failing after 8m28s
- 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 <github@multica.ai>
This commit is contained in:
@@ -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;
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
||||||
@@ -12,6 +10,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommissionWithdrawLogic struct {
|
type CommissionWithdrawLogic struct {
|
||||||
@@ -36,65 +35,38 @@ 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()
|
var w user.Withdrawal
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// update user commission balance
|
w = user.Withdrawal{
|
||||||
u.Commission -= req.Amount
|
UserId: u.Id,
|
||||||
if err = l.svcCtx.UserModel.Update(l.ctx, u, tx); err != nil {
|
Amount: req.Amount,
|
||||||
tx.Rollback()
|
Content: req.Content,
|
||||||
l.Errorf("Failed to update user %d commission balance: %v", u.Id, err)
|
Status: 0,
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err)
|
Reason: "",
|
||||||
}
|
}
|
||||||
|
return tx.Create(&w).Error
|
||||||
// create withdrawal log
|
})
|
||||||
logInfo := log.Commission{
|
|
||||||
Type: log.CommissionTypeConvertBalance,
|
|
||||||
Amount: req.Amount,
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
b, err := logInfo.Marshal()
|
|
||||||
|
|
||||||
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 marshal commission 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.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.WithdrawalLog{
|
return &types.WithdrawalLog{
|
||||||
@@ -103,6 +75,7 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Reason: "",
|
Reason: "",
|
||||||
CreatedAt: time.Now().UnixMilli(),
|
CreatedAt: w.CreatedAt.UnixMilli(),
|
||||||
|
UpdatedAt: w.UpdatedAt.UnixMilli(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const (
|
|||||||
FamilyNotExist uint32 = 20017
|
FamilyNotExist uint32 = 20017
|
||||||
FamilyStatusInvalid uint32 = 20018
|
FamilyStatusInvalid uint32 = 20018
|
||||||
FamilyOwnerOperationForbidden uint32 = 20019
|
FamilyOwnerOperationForbidden uint32 = 20019
|
||||||
|
WithdrawalStatusInvalid uint32 = 20020
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node error
|
// Node error
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func init() {
|
|||||||
FamilyNotExist: "家庭组不存在",
|
FamilyNotExist: "家庭组不存在",
|
||||||
FamilyStatusInvalid: "家庭组状态无效",
|
FamilyStatusInvalid: "家庭组状态无效",
|
||||||
FamilyOwnerOperationForbidden: "家庭组所有者不允许此操作",
|
FamilyOwnerOperationForbidden: "家庭组所有者不允许此操作",
|
||||||
|
WithdrawalStatusInvalid: "提现状态无效",
|
||||||
|
|
||||||
// Node error
|
// Node error
|
||||||
NodeExist: "Node already exists",
|
NodeExist: "Node already exists",
|
||||||
|
|||||||
Reference in New Issue
Block a user