feat: implement withdrawal improvements P01-P05

- P04: fix Withdrawal.TableName() to return 'withdrawals'
- P03: add WithdrawalStatus* and WithdrawalMethod* constants; replace magic numbers; add CommissionTypeWithdrawCancel=338
- P01: add POST /v1/public/user/withdrawal_cancel to cancel pending withdrawals with ownership check, row lock, commission refund, and audit log
- P02: add method/account/qr_code_url fields to Withdrawal model and API; add input validation (qr_code_url required for Alipay/Wechat, account required for bank)
- P05: add method filter to admin GET /v1/admin/user/withdrawal/list

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-25 20:44:04 -07:00
parent fd522b6c71
commit d74803bcbe
14 changed files with 214 additions and 21 deletions
@@ -42,6 +42,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.Method != nil {
query = query.Where("method = ?", *req.Method)
}
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -62,6 +65,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
Content: row.Content,
Status: row.Status,
Reason: row.Reason,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(),
})
@@ -25,9 +25,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
}
if err := tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID).
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{
"status": 1,
"status": usermodel.WithdrawalStatusApproved,
"reason": "",
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
@@ -52,9 +52,9 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa
}
if err := tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID).
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{
"status": 2,
"status": usermodel.WithdrawalStatusRejected,
"reason": reason,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "reject withdrawal failed: %v", err)
+1 -1
View File
@@ -41,7 +41,7 @@ func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawal
First(&withdrawal).Error; err != nil {
return nil, err
}
if withdrawal.Status != 0 {
if withdrawal.Status != usermodel.WithdrawalStatusPending {
return nil, errors.New("withdrawal status invalid")
}
return &withdrawal, nil
@@ -0,0 +1,89 @@
package user
import (
"context"
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"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type CancelWithdrawalLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCancelWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelWithdrawalLogic {
return &CancelWithdrawalLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequest) (resp *types.WithdrawalLog, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
var withdrawal *user.Withdrawal
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
var txErr error
withdrawal, txErr = logicCommon.LoadPendingWithdrawalForUpdate(l.ctx, tx, req.WithdrawalId)
if txErr != nil {
if txErr.Error() == "withdrawal status invalid" {
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d is not in pending state", req.WithdrawalId)
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", txErr)
}
if withdrawal.UserId != u.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.PermissionDenied), "user %d cannot cancel withdrawal belonging to user %d", u.Id, withdrawal.UserId)
}
if txErr = tx.Model(&user.Withdrawal{}).
Where("id = ? AND status = ?", req.WithdrawalId, user.WithdrawalStatusPending).
Update("status", user.WithdrawalStatusCancelled).Error; txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
}
if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr)
}
if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr)
}
return nil
})
if err != nil {
return nil, err
}
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
return &types.WithdrawalLog{
Id: withdrawal.Id,
UserId: withdrawal.UserId,
Amount: withdrawal.Amount,
Content: withdrawal.Content,
Status: user.WithdrawalStatusCancelled,
Reason: "",
Method: withdrawal.Method,
Account: withdrawal.Account,
QrCodeUrl: withdrawal.QrCodeUrl,
CreatedAt: withdrawal.CreatedAt.UnixMilli(),
UpdatedAt: withdrawal.UpdatedAt.UnixMilli(),
}, nil
}
@@ -38,6 +38,22 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// Validate payment method fields
switch req.Method {
case user.WithdrawalMethodAlipay, user.WithdrawalMethodWechat:
if req.QrCodeUrl == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "qr_code_url is required for method %d", req.Method)
}
case user.WithdrawalMethodBank:
if req.Account == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer")
}
default: // WithdrawalMethodOther
if req.Account == "" && req.Content == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods")
}
}
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)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
@@ -64,15 +80,17 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
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 {
withdrawal := &user.Withdrawal{
UserId: u.Id,
Amount: req.Amount,
Content: req.Content,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
}
if err = tx.Model(&user.Withdrawal{}).Create(withdrawal).Error; 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)
@@ -83,11 +101,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
}
return &types.WithdrawalLog{
Id: withdrawal.Id,
UserId: u.Id,
Amount: req.Amount,
Content: req.Content,
Status: 0,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
CreatedAt: now.UnixMilli(),
UpdatedAt: now.UnixMilli(),
}, nil
@@ -64,6 +64,9 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
Content: row.Content,
Status: row.Status,
Reason: row.Reason,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(),
})