diff --git a/apis/admin/user.api b/apis/admin/user.api index 8d01bda..bb585ff 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/initialize/migrate/database/02123_withdrawal_method.down.sql b/initialize/migrate/database/02123_withdrawal_method.down.sql new file mode 100644 index 0000000..83b2a18 --- /dev/null +++ b/initialize/migrate/database/02123_withdrawal_method.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE `withdrawals` + DROP COLUMN `qr_code_url`, + DROP COLUMN `account`, + DROP COLUMN `method`; diff --git a/initialize/migrate/database/02123_withdrawal_method.up.sql b/initialize/migrate/database/02123_withdrawal_method.up.sql new file mode 100644 index 0000000..57f3104 --- /dev/null +++ b/initialize/migrate/database/02123_withdrawal_method.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `withdrawals` + ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`, + ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`, + ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`; 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 236da3a..7b61ba9 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1059,6 +1059,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 a5e394b..d24db69 100644 --- a/internal/logic/admin/user/withdrawalCommon.go +++ b/internal/logic/admin/user/withdrawalCommon.go @@ -37,9 +37,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) @@ -80,9 +80,9 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa // 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 = ?", withdrawalID, usermodel.WithdrawalStatusPending). Updates(map[string]interface{}{ - "status": 2, + "status": usermodel.WithdrawalStatusRejected, "reason": reason, }).Error }) 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 b813aa0..64546c1 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -35,12 +35,28 @@ 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") + } + } + // 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). + Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending). Select("COALESCE(SUM(amount), 0)"). Scan(&pendingTotal).Error; err != nil { l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err) @@ -56,11 +72,14 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr 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: "", + UserId: u.Id, + Amount: req.Amount, + Content: req.Content, + Status: user.WithdrawalStatusPending, + Reason: "", + Method: req.Method, + Account: req.Account, + QrCodeUrl: req.QrCodeUrl, } return tx.Create(&w).Error }) @@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr } return &types.WithdrawalLog{ + Id: w.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: w.CreatedAt.UnixMilli(), UpdatedAt: w.UpdatedAt.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 cb335ec..0573d2c 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -51,6 +51,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 6c75778..caafcb0 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -167,8 +167,11 @@ 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"` } 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 d90b2ed..1717659 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -296,8 +296,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 { @@ -2315,6 +2318,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"` @@ -2330,6 +2337,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 { @@ -3648,6 +3656,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"` }