From d74803bcbef8b4da166e644d7489686208e40bf7 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Mon, 25 May 2026 20:44:04 -0700 Subject: [PATCH] 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 --- apis/admin/user.api | 1 + apis/public/user.api | 17 +++- .../public/user/cancelWithdrawalHandler.go | 26 ++++++ internal/handler/routes.go | 3 + .../admin/user/getWithdrawalListLogic.go | 6 ++ internal/logic/admin/user/withdrawalCommon.go | 8 +- internal/logic/common/withdrawal.go | 2 +- .../public/user/cancelWithdrawalLogic.go | 89 +++++++++++++++++++ .../public/user/commissionWithdrawLogic.go | 42 ++++++--- .../public/user/queryWithdrawalLogLogic.go | 3 + internal/model/log/log.go | 1 + internal/model/user/user.go | 7 +- internal/model/user/withdrawal_const.go | 15 ++++ internal/types/types.go | 15 +++- 14 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 internal/handler/public/user/cancelWithdrawalHandler.go create mode 100644 internal/logic/public/user/cancelWithdrawalLogic.go create mode 100644 internal/model/user/withdrawal_const.go diff --git a/apis/admin/user.api b/apis/admin/user.api index aa6243f..c107826 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -235,6 +235,7 @@ type ( Size int `form:"size"` UserId *int64 `form:"user_id,omitempty"` Status *uint8 `form:"status,omitempty"` + Method *uint8 `form:"method,omitempty"` } GetWithdrawalListResponse { List []WithdrawalLog `json:"list"` diff --git a/apis/public/user.api b/apis/public/user.api index e55919f..f88fc26 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -109,8 +109,11 @@ type ( Rules []string `json:"rules" validate:"required"` } CommissionWithdrawRequest { - Amount int64 `json:"amount"` - Content string `json:"content"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Method uint8 `json:"method" validate:"oneof=0 1 2 3"` + Account string `json:"account,omitempty"` + QrCodeUrl string `json:"qr_code_url,omitempty"` } WithdrawalLog { Id int64 `json:"id"` @@ -119,9 +122,15 @@ type ( Content string `json:"content"` Status uint8 `json:"status"` Reason string `json:"reason,omitempty"` + Method uint8 `json:"method"` + Account string `json:"account"` + QrCodeUrl string `json:"qr_code_url"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + CancelWithdrawalRequest { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + } QueryWithdrawalLogListRequest { Page int `form:"page"` Size int `form:"size"` @@ -352,6 +361,10 @@ service ppanel { @handler CommissionWithdraw post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog) + @doc "Cancel pending withdrawal" + @handler CancelWithdrawal + post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog) + @doc "Query Withdrawal Log" @handler QueryWithdrawalLog get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) diff --git a/internal/handler/public/user/cancelWithdrawalHandler.go b/internal/handler/public/user/cancelWithdrawalHandler.go new file mode 100644 index 0000000..e3edad2 --- /dev/null +++ b/internal/handler/public/user/cancelWithdrawalHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Cancel Withdrawal +func CancelWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CancelWithdrawalRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewCancelWithdrawalLogic(c.Request.Context(), svcCtx) + resp, err := l.CancelWithdrawal(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 5e387ea..c831dc3 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1050,6 +1050,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Commission Withdraw publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx)) + // Cancel Withdrawal + publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx)) + // Delete Current User Account publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) diff --git a/internal/logic/admin/user/getWithdrawalListLogic.go b/internal/logic/admin/user/getWithdrawalListLogic.go index ff262af..fb83f48 100644 --- a/internal/logic/admin/user/getWithdrawalListLogic.go +++ b/internal/logic/admin/user/getWithdrawalListLogic.go @@ -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(), }) diff --git a/internal/logic/admin/user/withdrawalCommon.go b/internal/logic/admin/user/withdrawalCommon.go index 2cb8578..524fbfb 100644 --- a/internal/logic/admin/user/withdrawalCommon.go +++ b/internal/logic/admin/user/withdrawalCommon.go @@ -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) diff --git a/internal/logic/common/withdrawal.go b/internal/logic/common/withdrawal.go index 6a8cbe4..3d2304a 100644 --- a/internal/logic/common/withdrawal.go +++ b/internal/logic/common/withdrawal.go @@ -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 diff --git a/internal/logic/public/user/cancelWithdrawalLogic.go b/internal/logic/public/user/cancelWithdrawalLogic.go new file mode 100644 index 0000000..a3f5135 --- /dev/null +++ b/internal/logic/public/user/cancelWithdrawalLogic.go @@ -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 +} diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index bbe9541..fa3322e 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -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 diff --git a/internal/logic/public/user/queryWithdrawalLogLogic.go b/internal/logic/public/user/queryWithdrawalLogLogic.go index 79e8a2c..9219f0f 100644 --- a/internal/logic/public/user/queryWithdrawalLogLogic.go +++ b/internal/logic/public/user/queryWithdrawalLogLogic.go @@ -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(), }) diff --git a/internal/model/log/log.go b/internal/model/log/log.go index c970d91..ad338df 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -50,6 +50,7 @@ const ( CommissionTypeAdjust uint16 = 335 // Admin Adjust CommissionTypeConvertBalance uint16 = 336 // Convert to Balance CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund + CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金 GiftTypeIncrease uint16 = 341 // Increase GiftTypeReduce uint16 = 342 // Reduce ) diff --git a/internal/model/user/user.go b/internal/model/user/user.go index c9ff2bd..caafcb0 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -167,12 +167,15 @@ type Withdrawal struct { UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` Amount int64 `gorm:"not null;comment:Withdrawal Amount"` Content string `gorm:"type:text;comment:Withdrawal Content"` - Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected"` + Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected 3: Cancelled"` Reason string `gorm:"type:varchar(500);default:'';comment:Rejection Reason"` + Method uint8 `gorm:"type:tinyint(1);default:0;comment:收款方式 0:其他 1:支付宝 2:微信 3:银行卡"` + Account string `gorm:"type:varchar(255);default:'';comment:收款账号"` + QrCodeUrl string `gorm:"type:varchar(500);default:'';comment:收款码图片URL"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } func (*Withdrawal) TableName() string { - return "user_withdrawal" + return "withdrawals" } diff --git a/internal/model/user/withdrawal_const.go b/internal/model/user/withdrawal_const.go new file mode 100644 index 0000000..cd08a24 --- /dev/null +++ b/internal/model/user/withdrawal_const.go @@ -0,0 +1,15 @@ +package user + +const ( + WithdrawalStatusPending uint8 = 0 // 待审核 + WithdrawalStatusApproved uint8 = 1 // 已通过 + WithdrawalStatusRejected uint8 = 2 // 已拒绝 + WithdrawalStatusCancelled uint8 = 3 // 已取消(用户自行撤回) +) + +const ( + WithdrawalMethodOther uint8 = 0 // 其他 + WithdrawalMethodAlipay uint8 = 1 // 支付宝 + WithdrawalMethodWechat uint8 = 2 // 微信 + WithdrawalMethodBank uint8 = 3 // 银行卡 +) diff --git a/internal/types/types.go b/internal/types/types.go index 385aec1..9050307 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -289,8 +289,11 @@ type CommissionLog struct { } type CommissionWithdrawRequest struct { - Amount int64 `json:"amount"` - Content string `json:"content"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Method uint8 `json:"method" validate:"oneof=0 1 2 3"` + Account string `json:"account,omitempty"` + QrCodeUrl string `json:"qr_code_url,omitempty"` } type ConnectionRecords struct { @@ -2274,6 +2277,10 @@ type QueryUserSubscribeNodeListResponse struct { List []UserSubscribeInfo `json:"list"` } +type CancelWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +} + type QueryWithdrawalLogListRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -2289,6 +2296,7 @@ type GetWithdrawalListRequest struct { Size int `form:"size"` UserId *int64 `form:"user_id,omitempty"` Status *uint8 `form:"status,omitempty"` + Method *uint8 `form:"method,omitempty"` } type GetWithdrawalListResponse struct { @@ -3607,6 +3615,9 @@ type WithdrawalLog struct { Content string `json:"content"` Status uint8 `json:"status"` Reason string `json:"reason,omitempty"` + Method uint8 `json:"method"` + Account string `json:"account"` + QrCodeUrl string `json:"qr_code_url"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` }