优化支付退款钱包链路

This commit is contained in:
yml
2026-06-03 21:47:04 +08:00
parent 04193ae687
commit c80d3f960e
26 changed files with 1342 additions and 504 deletions
@@ -72,6 +72,56 @@ type QueryPaymentResponse struct {
Raw map[string]string
}
type CreateRefundRequest struct {
ThirdOrderID string // 原支付商户订单号
LeshuaOrderID string // 原支付乐刷订单号(优先使用)
MerchantRefundID string // 商户退款单号(唯一)
RefundAmountCent int64 // 退款金额(分)
NotifyURL string
Attach string
}
type CreateRefundResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
RefundAmount string
TotalAmount string
OrderBalance string
Status string
Raw map[string]string
}
type QueryRefundRequest struct {
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
}
type QueryRefundResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
Status string
RefundAmount string
TotalAmount string
RefundTime string
Raw map[string]string
}
type VerifyNotifyResult struct {
OK bool
MatchedKey string
@@ -179,6 +229,100 @@ func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID
}, nil
}
func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, map[string]string, error) {
if err := c.validate(); err != nil {
return nil, nil, err
}
params := map[string]string{
"service": "unified_refund",
"merchant_id": c.cfg.MerchantID,
"merchant_refund_id": req.MerchantRefundID,
"refund_amount": fmt.Sprintf("%d", req.RefundAmountCent),
"nonce_str": Nonce(32),
}
if req.LeshuaOrderID != "" {
params["leshua_order_id"] = req.LeshuaOrderID
} else if req.ThirdOrderID != "" {
params["third_order_id"] = req.ThirdOrderID
}
if req.Attach != "" {
params["attach"] = sanitizeText(req.Attach, 64)
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
if req.NotifyURL != "" {
params["notify_url"] = req.NotifyURL
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, params, err
}
resp := &CreateRefundResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
LeshuaOrderID: raw["leshua_order_id"],
MerchantRefundID: raw["merchant_refund_id"],
LeshuaRefundID: raw["leshua_refund_id"],
RefundAmount: raw["refund_amount"],
TotalAmount: raw["total_amount"],
OrderBalance: raw["order_balance"],
Status: raw["status"],
Raw: raw,
}
return resp, params, nil
}
func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) {
if err := c.validate(); err != nil {
return nil, err
}
params := map[string]string{
"service": "unified_query_refund",
"merchant_id": c.cfg.MerchantID,
"nonce_str": Nonce(32),
}
if req.LeshuaOrderID != "" {
params["leshua_order_id"] = req.LeshuaOrderID
} else if req.ThirdOrderID != "" {
params["third_order_id"] = req.ThirdOrderID
}
if req.LeshuaRefundID != "" {
params["leshua_refund_id"] = req.LeshuaRefundID
} else if req.MerchantRefundID != "" {
params["merchant_refund_id"] = req.MerchantRefundID
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, err
}
return &QueryRefundResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
LeshuaOrderID: raw["leshua_order_id"],
MerchantRefundID: raw["merchant_refund_id"],
LeshuaRefundID: raw["leshua_refund_id"],
Status: raw["status"],
RefundAmount: raw["refund_amount"],
TotalAmount: raw["total_amount"],
RefundTime: raw["refund_time"],
Raw: raw,
}, nil
}
func (c *Client) VerifyNotify(params map[string]string) bool {
return c.VerifyNotifyDetail(params).OK
}
@@ -159,3 +159,60 @@ func TestParsePayloadSupportsFormAndXML(t *testing.T) {
t.Fatalf("ParsePayload(xml).coupon = %q, exists=%v; want empty value", value, ok)
}
}
func TestSignRefundUsesSameAlgorithm(t *testing.T) {
params := map[string]string{
"service": "unified_refund",
"merchant_id": "1234567890",
"merchant_refund_id": "REF001",
"refund_amount": "100",
"leshua_order_id": "LS1",
"nonce_str": "abc",
"sign": "ignored",
}
got := Sign(params, "secret", SignOptions{})
baseString := SignBaseString(params, SignOptions{})
wantBaseString := "leshua_order_id=LS1&merchant_id=1234567890&merchant_refund_id=REF001&nonce_str=abc&refund_amount=100&service=unified_refund"
if baseString != wantBaseString {
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
}
want := Sign(map[string]string{
"service": "unified_refund",
"merchant_id": "1234567890",
"merchant_refund_id": "REF001",
"refund_amount": "100",
"leshua_order_id": "LS1",
"nonce_str": "abc",
}, "secret", SignOptions{})
if got != want {
t.Fatalf("Sign() = %s, want %s", got, want)
}
}
func TestVerifyRefundNotify(t *testing.T) {
client := NewClient(config.LeshuaPaymentConfig{NotifyKey: "notify-secret"})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"leshua_order_id": "LS1",
"merchant_refund_id": "REF001",
"leshua_refund_id": "LREF001",
"refund_amount": "100",
"total_amount": "200",
"status": "11",
"attach": "",
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
if !client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = false, want true for refund notify")
}
params["status"] = "12"
if client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = true after status changed without re-sign, want false")
}
}
+3
View File
@@ -23,6 +23,9 @@ type RentalOrder struct {
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
RefundedAt *time.Time `json:"refunded_at"`
OwnerSettledAt *time.Time `json:"owner_settled_at"`
SettledAt *time.Time `json:"settled_at"`
CreatedAt time.Time `json:"created_at"`
+1
View File
@@ -19,6 +19,7 @@ type PaymentOrder struct {
PayWay string `gorm:"size:16;not null;default:''" json:"pay_way"`
JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:''" json:"jspay_flag"`
AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"`
BizType string `gorm:"size:32;not null;default:'order_pay';index" json:"biz_type"`
Status string `gorm:"size:32;not null;default:'created';index" json:"status"`
TDCode string `gorm:"size:512;not null;default:''" json:"td_code"`
JSPayURL string `gorm:"column:jspay_url;size:512;not null;default:''" json:"jspay_url"`
+9
View File
@@ -72,6 +72,15 @@ type AdminActionRequest struct {
type AuditMeta = auditlog.Meta
type RefundStatusDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
RefundStatus string `json:"refund_status"`
RefundAmountCent int64 `json:"refund_amount_cent"`
RefundedAt *time.Time `json:"refunded_at,omitempty"`
TotalAmount float64 `json:"total_amount"`
}
type HandoffRecordDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
+28
View File
@@ -95,6 +95,32 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) {
h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true})
}
func (h *Handler) AdminRefund(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.AdminRefund(orderID)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminRefundStatus(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.AdminRefundStatus(orderID)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -394,6 +420,8 @@ func writeOrderError(c *gin.Context, err error) {
response.BadRequest(c, "不能租用自己发布的账号")
case errors.Is(err, ErrInsufficientBalance):
response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值")
case errors.Is(err, ErrChannelPaymentRequired):
response.Error(c, http.StatusGone, "channel_payment_required", "请使用第三方支付入口完成订单付款")
case errors.Is(err, ErrOrderCannotPay):
response.Error(c, http.StatusConflict, "order_cannot_pay", "当前订单不能支付")
case errors.Is(err, ErrOrderCannotCancel):
+176 -221
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"log"
"math"
"strconv"
"time"
@@ -20,9 +21,20 @@ import (
"gorm.io/gorm/clause"
)
// RefundFunc 由 payment 模块注入,避免 order 与 payment 形成循环依赖。
type RefundFunc func(orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
type refundAction struct {
OrderID uint64
RefundAmountCent int64
BizType string
Remark string
}
type Repository struct {
db *gorm.DB
chatRepo *chat.Repository
db *gorm.DB
chatRepo *chat.Repository
refundFunc RefundFunc
}
const defaultPendingPaymentTimeoutMinutes = 15
@@ -35,6 +47,10 @@ func (r *Repository) SetChatRepo(cr *chat.Repository) {
r.chatRepo = cr
}
func (r *Repository) SetRefundFunc(fn RefundFunc) {
r.refundFunc = fn
}
type orderPricing struct {
RentAmount float64
OwnerRentAmount float64
@@ -201,112 +217,12 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
return r.FindForUser(renterID, createdID)
}
// Pay 保留旧接口兼容,但真实付款必须走 payment 模块的渠道支付入口。
func (r *Repository) Pay(userID uint64, orderID uint64) error {
var newConvID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND renter_id = ?", orderID, userID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending_payment" {
return ErrOrderCannotPay
}
timeoutMinutes := pendingPaymentTimeoutMinutes(tx)
if timeoutMinutes > 0 && order.CreatedAt.Before(time.Now().Add(-time.Duration(timeoutMinutes)*time.Minute)) {
return ErrOrderCannotPay
}
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
if listing.Status != "published" || listing.ReviewStatus != "approved" || !listing.InTransaction {
return ErrListingUnavailable
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
}
total := order.RentAmount + order.DepositAmount
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &order.ID,
Direction: "out",
Amount: total,
BalanceType: "available",
BizType: "order_pay",
BizNo: order.OrderNo,
Remark: "订单支付扣减可用余额",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &order.ID,
Direction: "in",
Amount: total,
BalanceType: "frozen",
BizType: "order_lock",
BizNo: order.OrderNo,
Remark: "订单支付冻结租金和押金",
},
); err != nil {
if errors.Is(err, wallet.ErrInsufficientBalance) {
return ErrInsufficientBalance
}
return err
}
order.Status = "pending_handoff"
order.HandoffStatus = "pending_owner"
listing.Status = "rented"
account.Status = "rented"
conv, err := chat.EnsureOrderConversation(tx, order)
if err != nil {
return err
}
newConvID = conv.ID
orderID := order.ID
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "收到新的租号订单",
Content: "租客已完成支付,请尽快提交交接说明。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单支付成功",
Content: "支付金额已冻结,等待号主提交交接说明。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return tx.Save(&account).Error
})
if err != nil {
return err
}
// 事务成功后推送群聊创建事件
if newConvID > 0 && r.chatRepo != nil {
r.chatRepo.NotifyNewConversation(newConvID)
}
return nil
return ErrChannelPaymentRequired
}
// ConfirmPaidFromChannel 在乐刷确认支付后推进订单状态;租客资金不进入站内钱包。
func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string) error {
var newConvID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
@@ -333,21 +249,8 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
return err
}
// 租客已通过外部渠道付款,这里不写租客钱包流水。
orderID := order.ID
total := order.RentAmount + order.DepositAmount
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: total,
BalanceType: "frozen",
BizType: "channel_order_lock",
BizNo: firstNonEmpty(providerBizNo, order.OrderNo),
Remark: "渠道支付成功冻结租金和押金",
}); err != nil {
return err
}
order.Status = "pending_handoff"
order.HandoffStatus = "pending_owner"
listing.Status = "rented"
@@ -370,7 +273,7 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
UserID: order.RenterID,
Type: "order",
Title: "订单支付成功",
Content: "支付金额已冻结,等待号主提交交接说明。",
Content: "支付已完成,等待号主提交交接说明。",
BizType: "order",
BizID: &orderID,
},
@@ -395,7 +298,8 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
}
func (r *Repository) Cancel(userID uint64, orderID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND renter_id = ?", orderID, userID).
@@ -419,31 +323,12 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
order.HandoffStatus = "cancelled"
orderID := order.ID
if beforeStatus == "pending_handoff" {
total := order.RentAmount + order.DepositAmount
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: total,
BalanceType: "frozen",
BizType: "order_cancel",
BizNo: order.OrderNo,
Remark: "取消订单释放冻结金额",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: total,
BalanceType: "available",
BizType: "order_cancel_refund",
BizNo: order.OrderNo,
Remark: "取消订单退回可用余额",
},
); err != nil {
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
action, err := r.prepareRefund(&order, totalCent, "cancel_refund", "取消订单原路退款")
if err != nil {
return err
}
refund = action
}
if err := notification.Append(tx,
notification.Entry{
@@ -458,7 +343,7 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
UserID: order.RenterID,
Type: "order",
Title: "订单取消成功",
Content: "订单已取消,相关金额已释放。",
Content: "订单已取消,退款将原路退回您的支付账户。",
BizType: "order",
BizID: &orderID,
},
@@ -476,6 +361,11 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
}
return tx.Save(&account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) SubmitHandoff(userID uint64, orderID uint64, req SubmitHandoffRequest) (*HandoffRecordDTO, error) {
@@ -651,7 +541,8 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
}
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
@@ -677,8 +568,15 @@ func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
}
checkout.Status = "accepted"
checkout.OwnerAdjustedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
action, err := r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
refund = action
return err
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
@@ -756,7 +654,8 @@ func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterC
}
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
@@ -777,8 +676,15 @@ func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
now := time.Now()
checkout.Status = "accepted"
checkout.RenterConfirmedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
action, err := r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
refund = action
return err
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
@@ -840,7 +746,8 @@ func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, er
}
func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID)
if err != nil {
return err
@@ -862,38 +769,19 @@ func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionR
listing.InTransaction = false
account.Status = "offline"
if beforeOrderStatus != "pending_payment" {
total := order.RentAmount + order.DepositAmount
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &order.ID,
Direction: "out",
Amount: total,
BalanceType: "frozen",
BizType: "admin_order_close",
BizNo: order.OrderNo,
Remark: "后台关闭订单释放冻结金额",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &order.ID,
Direction: "in",
Amount: total,
BalanceType: "available",
BizType: "admin_order_close_refund",
BizNo: order.OrderNo,
Remark: "后台关闭订单退回可用余额",
},
); err != nil {
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
action, err := r.prepareRefund(order, totalCent, "admin_close_refund", "客服关闭订单原路退款")
if err != nil {
return err
}
refund = action
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已由客服关闭",
Content: "客服已关闭订单,模拟冻结金额已释放。原因:" + req.Reason,
Content: "客服已关闭订单,退款将原路退回您的支付账户。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
@@ -935,6 +823,11 @@ func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionR
}
return tx.Save(account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
@@ -1002,6 +895,57 @@ func (r *Repository) AdminMarkAbnormal(adminID uint64, orderID uint64, req Admin
})
}
// AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
func (r *Repository) AdminRefund(orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
if order.RefundStatus == "refunded" {
return r.buildRefundStatusDTO(&order), nil
}
if r.refundFunc == nil {
return nil, ErrDependencyUnavailable
}
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
if totalCent <= 0 {
return nil, ErrInvalidCheckoutAmount
}
status, err := r.refundFunc(orderID, totalCent, "admin_refund", "后台人工退款")
if err != nil {
return nil, err
}
// 重新读取订单,拿到 payment 模块更新后的退款字段。
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
dto := r.buildRefundStatusDTO(&order)
if status != "" {
dto.RefundStatus = status
}
return dto, nil
}
// AdminRefundStatus 查询订单退款状态。
func (r *Repository) AdminRefundStatus(orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
return r.buildRefundStatusDTO(&order), nil
}
func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatusDTO {
return &RefundStatusDTO{
OrderID: order.ID,
OrderNo: order.OrderNo,
RefundStatus: order.RefundStatus,
RefundAmountCent: order.RefundAmountCent,
RefundedAt: order.RefundedAt,
TotalAmount: order.RentAmount + order.DepositAmount,
}
}
func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
var row orderRow
if err := r.baseQuery().
@@ -1014,14 +958,14 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
return &dto, nil
}
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) (*refundAction, error) {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
return nil, err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
return nil, err
}
now := time.Now()
order.Status = "completed"
@@ -1034,20 +978,11 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
account.Status = "published"
orderID := order.ID
settlement := buildCheckoutSettlement(*order, checkout)
entries := []wallet.Entry{
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: order.RentAmount + order.DepositAmount,
BalanceType: "frozen",
BizType: "order_settle",
BizNo: order.OrderNo,
Remark: "订单结账释放冻结金额",
},
}
// 卖家收入进入站内钱包;租客资金不进入站内钱包。
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncome > 0 {
entries = append(entries, wallet.Entry{
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
@@ -1059,7 +994,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
})
}
if settlement.DepositCompensation > 0 {
entries = append(entries, wallet.Entry{
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
@@ -1070,33 +1005,23 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
Remark: "订单结账押金赔付",
})
}
if settlement.RentRefund > 0 {
entries = append(entries, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: settlement.RentRefund,
BalanceType: "available",
BizType: "rent_refund",
BizNo: order.OrderNo,
Remark: "订单结账退回未使用租金",
})
if len(ownerEntries) > 0 {
if err := wallet.AppendEntries(tx, ownerEntries...); err != nil {
return nil, err
}
}
if settlement.DepositRefund > 0 {
entries = append(entries, wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: settlement.DepositRefund,
BalanceType: "available",
BizType: "deposit_release",
BizNo: order.OrderNo,
Remark: "订单结账退回押金",
})
}
if err := wallet.AppendEntries(tx, entries...); err != nil {
return err
var refund *refundAction
renterRefundTotal := settlement.RentRefund + settlement.DepositRefund
if renterRefundTotal > 0 {
refundCent := int64(math.Round(renterRefundTotal * 100))
action, err := r.prepareRefund(order, refundCent, "checkout_refund", "结账退款原路退还")
if err != nil {
return nil, err
}
refund = action
}
checkout.RentAmount = settlement.ActualRentAmount
checkout.OwnerRentAmount = settlement.OwnerRentIncome
checkout.PlatformFee = settlement.PlatformFee
@@ -1120,18 +1045,48 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
BizID: &orderID,
},
); err != nil {
return err
return nil, err
}
if err := tx.Save(order).Error; err != nil {
return err
return nil, err
}
if err := tx.Save(checkout).Error; err != nil {
return err
return nil, err
}
if err := tx.Save(&listing).Error; err != nil {
return err
return nil, err
}
if err := tx.Save(&account).Error; err != nil {
return nil, err
}
return refund, nil
}
func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) {
if amountCent <= 0 {
return nil, nil
}
if r.refundFunc == nil {
return nil, ErrDependencyUnavailable
}
order.RefundStatus = "pending"
order.RefundAmountCent = amountCent
order.RefundedAt = nil
return &refundAction{
OrderID: order.ID,
RefundAmountCent: amountCent,
BizType: bizType,
Remark: remark,
}, nil
}
func (r *Repository) startRefundBestEffort(action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
if _, err := r.refundFunc(action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
log.Printf("[order] start refund failed order_id=%d biz_type=%s amount_cent=%d err=%v", action.OrderID, action.BizType, action.RefundAmountCent, err)
}
return tx.Save(&account).Error
}
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
+37 -16
View File
@@ -3,22 +3,23 @@ package order
import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidRentHours = errors.New("invalid rent hours")
ErrListingUnavailable = errors.New("listing unavailable")
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidRentHours = errors.New("invalid rent hours")
ErrListingUnavailable = errors.New("listing unavailable")
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrChannelPaymentRequired = errors.New("channel payment required")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied")
)
const internalOrderHours = 24
@@ -181,6 +182,26 @@ func (s *Service) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminAct
return s.repo.AdminMarkAbnormal(adminID, orderID, req, meta)
}
func (s *Service) AdminRefund(orderID uint64) (*RefundStatusDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 {
return nil, ErrOrderCannotComplete
}
return s.repo.AdminRefund(orderID)
}
func (s *Service) AdminRefundStatus(orderID uint64) (*RefundStatusDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 {
return nil, ErrOrderCannotComplete
}
return s.repo.AdminRefundStatus(orderID)
}
func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+14
View File
@@ -34,6 +34,20 @@ type PaymentDTO struct {
UpdatedAt time.Time `json:"updated_at"`
}
type RefundDTO struct {
ID uint64 `json:"id"`
PaymentNo string `json:"payment_no"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
BizType string `json:"biz_type"`
AmountCent int64 `json:"amount_cent"`
Status string `json:"status"`
ProviderOrderID string `json:"provider_order_id,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotifyResult struct {
OK bool
Message string
@@ -99,6 +99,19 @@ func (h *Handler) WalletRechargeQuery(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) QueryRefundStatus(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.QueryRefundStatus(orderID)
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) LeshuaNotify(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
@@ -157,6 +170,10 @@ func writePaymentError(c *gin.Context, err error) {
response.Error(c, http.StatusBadGateway, "payment_unavailable", "支付渠道暂不可用")
case errors.Is(err, ErrPaymentCannotStart), errors.Is(err, order.ErrOrderCannotPay):
response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付")
case errors.Is(err, ErrRefundCannotStart):
response.Error(c, http.StatusConflict, "refund_cannot_start", "当前订单不能退款")
case errors.Is(err, ErrWalletRechargeDisabled):
response.Error(c, http.StatusGone, "wallet_recharge_disabled", "钱包充值已关闭")
case errors.Is(err, ErrPaymentVerifyFailed):
response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败")
case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err):
+289 -2
View File
@@ -38,6 +38,15 @@ const (
channelSourceMock = "mock"
)
var refundBizTypes = []string{
"cancel_refund",
"admin_close_refund",
"admin_refund",
"checkout_refund",
"deposit_refund",
"rent_refund",
}
func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
provider := cfg.Provider
if provider == "" {
@@ -216,7 +225,7 @@ func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*Paym
func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
var payment model.PaymentOrder
if err := r.db.Where("order_id = ? AND user_id = ?", orderID, userID).Order("id DESC").First(&payment).Error; err != nil {
if err := r.db.Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, "order_pay").Order("id DESC").First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
@@ -261,6 +270,10 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload str
}
log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey)
}
// 退款通知会携带 merchant_refund_id 或 leshua_refund_id。
if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" {
return r.HandleRefundNotify(params, rawPayload, contentType)
}
thirdOrderID := params["third_order_id"]
if thirdOrderID == "" {
return nil, ErrPaymentNotFound
@@ -285,6 +298,278 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload str
return &NotifyResult{OK: true, Message: "000000"}, nil
}
// StartRefund 创建退款单,并在本地落库后调用乐刷退款接口。
func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) {
var originalPayment model.PaymentOrder
if err := r.db.Where("order_id = ? AND status = 'paid' AND biz_type = 'order_pay'", orderID).Order("id DESC").First(&originalPayment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
return nil, err
}
var existingRefund model.PaymentOrder
err := r.db.Where("order_id = ? AND biz_type = ? AND status NOT IN ('failed')", orderID, bizType).Order("id DESC").First(&existingRefund).Error
if err == nil {
dto := toRefundDTO(existingRefund)
return &dto, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
paymentNo, err := newPaymentNo()
if err != nil {
return nil, err
}
merchantRefundID := "REF" + paymentNo[3:]
refundOrder := model.PaymentOrder{
PaymentNo: paymentNo,
OrderID: orderID,
OrderNo: originalPayment.OrderNo,
UserID: originalPayment.UserID,
Provider: r.provider,
MerchantID: r.cfg.Leshua.MerchantID,
ThirdOrderID: merchantRefundID,
ProviderOrderID: "",
PayWay: originalPayment.PayWay,
JSPayFlag: originalPayment.JSPayFlag,
AmountCent: refundAmountCent,
BizType: bizType,
Status: "refunding",
}
if r.isMockMode {
refundOrder.ProviderOrderID = "MOCKREF" + merchantRefundID
refundOrder.Status = "refunded"
now := time.Now()
refundOrder.PaidAt = &now
if remark != "" {
refundOrder.RawResponse = datatypes.JSON([]byte(fmt.Sprintf(`{"mock":"true","remark":"%s"}`, remark)))
}
if err := r.db.Create(&refundOrder).Error; err != nil {
return nil, err
}
if err := r.updateOrderRefundStatus(orderID, refundAmountCent); err != nil {
log.Printf("[payment] mock update order refund status failed order_id=%d err=%v", orderID, err)
}
dto := toRefundDTO(refundOrder)
return &dto, nil
}
if err := r.db.Create(&refundOrder).Error; err != nil {
return nil, err
}
if err := r.markOrderRefunding(orderID, refundAmountCent); err != nil {
log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err)
}
resp, rawReq, err := r.leshua.CreateRefund(context.Background(), leshua.CreateRefundRequest{
ThirdOrderID: originalPayment.ThirdOrderID,
LeshuaOrderID: originalPayment.ProviderOrderID,
MerchantRefundID: merchantRefundID,
RefundAmountCent: refundAmountCent,
NotifyURL: r.cfg.Leshua.NotifyURL,
Attach: originalPayment.OrderNo,
})
if err != nil {
_ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()})
return nil, err
}
if resp.RespCode != "0" || resp.ResultCode != "0" {
_ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw)
return nil, ErrPaymentUnavailable
}
refundStatus := "refunding"
var paidAt *time.Time
if resp.Status == "11" {
refundStatus = "refunded"
now := time.Now()
paidAt = &now
} else if resp.Status == "12" {
refundStatus = "failed"
}
if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{
"status": refundStatus,
"provider_order_id": resp.LeshuaRefundID,
"raw_request": jsonMap(rawReq),
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
"paid_at": paidAt,
}).Error; err != nil {
return nil, err
}
if refundStatus == "refunded" {
_ = r.updateOrderRefundStatus(orderID, refundAmountCent)
refundOrder.PaidAt = paidAt
} else if refundStatus == "failed" {
_ = r.markOrderRefundFailed(orderID, refundAmountCent)
} else {
_ = r.markOrderRefunding(orderID, refundAmountCent)
}
refundOrder.Status = refundStatus
refundOrder.ProviderOrderID = resp.LeshuaRefundID
dto := toRefundDTO(refundOrder)
return &dto, nil
}
// QueryRefundStatus 查询订单最近一笔退款状态。
func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) {
var payment model.PaymentOrder
if err := r.db.Where("order_id = ? AND biz_type IN ?", orderID, refundBizTypes).Order("id DESC").First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
return nil, err
}
if payment.Status == "refunded" || payment.Status == "failed" || r.isMockMode {
dto := toRefundDTO(payment)
return &dto, nil
}
resp, err := r.leshua.QueryRefund(context.Background(), leshua.QueryRefundRequest{
ThirdOrderID: payment.ThirdOrderID,
MerchantRefundID: payment.ThirdOrderID,
LeshuaRefundID: payment.ProviderOrderID,
})
if err != nil {
return nil, err
}
if resp.Status == "11" {
now := time.Now()
if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "refunded",
"paid_at": now,
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)),
}).Error; err != nil {
return nil, err
}
payment.Status = "refunded"
payment.PaidAt = &now
_ = r.updateOrderRefundStatus(orderID, payment.AmountCent)
} else if resp.Status == "12" {
if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "failed",
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)),
}).Error; err != nil {
return nil, err
}
payment.Status = "failed"
_ = r.markOrderRefundFailed(orderID, payment.AmountCent)
}
dto := toRefundDTO(payment)
return &dto, nil
}
// HandleRefundNotify 处理乐刷退款通知。
func (r *Repository) HandleRefundNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
var verify leshua.VerifyNotifyResult
if !r.isMockMode {
verify = r.leshua.VerifyNotifyDetail(params)
if !verify.OK {
log.Printf("[payment] refund notify verify failed merchant_refund_id=%s", params["merchant_refund_id"])
return nil, ErrPaymentVerifyFailed
}
}
merchantRefundID := params["merchant_refund_id"]
if merchantRefundID == "" {
return nil, ErrPaymentNotFound
}
var payment model.PaymentOrder
if err := r.db.Where("third_order_id = ?", merchantRefundID).First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
return nil, err
}
raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified")
status := params["status"]
switch status {
case "11":
now := time.Now()
if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "refunded",
"paid_at": now,
"notified_at": now,
"raw_response": jsonMap(raw),
}).Error; err != nil {
return nil, err
}
_ = r.updateOrderRefundStatus(payment.OrderID, payment.AmountCent)
case "12":
r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "failed",
"notified_at": time.Now(),
"raw_response": jsonMap(raw),
})
_ = r.markOrderRefundFailed(payment.OrderID, payment.AmountCent)
default:
r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "refunding",
"raw_response": jsonMap(raw),
})
}
return &NotifyResult{OK: true, Message: "000000"}, nil
}
// updateOrderRefundStatus 更新订单退款成功状态。
func (r *Repository) updateOrderRefundStatus(orderID uint64, refundAmountCent int64) error {
now := time.Now()
return r.db.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
"refund_status": "refunded",
"refund_amount_cent": refundAmountCent,
"refunded_at": now,
}).Error
}
func (r *Repository) markOrderRefunding(orderID uint64, refundAmountCent int64) error {
return r.db.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
"refund_status": "refunding",
"refund_amount_cent": refundAmountCent,
"refunded_at": nil,
}).Error
}
func (r *Repository) markOrderRefundFailed(orderID uint64, refundAmountCent int64) error {
return r.db.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
"refund_status": "failed",
"refund_amount_cent": refundAmountCent,
"refunded_at": nil,
}).Error
}
func (r *Repository) markRefundFailed(paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error {
if raw == nil {
raw = map[string]string{"error": "refund failed"}
}
if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{
"status": "failed",
"raw_response": jsonMap(raw),
}).Error; err != nil {
return err
}
return r.markOrderRefundFailed(orderID, refundAmountCent)
}
// toRefundDTO 将支付表里的退款单转换为接口 DTO。
func toRefundDTO(payment model.PaymentOrder) RefundDTO {
return RefundDTO{
ID: payment.ID,
PaymentNo: payment.PaymentNo,
OrderID: payment.OrderID,
OrderNo: payment.OrderNo,
BizType: payment.BizType,
AmountCent: payment.AmountCent,
Status: payment.Status,
ProviderOrderID: payment.ProviderOrderID,
PaidAt: payment.PaidAt,
CreatedAt: payment.CreatedAt,
UpdatedAt: payment.UpdatedAt,
}
}
func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaymentRequest) (*model.PaymentOrder, *model.RentalOrder, error) {
var paymentID uint64
var orderRow model.RentalOrder
@@ -304,7 +589,7 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym
}
var existing model.PaymentOrder
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ?", row.ID).
Where("order_id = ? AND biz_type = ?", row.ID, "order_pay").
Order("id DESC").
First(&existing).Error
if err == nil {
@@ -342,6 +627,7 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym
PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"),
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
AmountCent: amountCent,
BizType: "order_pay",
Status: "created",
}
if r.isMockMode {
@@ -382,6 +668,7 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64
PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"),
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
AmountCent: amountCent,
BizType: "wallet_recharge",
Status: "created",
}
if r.isMockMode {
+28 -9
View File
@@ -3,11 +3,13 @@ package payment
import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrPaymentUnavailable = errors.New("payment unavailable")
ErrPaymentCannotStart = errors.New("payment cannot start")
ErrPaymentVerifyFailed = errors.New("payment verify failed")
ErrPaymentNotFound = errors.New("payment not found")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrPaymentUnavailable = errors.New("payment unavailable")
ErrPaymentCannotStart = errors.New("payment cannot start")
ErrPaymentVerifyFailed = errors.New("payment verify failed")
ErrPaymentNotFound = errors.New("payment not found")
ErrRefundCannotStart = errors.New("refund cannot start")
ErrWalletRechargeDisabled = errors.New("wallet recharge disabled")
)
const MinWalletRechargeAmount = 0.01
@@ -44,10 +46,7 @@ func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRe
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || req.Amount < MinWalletRechargeAmount {
return nil, ErrPaymentCannotStart
}
return s.repo.StartWalletRecharge(userID, req, clientIP)
return nil, ErrWalletRechargeDisabled
}
func (s *Service) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) {
@@ -66,3 +65,23 @@ func (s *Service) HandleLeshuaNotify(params map[string]string, rawPayload string
}
return s.repo.HandleLeshuaNotify(params, rawPayload, contentType)
}
func (s *Service) StartRefund(orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || refundAmountCent <= 0 {
return nil, ErrRefundCannotStart
}
return s.repo.StartRefund(orderID, refundAmountCent, bizType, remark)
}
func (s *Service) QueryRefundStatus(orderID uint64) (*RefundDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 {
return nil, ErrPaymentNotFound
}
return s.repo.QueryRefundStatus(orderID)
}
+4
View File
@@ -13,6 +13,10 @@ type RechargeRequest struct {
Amount float64 `json:"amount" binding:"required"`
}
type WithdrawRequest struct {
Amount float64 `json:"amount" binding:"required"`
}
type LedgerDTO struct {
ID uint64 `json:"id"`
LedgerNo string `json:"ledger_no"`
@@ -81,6 +81,25 @@ func (h *Handler) Recharge(c *gin.Context) {
response.OK(c, account)
}
func (h *Handler) Withdraw(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
var req WithdrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "提现金额不正确")
return
}
account, err := h.service.Withdraw(userID, req)
if err != nil {
writeWalletError(c, err)
return
}
response.OK(c, account)
}
func (h *Handler) AdminLedger(c *gin.Context) {
query, ok := parseAdminLedgerQuery(c)
if !ok {
@@ -136,6 +155,10 @@ func writeWalletError(c *gin.Context, err error) {
response.BadRequest(c, "充值金额不正确")
case errors.Is(err, ErrInsufficientBalance):
response.Error(c, 409, "insufficient_balance", "钱包余额不足")
case errors.Is(err, ErrRechargeDisabled):
response.Error(c, 410, "wallet_recharge_disabled", "钱包充值已关闭")
case errors.Is(err, ErrFeaturePending):
response.Error(c, 501, "feature_pending", "提现功能待开发")
default:
response.ServiceUnavailable(c, "钱包服务暂时不可用")
}
@@ -117,6 +117,29 @@ func (r *Repository) ConfirmRechargeFromChannel(userID uint64, bizNo string, amo
})
}
// Withdraw 保留仓库能力;当前公开提现入口在 service 层标记为待开发,不会调用到这里。
func (r *Repository) Withdraw(userID uint64, amount float64) (*AccountDTO, error) {
amount = roundWalletMoney(amount)
if userID == 0 || amount <= 0 {
return nil, ErrInvalidAmount
}
err := r.db.Transaction(func(tx *gorm.DB) error {
return AppendEntries(tx, Entry{
UserID: userID,
Direction: "out",
Amount: amount,
BalanceType: "available",
BizType: "withdraw_apply",
BizNo: fmt.Sprintf("WD%d", time.Now().UnixNano()),
Remark: "卖家申请提现",
})
})
if err != nil {
return nil, err
}
return r.Account(userID)
}
func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
db := r.db.Table("wallet_ledger AS wl").
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
+9 -3
View File
@@ -6,6 +6,8 @@ var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidAmount = errors.New("invalid amount")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrFeaturePending = errors.New("feature pending")
ErrRechargeDisabled = errors.New("wallet recharge disabled")
)
const MinRechargeAmount = 0.01
@@ -36,10 +38,14 @@ func (s *Service) Recharge(userID uint64, req RechargeRequest) (*AccountDTO, err
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Amount < MinRechargeAmount {
return nil, ErrInvalidAmount
return nil, ErrRechargeDisabled
}
func (s *Service) Withdraw(userID uint64, req WithdrawRequest) (*AccountDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Recharge(userID, req.Amount)
return nil, ErrFeaturePending
}
func (s *Service) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
+16
View File
@@ -105,6 +105,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
paymentService := payment.NewService(paymentRepo)
paymentHandler := payment.NewHandler(paymentService)
// Inject refund function into order repo to avoid circular dependency
if orderRepo != nil && paymentRepo != nil {
orderRepo.SetRefundFunc(func(orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) {
dto, err := paymentRepo.StartRefund(orderID, refundAmountCent, bizType, remark)
if err != nil {
return "", err
}
return dto.Status, nil
})
}
var notificationRepo *notification.Repository
if deps.DB != nil {
notificationRepo = notification.NewRepository(deps.DB)
@@ -223,6 +233,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.GET("/:id", orderHandler.Detail)
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
orderRoutes.POST("/:id/pay", orderHandler.Pay)
orderRoutes.POST("/:id/start-payment", paymentHandler.Start)
orderRoutes.GET("/:id/query-payment", paymentHandler.Query)
orderRoutes.POST("/:id/cancel", orderHandler.Cancel)
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
@@ -234,6 +246,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout)
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
orderRoutes.GET("/:id/refund-status", paymentHandler.QueryRefundStatus)
}
disputeRoutes := api.Group("/disputes", requireAuth)
@@ -249,6 +262,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
walletRoutes.POST("/recharge", walletHandler.Recharge)
walletRoutes.POST("/recharge/pay", paymentHandler.WalletRecharge)
walletRoutes.POST("/recharge/pay/:id/query", paymentHandler.WalletRechargeQuery)
walletRoutes.POST("/withdraw", walletHandler.Withdraw)
}
fileRoutes := api.Group("/files", requireAuth)
@@ -304,6 +318,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
+6
View File
@@ -104,6 +104,9 @@ CREATE TABLE IF NOT EXISTS rental_orders (
status VARCHAR(32) NOT NULL DEFAULT 'pending_payment',
handoff_status VARCHAR(32) NOT NULL DEFAULT 'none',
settlement_status VARCHAR(32) NOT NULL DEFAULT 'unsettled',
refund_status VARCHAR(32) NOT NULL DEFAULT 'none',
refund_amount_cent BIGINT NOT NULL DEFAULT 0,
refunded_at DATETIME NULL,
owner_settled_at DATETIME NULL,
settled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -112,6 +115,7 @@ CREATE TABLE IF NOT EXISTS rental_orders (
KEY idx_rental_orders_renter_status (renter_id, status),
KEY idx_rental_orders_owner_status (owner_id, status),
KEY idx_rental_orders_listing_id (listing_id),
KEY idx_rental_orders_refund_status (refund_status),
KEY idx_rental_orders_rented_at (status, rented_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -207,6 +211,7 @@ CREATE TABLE IF NOT EXISTS payment_orders (
pay_way VARCHAR(16) NOT NULL DEFAULT '',
jspay_flag VARCHAR(8) NOT NULL DEFAULT '',
amount_cent BIGINT NOT NULL DEFAULT 0,
biz_type VARCHAR(32) NOT NULL DEFAULT 'order_pay',
status VARCHAR(32) NOT NULL DEFAULT 'created',
td_code VARCHAR(512) NOT NULL DEFAULT '',
jspay_url VARCHAR(512) NOT NULL DEFAULT '',
@@ -223,6 +228,7 @@ CREATE TABLE IF NOT EXISTS payment_orders (
KEY idx_payment_orders_order_no (order_no),
KEY idx_payment_orders_user_id (user_id),
KEY idx_payment_orders_provider_order_id (provider_order_id),
KEY idx_payment_orders_biz_type (biz_type),
KEY idx_payment_orders_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,77 @@
-- 支付退款字段补齐:兼容已经应用过 000001 的现有数据库。
SET @has_refund_status := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'rental_orders' AND column_name = 'refund_status'
);
SET @sql := IF(@has_refund_status = 0,
'ALTER TABLE rental_orders ADD COLUMN refund_status VARCHAR(32) NOT NULL DEFAULT ''none'' AFTER settlement_status',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_refund_amount_cent := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'rental_orders' AND column_name = 'refund_amount_cent'
);
SET @sql := IF(@has_refund_amount_cent = 0,
'ALTER TABLE rental_orders ADD COLUMN refund_amount_cent BIGINT NOT NULL DEFAULT 0 AFTER refund_status',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_refunded_at := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'rental_orders' AND column_name = 'refunded_at'
);
SET @sql := IF(@has_refunded_at = 0,
'ALTER TABLE rental_orders ADD COLUMN refunded_at DATETIME NULL AFTER refund_amount_cent',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_order_refund_idx := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'rental_orders' AND index_name = 'idx_rental_orders_refund_status'
);
SET @sql := IF(@has_order_refund_idx = 0,
'ALTER TABLE rental_orders ADD KEY idx_rental_orders_refund_status (refund_status)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_payment_biz_type := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'payment_orders' AND column_name = 'biz_type'
);
SET @sql := IF(@has_payment_biz_type = 0,
'ALTER TABLE payment_orders ADD COLUMN biz_type VARCHAR(32) NOT NULL DEFAULT ''order_pay'' AFTER amount_cent',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @has_payment_biz_idx := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'payment_orders' AND index_name = 'idx_payment_orders_biz_type'
);
SET @sql := IF(@has_payment_biz_idx = 0,
'ALTER TABLE payment_orders ADD KEY idx_payment_orders_biz_type (biz_type)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE payment_orders
SET biz_type = CASE WHEN order_id = 0 THEN 'wallet_recharge' ELSE 'order_pay' END
WHERE biz_type = '' OR biz_type = 'order_pay';