From c80d3f960ec088ba0d7927c6bc5d3ab290a4637e Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 3 Jun 2026 21:47:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=94=AF=E4=BB=98=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E9=92=B1=E5=8C=85=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/payment/leshua/client.go | 144 +++++++ .../payment/leshua/client_test.go | 57 +++ backend/internal/model/order.go | 3 + backend/internal/model/payment.go | 1 + backend/internal/modules/order/dto.go | 9 + backend/internal/modules/order/handler.go | 28 ++ backend/internal/modules/order/repository.go | 397 ++++++++---------- backend/internal/modules/order/service.go | 53 ++- backend/internal/modules/payment/dto.go | 14 + backend/internal/modules/payment/handler.go | 17 + .../internal/modules/payment/repository.go | 291 ++++++++++++- backend/internal/modules/payment/service.go | 37 +- backend/internal/modules/wallet/dto.go | 4 + backend/internal/modules/wallet/handler.go | 23 + backend/internal/modules/wallet/repository.go | 23 + backend/internal/modules/wallet/service.go | 12 +- backend/internal/router/router.go | 16 + backend/migrations/000001_init.sql | 6 + .../000002_payment_refund_schema.sql | 77 ++++ frontend/src/api/orders.ts | 34 ++ .../src/views/account/OrderDetailView.vue | 249 ++++++++++- frontend/src/views/account/OrdersView.vue | 8 +- frontend/src/views/account/WalletView.vue | 220 +--------- .../src/views/admin/AdminOrderDetailView.vue | 44 +- .../views/mobile/MobileOrderDetailView.vue | 26 +- .../src/views/mobile/MobileOrdersView.vue | 53 +-- 26 files changed, 1342 insertions(+), 504 deletions(-) create mode 100644 backend/migrations/000002_payment_refund_schema.sql diff --git a/backend/internal/integrations/payment/leshua/client.go b/backend/internal/integrations/payment/leshua/client.go index 7763aa9..bde3a37 100644 --- a/backend/internal/integrations/payment/leshua/client.go +++ b/backend/internal/integrations/payment/leshua/client.go @@ -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 } diff --git a/backend/internal/integrations/payment/leshua/client_test.go b/backend/internal/integrations/payment/leshua/client_test.go index 72f9a87..d124b37 100644 --- a/backend/internal/integrations/payment/leshua/client_test.go +++ b/backend/internal/integrations/payment/leshua/client_test.go @@ -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") + } +} diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index d4d1a59..8291834 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -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"` diff --git a/backend/internal/model/payment.go b/backend/internal/model/payment.go index 2af50dd..465dc1a 100644 --- a/backend/internal/model/payment.go +++ b/backend/internal/model/payment.go @@ -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"` diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 3a6a484..352594f 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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"` diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index d8dec0b..c6eceda 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -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): diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 47b3de2..c63616b 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -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) { diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index e4fc5ca..13bb602 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -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 diff --git a/backend/internal/modules/payment/dto.go b/backend/internal/modules/payment/dto.go index d83c3db..08a019b 100644 --- a/backend/internal/modules/payment/dto.go +++ b/backend/internal/modules/payment/dto.go @@ -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 diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 2ea0be0..653c2d6 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -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): diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 078500a..309a8c9 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -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 { diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go index 5cf3921..c489f74 100644 --- a/backend/internal/modules/payment/service.go +++ b/backend/internal/modules/payment/service.go @@ -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) +} diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go index cb5de60..1ccd07d 100644 --- a/backend/internal/modules/wallet/dto.go +++ b/backend/internal/modules/wallet/dto.go @@ -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"` diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index 64daeba..bbefb99 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -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, "钱包服务暂时不可用") } diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 80f08ce..31e5f76 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -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, diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go index 0774cbb..ea3cbcc 100644 --- a/backend/internal/modules/wallet/service.go +++ b/backend/internal/modules/wallet/service.go @@ -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) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2244b3a..6151f5d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index 6650434..f109334 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -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; diff --git a/backend/migrations/000002_payment_refund_schema.sql b/backend/migrations/000002_payment_refund_schema.sql new file mode 100644 index 0000000..a0ae7a8 --- /dev/null +++ b/backend/migrations/000002_payment_refund_schema.sql @@ -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'; diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 00a58b0..b98e49b 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -174,6 +174,40 @@ export async function adminMarkOrderAbnormal(id: number, reason: string) { return data.data } +export interface RefundStatus { + order_id: number + order_no: string + refund_status: string + refund_amount_cent: number + refunded_at?: string + total_amount: number +} + +export async function adminRefundOrder(id: number) { + const { data } = await apiClient.post>(`/admin/orders/${id}/refund`) + return data.data +} + +export async function adminRefundStatus(id: number) { + const { data } = await apiClient.get>(`/admin/orders/${id}/refund-status`) + return data.data +} + +export interface StartOrderPaymentRequest { + pay_way?: string + jspay_flag?: string +} + +export async function startOrderPayment(orderId: number, req?: StartOrderPaymentRequest) { + const { data } = await apiClient.post>(`/orders/${orderId}/start-payment`, req || {}) + return data.data +} + +export async function queryOrderPayment(orderId: number) { + const { data } = await apiClient.get>(`/orders/${orderId}/query-payment`) + return data.data +} + export async function confirmReceive(id: number) { const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) return data.data diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index 1cb0a2c..9a2cc84 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -1,8 +1,9 @@