优化支付退款钱包链路
This commit is contained in:
@@ -72,6 +72,56 @@ type QueryPaymentResponse struct {
|
|||||||
Raw map[string]string
|
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 {
|
type VerifyNotifyResult struct {
|
||||||
OK bool
|
OK bool
|
||||||
MatchedKey string
|
MatchedKey string
|
||||||
@@ -179,6 +229,100 @@ func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID
|
|||||||
}, nil
|
}, 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 {
|
func (c *Client) VerifyNotify(params map[string]string) bool {
|
||||||
return c.VerifyNotifyDetail(params).OK
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type RentalOrder struct {
|
|||||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_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"`
|
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"`
|
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||||
SettledAt *time.Time `json:"settled_at"`
|
SettledAt *time.Time `json:"settled_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type PaymentOrder struct {
|
|||||||
PayWay string `gorm:"size:16;not null;default:''" json:"pay_way"`
|
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"`
|
JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:''" json:"jspay_flag"`
|
||||||
AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"`
|
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"`
|
Status string `gorm:"size:32;not null;default:'created';index" json:"status"`
|
||||||
TDCode string `gorm:"size:512;not null;default:''" json:"td_code"`
|
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"`
|
JSPayURL string `gorm:"column:jspay_url;size:512;not null;default:''" json:"jspay_url"`
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ type AdminActionRequest struct {
|
|||||||
|
|
||||||
type AuditMeta = auditlog.Meta
|
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 {
|
type HandoffRecordDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) {
|
|||||||
h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true})
|
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) {
|
func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -394,6 +420,8 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "不能租用自己发布的账号")
|
response.BadRequest(c, "不能租用自己发布的账号")
|
||||||
case errors.Is(err, ErrInsufficientBalance):
|
case errors.Is(err, ErrInsufficientBalance):
|
||||||
response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值")
|
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):
|
case errors.Is(err, ErrOrderCannotPay):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_pay", "当前订单不能支付")
|
response.Error(c, http.StatusConflict, "order_cannot_pay", "当前订单不能支付")
|
||||||
case errors.Is(err, ErrOrderCannotCancel):
|
case errors.Is(err, ErrOrderCannotCancel):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,9 +21,20 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"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 {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
chatRepo *chat.Repository
|
chatRepo *chat.Repository
|
||||||
|
refundFunc RefundFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPendingPaymentTimeoutMinutes = 15
|
const defaultPendingPaymentTimeoutMinutes = 15
|
||||||
@@ -35,6 +47,10 @@ func (r *Repository) SetChatRepo(cr *chat.Repository) {
|
|||||||
r.chatRepo = cr
|
r.chatRepo = cr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SetRefundFunc(fn RefundFunc) {
|
||||||
|
r.refundFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
type orderPricing struct {
|
type orderPricing struct {
|
||||||
RentAmount float64
|
RentAmount float64
|
||||||
OwnerRentAmount float64
|
OwnerRentAmount float64
|
||||||
@@ -201,112 +217,12 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
|
|||||||
return r.FindForUser(renterID, createdID)
|
return r.FindForUser(renterID, createdID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pay 保留旧接口兼容,但真实付款必须走 payment 模块的渠道支付入口。
|
||||||
func (r *Repository) Pay(userID uint64, orderID uint64) error {
|
func (r *Repository) Pay(userID uint64, orderID uint64) error {
|
||||||
var newConvID uint64
|
return ErrChannelPaymentRequired
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmPaidFromChannel 在乐刷确认支付后推进订单状态;租客资金不进入站内钱包。
|
||||||
func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string) error {
|
func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string) error {
|
||||||
var newConvID uint64
|
var newConvID uint64
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -333,21 +249,8 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 租客已通过外部渠道付款,这里不写租客钱包流水。
|
||||||
orderID := order.ID
|
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.Status = "pending_handoff"
|
||||||
order.HandoffStatus = "pending_owner"
|
order.HandoffStatus = "pending_owner"
|
||||||
listing.Status = "rented"
|
listing.Status = "rented"
|
||||||
@@ -370,7 +273,7 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
|
|||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单支付成功",
|
Title: "订单支付成功",
|
||||||
Content: "支付金额已冻结,等待号主提交交接说明。",
|
Content: "支付已完成,等待号主提交交接说明。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -395,7 +298,8 @@ func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Cancel(userID uint64, orderID uint64) error {
|
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
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("id = ? AND renter_id = ?", orderID, userID).
|
Where("id = ? AND renter_id = ?", orderID, userID).
|
||||||
@@ -419,31 +323,12 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
|
|||||||
order.HandoffStatus = "cancelled"
|
order.HandoffStatus = "cancelled"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if beforeStatus == "pending_handoff" {
|
if beforeStatus == "pending_handoff" {
|
||||||
total := order.RentAmount + order.DepositAmount
|
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
|
||||||
if err := wallet.AppendEntries(tx,
|
action, err := r.prepareRefund(&order, totalCent, "cancel_refund", "取消订单原路退款")
|
||||||
wallet.Entry{
|
if err != nil {
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
refund = action
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
@@ -458,7 +343,7 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
|
|||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单取消成功",
|
Title: "订单取消成功",
|
||||||
Content: "订单已取消,相关金额已释放。",
|
Content: "订单已取消,退款将原路退回您的支付账户。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -476,6 +361,11 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
|
|||||||
}
|
}
|
||||||
return tx.Save(&account).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) {
|
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 {
|
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
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -677,8 +568,15 @@ func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
|
|||||||
}
|
}
|
||||||
checkout.Status = "accepted"
|
checkout.Status = "accepted"
|
||||||
checkout.OwnerAdjustedAt = &now
|
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) {
|
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 {
|
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
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -777,8 +676,15 @@ func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
checkout.Status = "accepted"
|
checkout.Status = "accepted"
|
||||||
checkout.RenterConfirmedAt = &now
|
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) {
|
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 {
|
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)
|
order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -862,38 +769,19 @@ func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionR
|
|||||||
listing.InTransaction = false
|
listing.InTransaction = false
|
||||||
account.Status = "offline"
|
account.Status = "offline"
|
||||||
if beforeOrderStatus != "pending_payment" {
|
if beforeOrderStatus != "pending_payment" {
|
||||||
total := order.RentAmount + order.DepositAmount
|
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
|
||||||
if err := wallet.AppendEntries(tx,
|
action, err := r.prepareRefund(order, totalCent, "admin_close_refund", "客服关闭订单原路退款")
|
||||||
wallet.Entry{
|
if err != nil {
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
refund = action
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order_admin",
|
Type: "order_admin",
|
||||||
Title: "订单已由客服关闭",
|
Title: "订单已由客服关闭",
|
||||||
Content: "客服已关闭订单,模拟冻结金额已释放。原因:" + req.Reason,
|
Content: "客服已关闭订单,退款将原路退回您的支付账户。原因:" + req.Reason,
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &order.ID,
|
BizID: &order.ID,
|
||||||
},
|
},
|
||||||
@@ -935,6 +823,11 @@ func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionR
|
|||||||
}
|
}
|
||||||
return tx.Save(account).Error
|
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 {
|
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) {
|
func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
var row orderRow
|
var row orderRow
|
||||||
if err := r.baseQuery().
|
if err := r.baseQuery().
|
||||||
@@ -1014,14 +958,14 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
|
|||||||
return &dto, nil
|
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
|
var listing model.RentalListing
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
var account model.GameAccount
|
var account model.GameAccount
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
order.Status = "completed"
|
order.Status = "completed"
|
||||||
@@ -1034,20 +978,11 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
account.Status = "published"
|
account.Status = "published"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
settlement := buildCheckoutSettlement(*order, checkout)
|
settlement := buildCheckoutSettlement(*order, checkout)
|
||||||
entries := []wallet.Entry{
|
|
||||||
wallet.Entry{
|
// 卖家收入进入站内钱包;租客资金不进入站内钱包。
|
||||||
UserID: order.RenterID,
|
var ownerEntries []wallet.Entry
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "out",
|
|
||||||
Amount: order.RentAmount + order.DepositAmount,
|
|
||||||
BalanceType: "frozen",
|
|
||||||
BizType: "order_settle",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单结账释放冻结金额",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if settlement.OwnerRentIncome > 0 {
|
if settlement.OwnerRentIncome > 0 {
|
||||||
entries = append(entries, wallet.Entry{
|
ownerEntries = append(ownerEntries, wallet.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
OrderID: &orderID,
|
OrderID: &orderID,
|
||||||
Direction: "in",
|
Direction: "in",
|
||||||
@@ -1059,7 +994,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if settlement.DepositCompensation > 0 {
|
if settlement.DepositCompensation > 0 {
|
||||||
entries = append(entries, wallet.Entry{
|
ownerEntries = append(ownerEntries, wallet.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
OrderID: &orderID,
|
OrderID: &orderID,
|
||||||
Direction: "in",
|
Direction: "in",
|
||||||
@@ -1070,33 +1005,23 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
Remark: "订单结账押金赔付",
|
Remark: "订单结账押金赔付",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if settlement.RentRefund > 0 {
|
if len(ownerEntries) > 0 {
|
||||||
entries = append(entries, wallet.Entry{
|
if err := wallet.AppendEntries(tx, ownerEntries...); err != nil {
|
||||||
UserID: order.RenterID,
|
return nil, err
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "in",
|
|
||||||
Amount: settlement.RentRefund,
|
|
||||||
BalanceType: "available",
|
|
||||||
BizType: "rent_refund",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单结账退回未使用租金",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
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.RentAmount = settlement.ActualRentAmount
|
||||||
checkout.OwnerRentAmount = settlement.OwnerRentIncome
|
checkout.OwnerRentAmount = settlement.OwnerRentIncome
|
||||||
checkout.PlatformFee = settlement.PlatformFee
|
checkout.PlatformFee = settlement.PlatformFee
|
||||||
@@ -1120,18 +1045,48 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := tx.Save(order).Error; err != nil {
|
if err := tx.Save(order).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := tx.Save(checkout).Error; err != nil {
|
if err := tx.Save(checkout).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := tx.Save(&listing).Error; err != nil {
|
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) {
|
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ var (
|
|||||||
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
|
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
|
||||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
ErrOrderCannotPay = errors.New("order cannot pay")
|
ErrOrderCannotPay = errors.New("order cannot pay")
|
||||||
|
ErrChannelPaymentRequired = errors.New("channel payment required")
|
||||||
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
||||||
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
@@ -181,6 +182,26 @@ func (s *Service) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminAct
|
|||||||
return s.repo.AdminMarkAbnormal(adminID, orderID, req, meta)
|
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) {
|
func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ type PaymentDTO struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type NotifyResult struct {
|
||||||
OK bool
|
OK bool
|
||||||
Message string
|
Message string
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ func (h *Handler) WalletRechargeQuery(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) LeshuaNotify(c *gin.Context) {
|
||||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,6 +170,10 @@ func writePaymentError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusBadGateway, "payment_unavailable", "支付渠道暂不可用")
|
response.Error(c, http.StatusBadGateway, "payment_unavailable", "支付渠道暂不可用")
|
||||||
case errors.Is(err, ErrPaymentCannotStart), errors.Is(err, order.ErrOrderCannotPay):
|
case errors.Is(err, ErrPaymentCannotStart), errors.Is(err, order.ErrOrderCannotPay):
|
||||||
response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付")
|
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):
|
case errors.Is(err, ErrPaymentVerifyFailed):
|
||||||
response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败")
|
response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败")
|
||||||
case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err):
|
case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err):
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ const (
|
|||||||
channelSourceMock = "mock"
|
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 {
|
func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
|
||||||
provider := cfg.Provider
|
provider := cfg.Provider
|
||||||
if 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) {
|
func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
|
||||||
var payment model.PaymentOrder
|
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 {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil, ErrPaymentNotFound
|
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)
|
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"]
|
thirdOrderID := params["third_order_id"]
|
||||||
if thirdOrderID == "" {
|
if thirdOrderID == "" {
|
||||||
return nil, ErrPaymentNotFound
|
return nil, ErrPaymentNotFound
|
||||||
@@ -285,6 +298,278 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload str
|
|||||||
return &NotifyResult{OK: true, Message: "000000"}, nil
|
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) {
|
func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaymentRequest) (*model.PaymentOrder, *model.RentalOrder, error) {
|
||||||
var paymentID uint64
|
var paymentID uint64
|
||||||
var orderRow model.RentalOrder
|
var orderRow model.RentalOrder
|
||||||
@@ -304,7 +589,7 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym
|
|||||||
}
|
}
|
||||||
var existing model.PaymentOrder
|
var existing model.PaymentOrder
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
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").
|
Order("id DESC").
|
||||||
First(&existing).Error
|
First(&existing).Error
|
||||||
if err == nil {
|
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"),
|
PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"),
|
||||||
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
|
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
|
||||||
AmountCent: amountCent,
|
AmountCent: amountCent,
|
||||||
|
BizType: "order_pay",
|
||||||
Status: "created",
|
Status: "created",
|
||||||
}
|
}
|
||||||
if r.isMockMode {
|
if r.isMockMode {
|
||||||
@@ -382,6 +668,7 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64
|
|||||||
PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"),
|
PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"),
|
||||||
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
|
JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"),
|
||||||
AmountCent: amountCent,
|
AmountCent: amountCent,
|
||||||
|
BizType: "wallet_recharge",
|
||||||
Status: "created",
|
Status: "created",
|
||||||
}
|
}
|
||||||
if r.isMockMode {
|
if r.isMockMode {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ var (
|
|||||||
ErrPaymentCannotStart = errors.New("payment cannot start")
|
ErrPaymentCannotStart = errors.New("payment cannot start")
|
||||||
ErrPaymentVerifyFailed = errors.New("payment verify failed")
|
ErrPaymentVerifyFailed = errors.New("payment verify failed")
|
||||||
ErrPaymentNotFound = errors.New("payment not found")
|
ErrPaymentNotFound = errors.New("payment not found")
|
||||||
|
ErrRefundCannotStart = errors.New("refund cannot start")
|
||||||
|
ErrWalletRechargeDisabled = errors.New("wallet recharge disabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
const MinWalletRechargeAmount = 0.01
|
const MinWalletRechargeAmount = 0.01
|
||||||
@@ -44,10 +46,7 @@ func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRe
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if userID == 0 || req.Amount < MinWalletRechargeAmount {
|
return nil, ErrWalletRechargeDisabled
|
||||||
return nil, ErrPaymentCannotStart
|
|
||||||
}
|
|
||||||
return s.repo.StartWalletRecharge(userID, req, clientIP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type RechargeRequest struct {
|
|||||||
Amount float64 `json:"amount" binding:"required"`
|
Amount float64 `json:"amount" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WithdrawRequest struct {
|
||||||
|
Amount float64 `json:"amount" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type LedgerDTO struct {
|
type LedgerDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
LedgerNo string `json:"ledger_no"`
|
LedgerNo string `json:"ledger_no"`
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ func (h *Handler) Recharge(c *gin.Context) {
|
|||||||
response.OK(c, account)
|
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) {
|
func (h *Handler) AdminLedger(c *gin.Context) {
|
||||||
query, ok := parseAdminLedgerQuery(c)
|
query, ok := parseAdminLedgerQuery(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -136,6 +155,10 @@ func writeWalletError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "充值金额不正确")
|
response.BadRequest(c, "充值金额不正确")
|
||||||
case errors.Is(err, ErrInsufficientBalance):
|
case errors.Is(err, ErrInsufficientBalance):
|
||||||
response.Error(c, 409, "insufficient_balance", "钱包余额不足")
|
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:
|
default:
|
||||||
response.ServiceUnavailable(c, "钱包服务暂时不可用")
|
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) {
|
func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||||
db := r.db.Table("wallet_ledger AS wl").
|
db := r.db.Table("wallet_ledger AS wl").
|
||||||
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
|
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ var (
|
|||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidAmount = errors.New("invalid amount")
|
ErrInvalidAmount = errors.New("invalid amount")
|
||||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
|
ErrFeaturePending = errors.New("feature pending")
|
||||||
|
ErrRechargeDisabled = errors.New("wallet recharge disabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
const MinRechargeAmount = 0.01
|
const MinRechargeAmount = 0.01
|
||||||
@@ -36,10 +38,14 @@ func (s *Service) Recharge(userID uint64, req RechargeRequest) (*AccountDTO, err
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if req.Amount < MinRechargeAmount {
|
return nil, ErrRechargeDisabled
|
||||||
return nil, ErrInvalidAmount
|
|
||||||
}
|
}
|
||||||
return s.repo.Recharge(userID, req.Amount)
|
|
||||||
|
func (s *Service) Withdraw(userID uint64, req WithdrawRequest) (*AccountDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return nil, ErrFeaturePending
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
func (s *Service) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
paymentService := payment.NewService(paymentRepo)
|
paymentService := payment.NewService(paymentRepo)
|
||||||
paymentHandler := payment.NewHandler(paymentService)
|
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
|
var notificationRepo *notification.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
notificationRepo = notification.NewRepository(deps.DB)
|
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", orderHandler.Detail)
|
||||||
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
||||||
orderRoutes.POST("/:id/pay", orderHandler.Pay)
|
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/cancel", orderHandler.Cancel)
|
||||||
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
||||||
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
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/counter", orderHandler.CounterCheckout)
|
||||||
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
||||||
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
||||||
|
orderRoutes.GET("/:id/refund-status", paymentHandler.QueryRefundStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
disputeRoutes := api.Group("/disputes", requireAuth)
|
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", walletHandler.Recharge)
|
||||||
walletRoutes.POST("/recharge/pay", paymentHandler.WalletRecharge)
|
walletRoutes.POST("/recharge/pay", paymentHandler.WalletRecharge)
|
||||||
walletRoutes.POST("/recharge/pay/:id/query", paymentHandler.WalletRechargeQuery)
|
walletRoutes.POST("/recharge/pay/:id/query", paymentHandler.WalletRechargeQuery)
|
||||||
|
walletRoutes.POST("/withdraw", walletHandler.Withdraw)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileRoutes := api.Group("/files", requireAuth)
|
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.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
||||||
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
|
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/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", requirePerm("listing:view"), listingHandler.ListAdmin)
|
||||||
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
||||||
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ CREATE TABLE IF NOT EXISTS rental_orders (
|
|||||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_payment',
|
status VARCHAR(32) NOT NULL DEFAULT 'pending_payment',
|
||||||
handoff_status VARCHAR(32) NOT NULL DEFAULT 'none',
|
handoff_status VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||||
settlement_status VARCHAR(32) NOT NULL DEFAULT 'unsettled',
|
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,
|
owner_settled_at DATETIME NULL,
|
||||||
settled_at DATETIME NULL,
|
settled_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
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_renter_status (renter_id, status),
|
||||||
KEY idx_rental_orders_owner_status (owner_id, status),
|
KEY idx_rental_orders_owner_status (owner_id, status),
|
||||||
KEY idx_rental_orders_listing_id (listing_id),
|
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)
|
KEY idx_rental_orders_rented_at (status, rented_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 '',
|
pay_way VARCHAR(16) NOT NULL DEFAULT '',
|
||||||
jspay_flag VARCHAR(8) NOT NULL DEFAULT '',
|
jspay_flag VARCHAR(8) NOT NULL DEFAULT '',
|
||||||
amount_cent BIGINT NOT NULL DEFAULT 0,
|
amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||||
|
biz_type VARCHAR(32) NOT NULL DEFAULT 'order_pay',
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'created',
|
status VARCHAR(32) NOT NULL DEFAULT 'created',
|
||||||
td_code VARCHAR(512) NOT NULL DEFAULT '',
|
td_code VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
jspay_url 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_order_no (order_no),
|
||||||
KEY idx_payment_orders_user_id (user_id),
|
KEY idx_payment_orders_user_id (user_id),
|
||||||
KEY idx_payment_orders_provider_order_id (provider_order_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)
|
KEY idx_payment_orders_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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';
|
||||||
@@ -174,6 +174,40 @@ export async function adminMarkOrderAbnormal(id: number, reason: string) {
|
|||||||
return data.data
|
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<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRefundStatus(id: number) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(`/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<ApiResponse<PaymentOrder>>(`/orders/${orderId}/start-payment`, req || {})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOrderPayment(orderId: number) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaymentOrder>>(`/orders/${orderId}/query-payment`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function confirmReceive(id: number) {
|
export async function confirmReceive(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ChatDotRound } from '@element-plus/icons-vue'
|
import { ChatDotRound, Loading } from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
import { fetchOrderChat } from '@/api/chats'
|
import { fetchOrderChat } from '@/api/chats'
|
||||||
import { createDispute } from '@/api/disputes'
|
import { createDispute } from '@/api/disputes'
|
||||||
@@ -15,11 +16,13 @@ import {
|
|||||||
counterCheckout,
|
counterCheckout,
|
||||||
fetchHandoffRecords,
|
fetchHandoffRecords,
|
||||||
fetchOrder,
|
fetchOrder,
|
||||||
payOrder,
|
queryOrderPayment,
|
||||||
|
startOrderPayment,
|
||||||
submitCheckout,
|
submitCheckout,
|
||||||
submitHandoff,
|
submitHandoff,
|
||||||
type HandoffRecord,
|
type HandoffRecord,
|
||||||
type Order,
|
type Order,
|
||||||
|
type PaymentOrder,
|
||||||
} from '@/api/orders'
|
} from '@/api/orders'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
||||||
@@ -30,7 +33,7 @@ const router = useRouter()
|
|||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const cancelling = ref(false)
|
const cancelling = ref(false)
|
||||||
const paying = ref(false)
|
const startingPayment = ref(false)
|
||||||
const handoffing = ref(false)
|
const handoffing = ref(false)
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const returning = ref(false)
|
const returning = ref(false)
|
||||||
@@ -43,6 +46,12 @@ const uploadingEvidence = ref(false)
|
|||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
const handoffContent = ref('')
|
const handoffContent = ref('')
|
||||||
|
const paymentDialogVisible = ref(false)
|
||||||
|
const activePayment = ref<PaymentOrder | null>(null)
|
||||||
|
const paymentQRCodeURL = ref('')
|
||||||
|
const qrGenerating = ref(false)
|
||||||
|
const checkingPayment = ref(false)
|
||||||
|
let paymentPollingTimer: number | undefined
|
||||||
const checkoutForm = ref({
|
const checkoutForm = ref({
|
||||||
content: '',
|
content: '',
|
||||||
consumable_amount: 0,
|
consumable_amount: 0,
|
||||||
@@ -64,6 +73,7 @@ const disputeType = ref('cannot_login')
|
|||||||
const disputeDescription = ref('')
|
const disputeDescription = ref('')
|
||||||
const disputeEvidenceText = ref('')
|
const disputeEvidenceText = ref('')
|
||||||
const openingChat = ref(false)
|
const openingChat = ref(false)
|
||||||
|
let autoPayHandled = false
|
||||||
|
|
||||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||||
@@ -97,6 +107,7 @@ const remainingHafCoinM = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadOrder)
|
onMounted(loadOrder)
|
||||||
|
onBeforeUnmount(stopPaymentPolling)
|
||||||
|
|
||||||
async function loadOrder() {
|
async function loadOrder() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -105,6 +116,16 @@ async function loadOrder() {
|
|||||||
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
||||||
hydrateResourceUsage()
|
hydrateResourceUsage()
|
||||||
hydrateCounterForm()
|
hydrateCounterForm()
|
||||||
|
if (
|
||||||
|
!autoPayHandled &&
|
||||||
|
route.query.pay === '1' &&
|
||||||
|
order.value?.status === 'pending_payment' &&
|
||||||
|
order.value?.renter_id === session.userId
|
||||||
|
) {
|
||||||
|
autoPayHandled = true
|
||||||
|
void router.replace({ path: route.path })
|
||||||
|
void handlePay()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -126,18 +147,106 @@ async function handleCancel() {
|
|||||||
|
|
||||||
async function handlePay() {
|
async function handlePay() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
paying.value = true
|
startingPayment.value = true
|
||||||
try {
|
try {
|
||||||
await payOrder(order.value.id)
|
const payment = await startOrderPayment(order.value.id)
|
||||||
|
if (payment.paid) {
|
||||||
ElMessage.success('支付成功,等待号主交接')
|
ElMessage.success('支付成功,等待号主交接')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
|
} else {
|
||||||
|
showPaymentDialog(payment)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '支付失败'))
|
ElMessage.error(readError(error, '支付失败'))
|
||||||
} finally {
|
} finally {
|
||||||
paying.value = false
|
startingPayment.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showPaymentDialog(payment: PaymentOrder) {
|
||||||
|
activePayment.value = payment
|
||||||
|
paymentDialogVisible.value = true
|
||||||
|
void renderPaymentQRCode(payment)
|
||||||
|
startPaymentPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentPayURL(payment = activePayment.value) {
|
||||||
|
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPaymentQRCode(payment = activePayment.value) {
|
||||||
|
const payURL = paymentPayURL(payment)
|
||||||
|
paymentQRCodeURL.value = ''
|
||||||
|
if (!payURL) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qrGenerating.value = true
|
||||||
|
try {
|
||||||
|
paymentQRCodeURL.value = await QRCode.toDataURL(payURL, {
|
||||||
|
width: 240,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: {
|
||||||
|
dark: '#111827',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('二维码生成失败')
|
||||||
|
} finally {
|
||||||
|
qrGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPaymentPolling() {
|
||||||
|
stopPaymentPolling()
|
||||||
|
paymentPollingTimer = window.setInterval(() => {
|
||||||
|
void refreshPaymentStatus(true)
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPaymentPolling() {
|
||||||
|
if (paymentPollingTimer !== undefined) {
|
||||||
|
window.clearInterval(paymentPollingTimer)
|
||||||
|
paymentPollingTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPaymentStatus(silent = false) {
|
||||||
|
if (!order.value || checkingPayment.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkingPayment.value = true
|
||||||
|
const currentPayURL = paymentPayURL()
|
||||||
|
try {
|
||||||
|
const payment = await queryOrderPayment(order.value.id)
|
||||||
|
if (payment.paid) {
|
||||||
|
stopPaymentPolling()
|
||||||
|
ElMessage.success('支付成功')
|
||||||
|
paymentDialogVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} else {
|
||||||
|
activePayment.value = payment
|
||||||
|
if (paymentPayURL(payment) !== currentPayURL) {
|
||||||
|
await renderPaymentQRCode(payment)
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.info('支付未完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.error(readError(error, '刷新支付状态失败'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingPayment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshPayment() {
|
||||||
|
await refreshPaymentStatus(false)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitHandoff() {
|
async function handleSubmitHandoff() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
handoffing.value = true
|
handoffing.value = true
|
||||||
@@ -511,7 +620,7 @@ function linesToList(value: string) {
|
|||||||
<p>开始:{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
|
<p>开始:{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
|
||||||
<p>预计截止:{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
|
<p>预计截止:{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
|
||||||
<p v-if="order.status === 'pending_payment'">订单待支付,支付后账号进入交接流程。</p>
|
<p v-if="order.status === 'pending_payment'">订单待支付,支付后账号进入交接流程。</p>
|
||||||
<el-button v-if="order.status === 'pending_payment' && isRenter" type="primary" :loading="paying" @click="handlePay">
|
<el-button v-if="order.status === 'pending_payment' && isRenter" type="primary" :loading="startingPayment" @click="handlePay">
|
||||||
支付订单
|
支付订单
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="['pending_payment', 'pending_handoff'].includes(order.status)" type="danger" :loading="cancelling" @click="handleCancel">
|
<el-button v-if="['pending_payment', 'pending_handoff'].includes(order.status)" type="danger" :loading="cancelling" @click="handleCancel">
|
||||||
@@ -662,6 +771,39 @@ function linesToList(value: string) {
|
|||||||
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
|
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="paymentDialogVisible"
|
||||||
|
title="订单支付"
|
||||||
|
width="520px"
|
||||||
|
append-to-body
|
||||||
|
:z-index="4000"
|
||||||
|
class="order-pay-dialog"
|
||||||
|
@closed="stopPaymentPolling"
|
||||||
|
>
|
||||||
|
<div v-if="activePayment" class="pay-dialog-body">
|
||||||
|
<div class="pay-summary">
|
||||||
|
<span>支付金额</span>
|
||||||
|
<strong>¥{{ (activePayment.amount_cent / 100).toFixed(2) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="paymentPayURL()" class="pay-qr-panel">
|
||||||
|
<div class="pay-qr-box">
|
||||||
|
<el-icon v-if="qrGenerating" class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
|
<img v-else-if="paymentQRCodeURL" :src="paymentQRCodeURL" alt="支付二维码" />
|
||||||
|
</div>
|
||||||
|
<div class="pay-scan-copy">
|
||||||
|
<strong>请使用微信或支付宝扫码支付</strong>
|
||||||
|
<span>扫码完成后将自动刷新,也可手动点击下方按钮确认。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="pay-hint">支付单已创建,请完成付款后刷新状态。</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="pay-dialog-footer">
|
||||||
|
<el-button type="primary" :loading="checkingPayment" @click="handleRefreshPayment">我已支付,刷新状态</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -722,4 +864,91 @@ function linesToList(value: string) {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pay-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-summary strong {
|
||||||
|
color: #111a44;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-qr-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-qr-box {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-qr-box img {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-scan-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-scan-copy strong {
|
||||||
|
color: #111a44;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-scan-copy span {
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.order-pay-dialog) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 4001;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pay-qr-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { fetchOrders, payOrder, type Order } from '@/api/orders'
|
import { fetchOrders, type Order } from '@/api/orders'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { orderStatusLabel } from '@/utils/statusLabels'
|
import { orderStatusLabel } from '@/utils/statusLabels'
|
||||||
|
|
||||||
@@ -60,11 +60,9 @@ async function loadOrders() {
|
|||||||
async function handlePay(order: Order) {
|
async function handlePay(order: Order) {
|
||||||
payingOrderId.value = order.id
|
payingOrderId.value = order.id
|
||||||
try {
|
try {
|
||||||
await payOrder(order.id)
|
await router.push(`/orders/${order.id}?pay=1`)
|
||||||
ElMessage.success('支付成功,等待号主交接')
|
|
||||||
await loadOrders()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '支付失败'))
|
ElMessage.error(readError(error, '打开支付失败'))
|
||||||
} finally {
|
} finally {
|
||||||
payingOrderId.value = null
|
payingOrderId.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { CircleCheck, CreditCard, Loading, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue'
|
import { CircleCheck, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue'
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchWalletBalance,
|
fetchWalletBalance,
|
||||||
fetchWalletLedger,
|
fetchWalletLedger,
|
||||||
queryWalletRechargePayment,
|
|
||||||
startWalletRechargePayment,
|
|
||||||
type WalletAccount,
|
type WalletAccount,
|
||||||
type WalletLedger,
|
type WalletLedger,
|
||||||
} from '@/api/wallet'
|
} from '@/api/wallet'
|
||||||
import type { PaymentOrder } from '@/api/orders'
|
|
||||||
import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels'
|
import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
@@ -22,16 +18,6 @@ const ledger = ref<WalletLedger[]>([])
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const currentPageSize = ref(20)
|
const currentPageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const rechargeAmount = ref(100)
|
|
||||||
const recharging = ref(false)
|
|
||||||
const rechargeDialogVisible = ref(false)
|
|
||||||
const activeRechargePayment = ref<PaymentOrder | null>(null)
|
|
||||||
const rechargeQRCodeURL = ref('')
|
|
||||||
const qrGenerating = ref(false)
|
|
||||||
const checkingRecharge = ref(false)
|
|
||||||
let rechargePollingTimer: number | undefined
|
|
||||||
|
|
||||||
const quickRechargeAmounts = [50, 100, 200, 500]
|
|
||||||
|
|
||||||
const walletMetrics = computed(() => {
|
const walletMetrics = computed(() => {
|
||||||
if (!account.value) {
|
if (!account.value) {
|
||||||
@@ -41,14 +27,14 @@ const walletMetrics = computed(() => {
|
|||||||
{
|
{
|
||||||
label: '可用余额',
|
label: '可用余额',
|
||||||
value: formatMoney(account.value.available_balance),
|
value: formatMoney(account.value.available_balance),
|
||||||
hint: '可用于支付租号订单',
|
hint: '卖家结算收入累计到此账户',
|
||||||
icon: WalletIcon,
|
icon: WalletIcon,
|
||||||
tone: 'available',
|
tone: 'available',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '冻结余额',
|
label: '冻结余额',
|
||||||
value: formatMoney(account.value.frozen_balance),
|
value: formatMoney(account.value.frozen_balance),
|
||||||
hint: '订单押金与待结算金额',
|
hint: '当前暂无冻结资金使用',
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
tone: 'frozen',
|
tone: 'frozen',
|
||||||
},
|
},
|
||||||
@@ -63,7 +49,6 @@ const walletMetrics = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadWallet)
|
onMounted(loadWallet)
|
||||||
onBeforeUnmount(stopRechargePolling)
|
|
||||||
|
|
||||||
async function loadWallet() {
|
async function loadWallet() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -86,128 +71,14 @@ function loadLedgerPage() {
|
|||||||
loadWallet()
|
loadWallet()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRecharge() {
|
|
||||||
recharging.value = true
|
|
||||||
try {
|
|
||||||
const payment = await startWalletRechargePayment(rechargeAmount.value)
|
|
||||||
if (payment.paid) {
|
|
||||||
ElMessage.success('充值成功')
|
|
||||||
} else {
|
|
||||||
showRechargePaymentDialog(payment)
|
|
||||||
}
|
|
||||||
await loadWallet()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '充值失败'))
|
|
||||||
} finally {
|
|
||||||
recharging.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWithdraw() {
|
function handleWithdraw() {
|
||||||
ElMessage.info('提现功能待实现')
|
ElMessage.info('提现功能待实现')
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRechargePaymentDialog(payment: PaymentOrder) {
|
|
||||||
activeRechargePayment.value = payment
|
|
||||||
rechargeDialogVisible.value = true
|
|
||||||
void renderRechargeQRCode(payment)
|
|
||||||
startRechargePolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
function rechargePayURL(payment = activeRechargePayment.value) {
|
|
||||||
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRechargeQRCode(payment = activeRechargePayment.value) {
|
|
||||||
const payURL = rechargePayURL(payment)
|
|
||||||
rechargeQRCodeURL.value = ''
|
|
||||||
if (!payURL) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qrGenerating.value = true
|
|
||||||
try {
|
|
||||||
rechargeQRCodeURL.value = await QRCode.toDataURL(payURL, {
|
|
||||||
width: 240,
|
|
||||||
margin: 1,
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
color: {
|
|
||||||
dark: '#111827',
|
|
||||||
light: '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('二维码生成失败,请复制链接后扫码支付')
|
|
||||||
} finally {
|
|
||||||
qrGenerating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRechargePolling() {
|
|
||||||
stopRechargePolling()
|
|
||||||
rechargePollingTimer = window.setInterval(() => {
|
|
||||||
void refreshRechargePayment(true)
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRechargePolling() {
|
|
||||||
if (rechargePollingTimer !== undefined) {
|
|
||||||
window.clearInterval(rechargePollingTimer)
|
|
||||||
rechargePollingTimer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRechargePayment(silent = false) {
|
|
||||||
const paymentID = activeRechargePayment.value?.id
|
|
||||||
if (!paymentID || checkingRecharge.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
checkingRecharge.value = true
|
|
||||||
const currentPayURL = rechargePayURL()
|
|
||||||
try {
|
|
||||||
const payment = await queryWalletRechargePayment(paymentID)
|
|
||||||
if (payment.paid) {
|
|
||||||
stopRechargePolling()
|
|
||||||
ElMessage.success('充值成功')
|
|
||||||
rechargeDialogVisible.value = false
|
|
||||||
await loadWallet()
|
|
||||||
} else {
|
|
||||||
activeRechargePayment.value = payment
|
|
||||||
if (rechargePayURL(payment) !== currentPayURL) {
|
|
||||||
await renderRechargeQRCode(payment)
|
|
||||||
}
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.info('支付未完成')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.error(readError(error, '刷新支付状态失败'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
checkingRecharge.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefreshRechargePayment() {
|
|
||||||
await refreshRechargePayment(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
|
||||||
if (typeof error === 'object' && error && 'response' in error) {
|
|
||||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
|
||||||
return response?.data?.message || fallback
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(value: number) {
|
function formatMoney(value: number) {
|
||||||
return `¥${Number(value || 0).toFixed(2)}`
|
return `¥${Number(value || 0).toFixed(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickQuickRecharge(amount: number) {
|
|
||||||
rechargeAmount.value = amount
|
|
||||||
}
|
|
||||||
|
|
||||||
function walletBizTypeLabel(type: string) {
|
function walletBizTypeLabel(type: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
dev_recharge: '测试充值',
|
dev_recharge: '测试充值',
|
||||||
@@ -227,6 +98,10 @@ function walletBizTypeLabel(type: string) {
|
|||||||
arbitration_release_frozen: '仲裁解冻',
|
arbitration_release_frozen: '仲裁解冻',
|
||||||
arbitration_renter_refund: '仲裁退款',
|
arbitration_renter_refund: '仲裁退款',
|
||||||
arbitration_owner_income: '仲裁收入',
|
arbitration_owner_income: '仲裁收入',
|
||||||
|
cancel_refund: '取消退款',
|
||||||
|
checkout_refund: '结账退款',
|
||||||
|
channel_deposit_refund: '押金退还',
|
||||||
|
withdraw_apply: '申请提现',
|
||||||
}
|
}
|
||||||
return map[type] || type || '-'
|
return map[type] || type || '-'
|
||||||
}
|
}
|
||||||
@@ -254,12 +129,15 @@ function amountPrefix(direction: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">我的钱包</p>
|
<p class="eyebrow">我的钱包</p>
|
||||||
<h1>资金账户</h1>
|
<h1>资金账户</h1>
|
||||||
<p>查看余额、充值和每一笔资金变化,订单押金与租金都会记录在这里。</p>
|
<p>查看卖家结算收入、可提现余额和每一笔资金变化。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="wallet-hero-action">
|
<div class="wallet-hero-action">
|
||||||
<span>当前可用</span>
|
<span>当前可用</span>
|
||||||
<strong>{{ account ? formatMoney(account.available_balance) : '¥0.00' }}</strong>
|
<strong>{{ account ? formatMoney(account.available_balance) : '¥0.00' }}</strong>
|
||||||
<el-button class="withdraw-button" :icon="Money" @click="handleWithdraw">申请提现</el-button>
|
<el-button class="withdraw-button" :icon="Money" disabled @click="handleWithdraw">
|
||||||
|
申请提现
|
||||||
|
<el-tag size="small" type="info" effect="plain" class="withdraw-tag">待开发</el-tag>
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,39 +155,6 @@ function amountPrefix(direction: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wallet-workspace">
|
<div class="wallet-workspace">
|
||||||
<section class="recharge-panel">
|
|
||||||
<div class="panel-title">
|
|
||||||
<div class="panel-title-icon">
|
|
||||||
<el-icon><CreditCard /></el-icon>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>钱包充值</h2>
|
|
||||||
<p>充值成功后会自动入账到可用余额。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="quick-amounts">
|
|
||||||
<button
|
|
||||||
v-for="amount in quickRechargeAmounts"
|
|
||||||
:key="amount"
|
|
||||||
type="button"
|
|
||||||
:class="{ active: rechargeAmount === amount }"
|
|
||||||
@click="pickQuickRecharge(amount)"
|
|
||||||
>
|
|
||||||
{{ formatMoney(amount) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="recharge-action-row">
|
|
||||||
<el-input-number
|
|
||||||
v-model="rechargeAmount"
|
|
||||||
:min="0.01"
|
|
||||||
:step="0.01"
|
|
||||||
:precision="2"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" :icon="CreditCard" :loading="recharging" @click="handleRecharge">发起充值</el-button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ledger-summary-card">
|
<section class="ledger-summary-card">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<div class="panel-title-icon is-blue">
|
<div class="panel-title-icon is-blue">
|
||||||
@@ -370,38 +215,6 @@ function amountPrefix(direction: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
v-model="rechargeDialogVisible"
|
|
||||||
title="支付充值"
|
|
||||||
width="520px"
|
|
||||||
append-to-body
|
|
||||||
:z-index="4000"
|
|
||||||
class="wallet-pay-dialog"
|
|
||||||
@closed="stopRechargePolling"
|
|
||||||
>
|
|
||||||
<div v-if="activeRechargePayment" class="pay-dialog-body">
|
|
||||||
<div class="pay-summary">
|
|
||||||
<span>充值金额</span>
|
|
||||||
<strong>{{ formatMoney(activeRechargePayment.amount_cent / 100) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="rechargePayURL()" class="pay-qr-panel">
|
|
||||||
<div class="pay-qr-box">
|
|
||||||
<el-icon v-if="qrGenerating" class="is-loading" :size="32"><Loading /></el-icon>
|
|
||||||
<img v-else-if="rechargeQRCodeURL" :src="rechargeQRCodeURL" alt="充值支付二维码" />
|
|
||||||
</div>
|
|
||||||
<div class="pay-scan-copy">
|
|
||||||
<strong>请使用微信或支付宝扫码支付</strong>
|
|
||||||
<span>不要在电脑浏览器直接打开该链接。扫码完成后将自动刷新,也可手动确认。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="pay-hint">充值支付单已创建,请完成付款后刷新状态。</p>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<div class="pay-dialog-footer">
|
|
||||||
<el-button type="primary" :loading="checkingRecharge" @click="handleRefreshRechargePayment">我已支付,刷新状态</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -457,6 +270,11 @@ function amountPrefix(direction: string) {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.withdraw-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.wallet-metric-grid {
|
.wallet-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -528,7 +346,7 @@ function amountPrefix(direction: string) {
|
|||||||
|
|
||||||
.wallet-workspace {
|
.wallet-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(420px, 1.1fr) minmax(320px, 0.9fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { adminCloseOrder, adminMarkOrderAbnormal, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
|
import { adminCloseOrder, adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order, type RefundStatus } from '@/api/orders'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ const order = ref<Order | null>(null)
|
|||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
const actionType = ref<'close' | 'abnormal' | ''>('')
|
const actionType = ref<'close' | 'abnormal' | ''>('')
|
||||||
const reason = ref('')
|
const reason = ref('')
|
||||||
|
const refundStatus = ref<RefundStatus | null>(null)
|
||||||
|
const refunding = ref(false)
|
||||||
|
|
||||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||||
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
|
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
|
||||||
@@ -26,11 +28,20 @@ async function loadOrder() {
|
|||||||
try {
|
try {
|
||||||
order.value = await fetchAdminOrder(String(route.params.id))
|
order.value = await fetchAdminOrder(String(route.params.id))
|
||||||
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
|
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
|
||||||
|
await loadRefundStatus()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRefundStatus() {
|
||||||
|
try {
|
||||||
|
refundStatus.value = await adminRefundStatus(Number(route.params.id))
|
||||||
|
} catch {
|
||||||
|
refundStatus.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openAction(type: 'close' | 'abnormal') {
|
function openAction(type: 'close' | 'abnormal') {
|
||||||
actionType.value = type
|
actionType.value = type
|
||||||
reason.value = ''
|
reason.value = ''
|
||||||
@@ -79,6 +90,29 @@ function orderEstimatedEndAt() {
|
|||||||
function money(value: unknown) {
|
function money(value: unknown) {
|
||||||
return Math.round(Number(value || 0))
|
return Math.round(Number(value || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRefund() {
|
||||||
|
if (!order.value) return
|
||||||
|
refunding.value = true
|
||||||
|
try {
|
||||||
|
refundStatus.value = await adminRefundOrder(order.value.id)
|
||||||
|
ElMessage.success('退款已发起')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '退款失败'))
|
||||||
|
} finally {
|
||||||
|
refunding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refundStatusLabel(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: '待退款',
|
||||||
|
refunded: '已退款',
|
||||||
|
failed: '退款失败',
|
||||||
|
}
|
||||||
|
return map[status] || status || '未退款'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -95,6 +129,9 @@ function money(value: unknown) {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
||||||
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
|
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="refunding" :disabled="refundStatus?.refund_status === 'refunded'" @click="handleRefund">
|
||||||
|
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,6 +156,11 @@ function money(value: unknown) {
|
|||||||
<span>押金</span>
|
<span>押金</span>
|
||||||
<strong>¥{{ money(order.deposit_amount) }}</strong>
|
<strong>¥{{ money(order.deposit_amount) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="refundStatus" class="metric-card">
|
||||||
|
<span>退款状态</span>
|
||||||
|
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
|
||||||
|
<small v-if="refundStatus.refund_amount_cent > 0">¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order" class="dashboard-panels">
|
<div v-if="order" class="dashboard-panels">
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import {
|
|||||||
counterCheckout,
|
counterCheckout,
|
||||||
fetchHandoffRecords,
|
fetchHandoffRecords,
|
||||||
fetchOrder,
|
fetchOrder,
|
||||||
payOrder,
|
startOrderPayment,
|
||||||
submitCheckout,
|
submitCheckout,
|
||||||
submitHandoff,
|
submitHandoff,
|
||||||
type HandoffRecord,
|
type HandoffRecord,
|
||||||
type Order,
|
type Order,
|
||||||
|
type PaymentOrder,
|
||||||
} from "@/api/orders";
|
} from "@/api/orders";
|
||||||
import { useSessionStore } from "@/stores/session";
|
import { useSessionStore } from "@/stores/session";
|
||||||
import { handoffStatusLabel, orderStatusLabel } from "@/utils/statusLabels";
|
import { handoffStatusLabel, orderStatusLabel } from "@/utils/statusLabels";
|
||||||
@@ -141,9 +142,13 @@ async function handlePay() {
|
|||||||
if (!order.value) return;
|
if (!order.value) return;
|
||||||
paying.value = true;
|
paying.value = true;
|
||||||
try {
|
try {
|
||||||
await payOrder(order.value.id);
|
const payment = await startOrderPayment(order.value.id);
|
||||||
|
if (payment.paid) {
|
||||||
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
|
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
|
||||||
await loadOrder();
|
await loadOrder();
|
||||||
|
} else {
|
||||||
|
openPaymentCashier(payment);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({ message: readError(error, "支付失败"), icon: "cross" });
|
showToast({ message: readError(error, "支付失败"), icon: "cross" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,6 +156,19 @@ async function handlePay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPaymentCashier(payment: PaymentOrder) {
|
||||||
|
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || "";
|
||||||
|
if (payURL && /^https?:\/\//i.test(payURL)) {
|
||||||
|
window.location.href = payURL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog({
|
||||||
|
title: "订单支付",
|
||||||
|
message: payURL || "支付单已创建,请稍后刷新订单状态。",
|
||||||
|
confirmButtonText: "知道了",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitHandoff() {
|
async function handleSubmitHandoff() {
|
||||||
if (!order.value) return;
|
if (!order.value) return;
|
||||||
if (!handoffContent.value.trim()) {
|
if (!handoffContent.value.trim()) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { showConfirmDialog, showToast } from "vant";
|
import { showDialog, showToast } from "vant";
|
||||||
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
||||||
|
|
||||||
import { fetchOrders, payOrder, type Order } from "@/api/orders";
|
import { fetchOrders, startOrderPayment, type Order, type PaymentOrder } from "@/api/orders";
|
||||||
import { useSessionStore } from "@/stores/session";
|
import { useSessionStore } from "@/stores/session";
|
||||||
import { formatDateMinute } from "@/utils/time";
|
import { formatDateMinute } from "@/utils/time";
|
||||||
|
|
||||||
@@ -75,46 +75,35 @@ function goDetail(id: number) {
|
|||||||
async function handlePay(order: Order) {
|
async function handlePay(order: Order) {
|
||||||
payingOrderId.value = order.id;
|
payingOrderId.value = order.id;
|
||||||
try {
|
try {
|
||||||
await payOrder(order.id);
|
const payment = await startOrderPayment(order.id);
|
||||||
|
if (payment.paid) {
|
||||||
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
|
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
|
||||||
await loadOrders();
|
await loadOrders();
|
||||||
} catch (error) {
|
|
||||||
const message = readError(error, "支付失败");
|
|
||||||
if (isInsufficientBalance(error)) {
|
|
||||||
await showRechargeGuide(message);
|
|
||||||
} else {
|
} else {
|
||||||
showToast({ message, icon: "cross" });
|
openPaymentCashier(payment);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, "支付失败"), icon: "cross" });
|
||||||
} finally {
|
} finally {
|
||||||
payingOrderId.value = null;
|
payingOrderId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showRechargeGuide(message: string) {
|
function openPaymentCashier(payment: PaymentOrder) {
|
||||||
await showConfirmDialog({
|
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || "";
|
||||||
title: "余额不足",
|
if (payURL && /^https?:\/\//i.test(payURL)) {
|
||||||
message: `${message}。订单支付只使用钱包余额,请先充值后再支付订单。`,
|
window.location.href = payURL;
|
||||||
confirmButtonText: "去充值",
|
return;
|
||||||
cancelButtonText: "稍后再说",
|
}
|
||||||
showCancelButton: true,
|
showDialog({
|
||||||
})
|
title: "订单支付",
|
||||||
.then(() => {
|
message: payURL || "支付单已创建,请在订单详情页刷新支付状态。",
|
||||||
router.push("/wallet");
|
confirmButtonText: "查看详情",
|
||||||
})
|
}).then(() => {
|
||||||
.catch(() => {
|
router.push(`/m/orders/${payment.order_id}`);
|
||||||
// 用户取消引导时不需要额外提示。
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsufficientBalance(error: unknown) {
|
|
||||||
if (typeof error === "object" && error && "response" in error) {
|
|
||||||
const response = (error as { response?: { data?: { code?: string } } })
|
|
||||||
.response;
|
|
||||||
return response?.data?.code === "insufficient_balance";
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
if (typeof error === "object" && error && "response" in error) {
|
if (typeof error === "object" && error && "response" in error) {
|
||||||
const response = (error as { response?: { data?: { message?: string } } })
|
const response = (error as { response?: { data?: { message?: string } } })
|
||||||
|
|||||||
Reference in New Issue
Block a user