新功能(#41): 提现优化 — 收款方式选择 + 取消提现 + 收款码上传
Build docker and publish / build (20.15.1) (push) Failing after 8m37s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-26 08:36:25 -07:00
parent 27f1203282
commit f9fa4756e9
16 changed files with 219 additions and 17 deletions
+1
View File
@@ -235,6 +235,7 @@ type (
Size int `form:"size"` Size int `form:"size"`
UserId *int64 `form:"user_id,omitempty"` UserId *int64 `form:"user_id,omitempty"`
Status *uint8 `form:"status,omitempty"` Status *uint8 `form:"status,omitempty"`
Method *uint8 `form:"method,omitempty"`
} }
GetWithdrawalListResponse { GetWithdrawalListResponse {
List []WithdrawalLog `json:"list"` List []WithdrawalLog `json:"list"`
+13
View File
@@ -111,6 +111,9 @@ type (
CommissionWithdrawRequest { CommissionWithdrawRequest {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Content string `json:"content"` 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 { WithdrawalLog {
Id int64 `json:"id"` Id int64 `json:"id"`
@@ -119,9 +122,15 @@ type (
Content string `json:"content"` Content string `json:"content"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Method uint8 `json:"method"`
Account string `json:"account"`
QrCodeUrl string `json:"qr_code_url"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
CancelWithdrawalRequest {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
QueryWithdrawalLogListRequest { QueryWithdrawalLogListRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
@@ -352,6 +361,10 @@ service ppanel {
@handler CommissionWithdraw @handler CommissionWithdraw
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog) post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
@doc "Cancel pending withdrawal"
@handler CancelWithdrawal
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
@doc "Query Withdrawal Log" @doc "Query Withdrawal Log"
@handler QueryWithdrawalLog @handler QueryWithdrawalLog
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
@@ -0,0 +1,4 @@
ALTER TABLE `withdrawals`
DROP COLUMN `qr_code_url`,
DROP COLUMN `account`,
DROP COLUMN `method`;
@@ -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`;
@@ -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)
}
}
+3
View File
@@ -1059,6 +1059,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Commission Withdraw // Commission Withdraw
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx)) publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
// Cancel Withdrawal
publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx))
// Delete Current User Account // Delete Current User Account
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
@@ -42,6 +42,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
if req.Status != nil { if req.Status != nil {
query = query.Where("status = ?", *req.Status) query = query.Where("status = ?", *req.Status)
} }
if req.Method != nil {
query = query.Where("method = ?", *req.Method)
}
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
@@ -62,6 +65,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
Content: row.Content, Content: row.Content,
Status: row.Status, Status: row.Status,
Reason: row.Reason, Reason: row.Reason,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(), CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(), UpdatedAt: row.UpdatedAt.UnixMilli(),
}) })
@@ -37,9 +37,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
} }
if err := tx.Model(&usermodel.Withdrawal{}). if err := tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID). Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": 1, "status": usermodel.WithdrawalStatusApproved,
"reason": "", "reason": "",
}).Error; err != nil { }).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err) 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, // Commission was NOT deducted at application time under the new logic,
// so rejection requires no refund — only a status update. // so rejection requires no refund — only a status update.
return tx.Model(&usermodel.Withdrawal{}). return tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID). Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": 2, "status": usermodel.WithdrawalStatusRejected,
"reason": reason, "reason": reason,
}).Error }).Error
}) })
+1 -1
View File
@@ -41,7 +41,7 @@ func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawal
First(&withdrawal).Error; err != nil { First(&withdrawal).Error; err != nil {
return nil, err return nil, err
} }
if withdrawal.Status != 0 { if withdrawal.Status != usermodel.WithdrawalStatusPending {
return nil, errors.New("withdrawal status invalid") return nil, errors.New("withdrawal status invalid")
} }
return &withdrawal, nil 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
}
@@ -35,12 +35,28 @@ 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")
} }
// 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. // Sum all pending (status=0) withdrawals to compute available balance.
// Available = commission - pendingTotal; commission is only deducted on approval. // Available = commission - pendingTotal; commission is only deducted on approval.
var pendingTotal int64 var pendingTotal int64
if err = l.svcCtx.DB.WithContext(l.ctx). if err = l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Withdrawal{}). 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)"). Select("COALESCE(SUM(amount), 0)").
Scan(&pendingTotal).Error; err != nil { Scan(&pendingTotal).Error; err != nil {
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err) l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
@@ -59,8 +75,11 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
UserId: u.Id, UserId: u.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: 0, Status: user.WithdrawalStatusPending,
Reason: "", Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
} }
return tx.Create(&w).Error return tx.Create(&w).Error
}) })
@@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
} }
return &types.WithdrawalLog{ return &types.WithdrawalLog{
Id: w.Id,
UserId: u.Id, UserId: u.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: 0, Status: user.WithdrawalStatusPending,
Reason: "", Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
CreatedAt: w.CreatedAt.UnixMilli(), CreatedAt: w.CreatedAt.UnixMilli(),
UpdatedAt: w.UpdatedAt.UnixMilli(), UpdatedAt: w.UpdatedAt.UnixMilli(),
}, nil }, nil
@@ -64,6 +64,9 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
Content: row.Content, Content: row.Content,
Status: row.Status, Status: row.Status,
Reason: row.Reason, Reason: row.Reason,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(), CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(), UpdatedAt: row.UpdatedAt.UnixMilli(),
}) })
+1
View File
@@ -51,6 +51,7 @@ const (
CommissionTypeAdjust uint16 = 335 // Admin Adjust CommissionTypeAdjust uint16 = 335 // Admin Adjust
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
GiftTypeIncrease uint16 = 341 // Increase GiftTypeIncrease uint16 = 341 // Increase
GiftTypeReduce uint16 = 342 // Reduce GiftTypeReduce uint16 = 342 // Reduce
) )
+4 -1
View File
@@ -167,8 +167,11 @@ type Withdrawal struct {
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
Amount int64 `gorm:"not null;comment:Withdrawal Amount"` Amount int64 `gorm:"not null;comment:Withdrawal Amount"`
Content string `gorm:"type:text;comment:Withdrawal Content"` 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"` 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"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"` UpdatedAt time.Time `gorm:"comment:Update Time"`
} }
+15
View File
@@ -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 // 银行卡
)
+11
View File
@@ -298,6 +298,9 @@ type CommissionLog struct {
type CommissionWithdrawRequest struct { type CommissionWithdrawRequest struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Content string `json:"content"` 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 { type ConnectionRecords struct {
@@ -2315,6 +2318,10 @@ type QueryUserSubscribeNodeListResponse struct {
List []UserSubscribeInfo `json:"list"` List []UserSubscribeInfo `json:"list"`
} }
type CancelWithdrawalRequest struct {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
type QueryWithdrawalLogListRequest struct { type QueryWithdrawalLogListRequest struct {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
@@ -2330,6 +2337,7 @@ type GetWithdrawalListRequest struct {
Size int `form:"size"` Size int `form:"size"`
UserId *int64 `form:"user_id,omitempty"` UserId *int64 `form:"user_id,omitempty"`
Status *uint8 `form:"status,omitempty"` Status *uint8 `form:"status,omitempty"`
Method *uint8 `form:"method,omitempty"`
} }
type GetWithdrawalListResponse struct { type GetWithdrawalListResponse struct {
@@ -3648,6 +3656,9 @@ type WithdrawalLog struct {
Content string `json:"content"` Content string `json:"content"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Method uint8 `json:"method"`
Account string `json:"account"`
QrCodeUrl string `json:"qr_code_url"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }