From a2a0489158685960fdfd35db69701e698ba9be03 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 8 Jun 2026 23:56:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/payment/lakala/client.go | 3 + .../payment/lakala/client_test.go | 10 + .../internal/modules/dispute/repository.go | 49 +++ .../modules/dispute/repository_test.go | 23 ++ backend/internal/modules/payment/dto.go | 49 ++- backend/internal/modules/payment/handler.go | 42 +++ .../internal/modules/payment/repository.go | 183 ++++++++++- .../modules/payment/repository_test.go | 29 ++ backend/internal/modules/payment/service.go | 16 + backend/internal/modules/wallet/repository.go | 4 +- .../modules/wallet/repository_test.go | 28 ++ backend/internal/router/router.go | 1 + .../src/features/admin/api/adminPayments.ts | 56 ++++ frontend/src/features/admin/index.ts | 1 + .../admin/views/AdminOrderDetailView.vue | 62 ++++ .../admin/views/AdminPaymentsView.vue | 311 ++++++++++++++++++ .../orders/views/MobileOrderDetailView.vue | 14 +- .../features/orders/views/OrderDetailView.vue | 1 + frontend/src/layouts/AdminLayout.vue | 1 + frontend/src/router/adminRoutes.ts | 6 + 20 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/admin/api/adminPayments.ts create mode 100644 frontend/src/features/admin/views/AdminPaymentsView.vue diff --git a/backend/internal/integrations/payment/lakala/client.go b/backend/internal/integrations/payment/lakala/client.go index 06b2f9c..ac61fe0 100644 --- a/backend/internal/integrations/payment/lakala/client.go +++ b/backend/internal/integrations/payment/lakala/client.go @@ -612,6 +612,9 @@ func normalizePaymentStatus(raw map[string]string) string { func normalizeRefundStatus(raw map[string]string) string { value := strings.ToUpper(firstNonEmpty(raw["refund_status"], raw["trade_state"], raw["trade_status"], raw["status"])) + if value == "" && responseOK(raw) { + return "refunded" + } switch value { case "SUCCESS", "REFUND_SUCCESS", "TRADE_SUCCESS", "REFUNDED", "S", "11": return "refunded" diff --git a/backend/internal/integrations/payment/lakala/client_test.go b/backend/internal/integrations/payment/lakala/client_test.go index bdc4ff2..ed80cd8 100644 --- a/backend/internal/integrations/payment/lakala/client_test.go +++ b/backend/internal/integrations/payment/lakala/client_test.go @@ -77,6 +77,16 @@ func TestParsePayloadNormalizesAliases(t *testing.T) { } } +func TestNormalizeRefundStatusTreatsSuccessCodeAsRefunded(t *testing.T) { + status := normalizeRefundStatus(map[string]string{ + "code": "BBS00000", + "msg": "成功", + }) + if status != "refunded" { + t.Fatalf("normalizeRefundStatus() = %q, want refunded", status) + } +} + func TestCreatePaymentOmitsCounterParamWhenPayModeBlank(t *testing.T) { body := captureCreatePaymentBody(t, "") reqData := body["req_data"].(map[string]any) diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 4cef63d..a90d8c3 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -3,6 +3,7 @@ package dispute import ( "encoding/json" "errors" + "fmt" "math" "time" @@ -268,6 +269,16 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, if err := tx.Save(&account).Error; err != nil { return err } + handoffRecord := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: adminID, + ToUserID: order.RenterID, + Type: "admin_arbitration", + Content: buildArbitrationHandoffContent(req, settlement), + } + if err := tx.Create(&handoffRecord).Error; err != nil { + return err + } disputeID := row.ID if err := appendAuditLog(tx, adminID, "dispute.arbitrate", "dispute", row.ID, meta, map[string]any{ "dispute_id": row.ID, @@ -525,6 +536,44 @@ func arbitrateOrderStatus(result string) string { } } +func buildArbitrationHandoffContent(req ArbitrateRequest, settlement arbitrationSettlement) string { + content := fmt.Sprintf("客服仲裁结果:%s", arbitrationResultLabel(req.Result)) + if req.Remark != "" { + content += "\n处理说明:" + req.Remark + } + if settlement.RenterRefundAmount > 0 { + content += fmt.Sprintf("\n退款给租客:¥%.2f", settlement.RenterRefundAmount) + } + if settlement.OwnerIncomeAmount > 0 { + content += fmt.Sprintf("\n结算给号主:¥%.2f", settlement.OwnerIncomeAmount) + } + if settlement.DepositDeductAmount > 0 { + content += fmt.Sprintf("\n押金扣除:¥%.2f", settlement.DepositDeductAmount) + } + return content +} + +func arbitrationResultLabel(result string) string { + switch result { + case "full_refund": + return "全额退款" + case "partial_refund": + return "部分退款" + case "deduct_deposit": + return "扣押金" + case "release_deposit": + return "释放押金" + case "compensate_owner": + return "赔付号主" + case "order_close": + return "关闭订单" + case "mark_abnormal": + return "标记异常" + default: + return result + } +} + func disputeType(input string, isCheckoutDispute bool) string { if isCheckoutDispute { return "checkout_dispute" diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index af29cfe..5b12613 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -1,6 +1,7 @@ package dispute import ( + "strings" "testing" "hfb_sys/backend/internal/model" @@ -76,3 +77,25 @@ func TestBuildArbitrationSettlementLimitsFrozenReleaseToExistingBalance(t *testi t.Fatalf("release frozen entry count = %d, want 1", releaseEntryCount) } } + +func TestBuildArbitrationHandoffContent(t *testing.T) { + content := buildArbitrationHandoffContent(ArbitrateRequest{ + Result: "partial_refund", + Remark: "账号异常,退还部分租金。", + }, arbitrationSettlement{ + RenterRefundAmount: 80, + OwnerIncomeAmount: 120, + }) + + wantParts := []string{ + "客服仲裁结果:部分退款", + "处理说明:账号异常,退还部分租金。", + "退款给租客:¥80.00", + "结算给号主:¥120.00", + } + for _, part := range wantParts { + if !strings.Contains(content, part) { + t.Fatalf("handoff content = %q, want include %q", content, part) + } + } +} diff --git a/backend/internal/modules/payment/dto.go b/backend/internal/modules/payment/dto.go index 08a019b..e871f88 100644 --- a/backend/internal/modules/payment/dto.go +++ b/backend/internal/modules/payment/dto.go @@ -1,6 +1,10 @@ package payment -import "time" +import ( + "time" + + "gorm.io/datatypes" +) type StartPaymentRequest struct { PayWay string `json:"pay_way"` @@ -52,3 +56,46 @@ type NotifyResult struct { OK bool Message string } + +type AdminPaymentQuery struct { + UserID uint64 + OrderID uint64 + OrderNo string + BizType string + Status string + Provider string + Page int + PageSize int +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type AdminPaymentDTO struct { + ID uint64 `json:"id"` + PaymentNo string `json:"payment_no"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + UserID uint64 `json:"user_id"` + UserPhone string `json:"user_phone"` + Provider string `json:"provider"` + MerchantID string `json:"merchant_id"` + ThirdOrderID string `json:"third_order_id"` + ProviderOrderID string `json:"provider_order_id"` + PayWay string `json:"pay_way"` + AmountCent int64 `json:"amount_cent"` + BizType string `json:"biz_type"` + Status string `json:"status"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + RawRequest datatypes.JSON `json:"raw_request"` + RawResponse datatypes.JSON `json:"raw_response"` + PaidAt *time.Time `json:"paid_at,omitempty"` + NotifiedAt *time.Time `json:"notified_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index d918993..fe01409 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -113,6 +113,19 @@ func (h *Handler) QueryRefundStatus(c *gin.Context) { response.OK(c, item) } +func (h *Handler) AdminList(c *gin.Context) { + query, ok := parseAdminPaymentQuery(c) + if !ok { + return + } + result, err := h.service.AdminList(query) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, result) +} + func (h *Handler) LeshuaNotify(c *gin.Context) { body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) if err != nil { @@ -196,6 +209,35 @@ func parseID(c *gin.Context) (uint64, bool) { return id, true } +func parseAdminPaymentQuery(c *gin.Context) (AdminPaymentQuery, bool) { + var query AdminPaymentQuery + if raw := c.Query("user_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "用户ID不正确") + return query, false + } + query.UserID = value + } + if raw := c.Query("order_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "订单ID不正确") + return query, false + } + query.OrderID = value + } + query.OrderNo = c.Query("order_no") + query.BizType = c.Query("biz_type") + query.Status = c.Query("status") + query.Provider = c.Query("provider") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + query.Page = page + query.PageSize = pageSize + return query, true +} + func writePaymentError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 202cd36..aa922db 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -180,6 +180,8 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques return nil, ErrPaymentUnavailable } + log.Printf("[payment] payment start order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d third_order_id=%s", + orderID, orderRow.OrderNo, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, @@ -193,10 +195,14 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) + log.Printf("[payment] payment request failed order_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", + orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) + log.Printf("[payment] payment rejected order_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", + orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) return nil, ErrPaymentUnavailable } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ @@ -216,6 +222,8 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques return nil, err } r.recordConfigUsage(runtimeConfig, latest) + log.Printf("[payment] payment result order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", + orderID, orderRow.OrderNo, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) dto := toDTO(*latest) return &dto, nil } @@ -254,6 +262,8 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen _ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable") return nil, ErrPaymentUnavailable } + log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, @@ -267,10 +277,14 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen }) if err != nil { _ = r.markPaymentFailed(payment.ID, nil, err.Error()) + log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) return nil, err } if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) + log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) return nil, ErrPaymentUnavailable } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ @@ -290,6 +304,8 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen return nil, err } r.recordConfigUsage(runtimeConfig, latest) + log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", + userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) dto := toDTO(*latest) return &dto, nil } @@ -466,6 +482,8 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType if err := r.db.Create(&refundOrder).Error; err != nil { return nil, err } + log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s", + orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) r.recordConfigUsage(runtimeConfig, &refundOrder) if err := r.markOrderRefunding(orderID, refundAmountCent); err != nil { log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) @@ -477,7 +495,7 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType } resp, err := runtimeConfig.Channel.CreateRefund(context.Background(), channelCreateRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, - ProviderOrderID: originalPayment.ProviderOrderID, + ProviderOrderID: refundOriginProviderOrderID(originalPayment), MerchantRefundID: merchantRefundID, RefundAmountCent: refundAmountCent, NotifyURL: runtimeConfig.NotifyURL, @@ -486,10 +504,14 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType }) if err != nil { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) + log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err) return nil, err } if !resp.OK { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw) + log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) return nil, ErrPaymentUnavailable } @@ -522,6 +544,8 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType } refundOrder.Status = refundStatus refundOrder.ProviderOrderID = resp.ProviderRefundID + log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundStatus, resp.ProviderRefundID) dto := toRefundDTO(refundOrder) return &dto, nil @@ -581,6 +605,51 @@ func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { return &dto, nil } +func (r *Repository) AdminList(query AdminPaymentQuery) (*PaginatedResult, error) { + db := r.db.Table("payment_orders AS p"). + Select("p.*, COALESCE(u.phone, '') AS user_phone"). + Joins("LEFT JOIN users AS u ON u.id = p.user_id") + countDB := r.db.Model(&model.PaymentOrder{}) + if query.UserID > 0 { + db = db.Where("p.user_id = ?", query.UserID) + countDB = countDB.Where("user_id = ?", query.UserID) + } + if query.OrderID > 0 { + db = db.Where("p.order_id = ?", query.OrderID) + countDB = countDB.Where("order_id = ?", query.OrderID) + } + if query.OrderNo != "" { + db = db.Where("p.order_no = ?", query.OrderNo) + countDB = countDB.Where("order_no = ?", query.OrderNo) + } + if query.BizType != "" { + db = db.Where("p.biz_type = ?", query.BizType) + countDB = countDB.Where("biz_type = ?", query.BizType) + } + if query.Status != "" { + db = db.Where("p.status = ?", query.Status) + countDB = countDB.Where("status = ?", query.Status) + } + if query.Provider != "" { + db = db.Where("p.provider = ?", query.Provider) + countDB = countDB.Where("provider = ?", query.Provider) + } + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + offset := (query.Page - 1) * query.PageSize + var rows []adminPaymentRow + if err := db.Order("p.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]AdminPaymentDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + // HandleRefundNotify 处理渠道退款通知。 func (r *Repository) HandleRefundNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { payment, err := r.findRefundPaymentForNotify(params) @@ -1004,6 +1073,55 @@ func toDTO(payment model.PaymentOrder) PaymentDTO { } } +type adminPaymentRow struct { + model.PaymentOrder + UserPhone string +} + +func (row adminPaymentRow) toDTO() AdminPaymentDTO { + errorCode, errorMessage := paymentErrorSummary(row.Status, row.RawResponse) + return AdminPaymentDTO{ + ID: row.ID, + PaymentNo: row.PaymentNo, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + UserID: row.UserID, + UserPhone: row.UserPhone, + Provider: row.Provider, + MerchantID: row.MerchantID, + ThirdOrderID: row.ThirdOrderID, + ProviderOrderID: row.ProviderOrderID, + PayWay: row.PayWay, + AmountCent: row.AmountCent, + BizType: row.BizType, + Status: row.Status, + ErrorCode: errorCode, + ErrorMessage: errorMessage, + RawRequest: row.RawRequest, + RawResponse: row.RawResponse, + PaidAt: row.PaidAt, + NotifiedAt: row.NotifiedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func paymentErrorSummary(status string, raw datatypes.JSON) (string, string) { + if status != "failed" { + return "", "" + } + if len(raw) == 0 { + return "", "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "", "" + } + code := firstStringValue(payload, "code", "resp_code", "result_code", "error_code", "status") + message := firstStringValue(payload, "msg", "message", "error", "error_message", "result_msg", "result_desc") + return code, message +} + func moneyCent(value float64) int64 { return int64(math.Round(value * 100)) } @@ -1014,6 +1132,69 @@ func parseCent(value string) int64 { return amount } +func refundOriginProviderOrderID(payment model.PaymentOrder) string { + if payment.Provider != "lakala" { + return payment.ProviderOrderID + } + if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" { + return tradeID + } + return payment.ProviderOrderID +} + +func lakalaOriginTradeID(raw datatypes.JSON) string { + if len(raw) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + value, ok := payload["order_trade_info_list"] + if !ok { + return "" + } + switch typed := value.(type) { + case string: + var items []map[string]any + if err := json.Unmarshal([]byte(typed), &items); err != nil { + return "" + } + for _, item := range items { + if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + } + case []any: + for _, item := range typed { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + } + } + return "" +} + +func firstStringValue(values map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := values[key] + if !ok { + continue + } + if text, ok := value.(string); ok && text != "" { + return text + } + } + return "" +} + func parseChannelTime(value string) *time.Time { if value == "" { return nil diff --git a/backend/internal/modules/payment/repository_test.go b/backend/internal/modules/payment/repository_test.go index fa21004..62ae256 100644 --- a/backend/internal/modules/payment/repository_test.go +++ b/backend/internal/modules/payment/repository_test.go @@ -128,3 +128,32 @@ func TestParseChannelTimeUsesShanghaiWhenLocalIsUTC(t *testing.T) { t.Fatalf("parseChannelTime() = %s, want 20260607202700 in Asia/Shanghai", got) } } + +func TestRefundOriginProviderOrderIDUsesLakalaTradeNo(t *testing.T) { + payment := model.PaymentOrder{ + Provider: "lakala", + ProviderOrderID: "26060811012001101011735013210", + RawResponse: datatypes.JSON([]byte(`{ + "pay_order_no": "26060811012001101011735013210", + "order_trade_info_list": "[{\"trade_no\":\"20260608110113230266224452004512\",\"pay_order_no\":\"26060811012001101011735013210\"}]" + }`)), + } + + got := refundOriginProviderOrderID(payment) + if got != "20260608110113230266224452004512" { + t.Fatalf("refundOriginProviderOrderID() = %q, want trade_no", got) + } +} + +func TestRefundOriginProviderOrderIDFallsBackToProviderOrderID(t *testing.T) { + payment := model.PaymentOrder{ + Provider: "lakala", + ProviderOrderID: "26060811012001101011735013210", + RawResponse: datatypes.JSON([]byte(`{"pay_order_no":"26060811012001101011735013210"}`)), + } + + got := refundOriginProviderOrderID(payment) + if got != payment.ProviderOrderID { + t.Fatalf("refundOriginProviderOrderID() = %q, want fallback %q", got, payment.ProviderOrderID) + } +} diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go index b23f016..93a8f05 100644 --- a/backend/internal/modules/payment/service.go +++ b/backend/internal/modules/payment/service.go @@ -106,3 +106,19 @@ func (s *Service) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { } return s.repo.QueryRefundStatus(orderID) } + +func (s *Service) AdminList(query AdminPaymentQuery) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + return s.repo.AdminList(query) +} diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 7b4a1c3..2a4c3c9 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -3,11 +3,11 @@ package wallet import ( "crypto/rand" "fmt" - "math" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/timeutil" + "hfb_sys/backend/pkg/money" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -260,7 +260,7 @@ func applyEntry(account *model.WalletAccount, entry Entry) (float64, error) { } func roundWalletMoney(value float64) float64 { - return math.Round(value) + return money.Round(value) } func toAccountDTO(account model.WalletAccount) *AccountDTO { diff --git a/backend/internal/modules/wallet/repository_test.go b/backend/internal/modules/wallet/repository_test.go index c4b8ad0..4f4b659 100644 --- a/backend/internal/modules/wallet/repository_test.go +++ b/backend/internal/modules/wallet/repository_test.go @@ -31,6 +31,34 @@ func TestApplyEntryRoundsMoneyBeforeComparing(t *testing.T) { } } +func TestApplyEntryKeepsWalletMoneyAtJiaoPrecision(t *testing.T) { + account := model.WalletAccount{ + UserID: 8, + AvailableBalance: 0, + } + entry := Entry{ + UserID: 8, + Direction: "in", + Amount: 2.30, + BalanceType: "available", + } + + balanceAfter, err := applyEntry(&account, entry) + if err != nil { + t.Fatalf("applyEntry() error = %v", err) + } + if balanceAfter != 2.3 { + t.Fatalf("balanceAfter = %v, want 2.3", balanceAfter) + } + if account.AvailableBalance != 2.3 { + t.Fatalf("AvailableBalance = %v, want 2.3", account.AvailableBalance) + } + + if rounded := roundWalletMoney(2.36); rounded != 2.4 { + t.Fatalf("roundWalletMoney(2.36) = %v, want 2.4", rounded) + } +} + func TestNewLedgerNoUsesReadableFormat(t *testing.T) { ledgerNo, err := newLedgerNo() if err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e086f00..7d3bd1a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -425,6 +425,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) + adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) // 提现管理 adminRoutes.GET("/withdrawals", requirePerm("withdrawal:list"), withdrawalHandler.AdminList) diff --git a/frontend/src/features/admin/api/adminPayments.ts b/frontend/src/features/admin/api/adminPayments.ts new file mode 100644 index 0000000..0ffc002 --- /dev/null +++ b/frontend/src/features/admin/api/adminPayments.ts @@ -0,0 +1,56 @@ +import { apiClient } from '@/shared/api/client' + +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export interface AdminPayment { + id: number + payment_no: string + order_id: number + order_no: string + user_id: number + user_phone: string + provider: string + merchant_id: string + third_order_id: string + provider_order_id: string + pay_way: string + amount_cent: number + biz_type: string + status: string + error_code: string + error_message: string + raw_request?: Record + raw_response?: Record + paid_at?: string + notified_at?: string + created_at: string + updated_at: string +} + +export interface AdminPaymentQuery { + user_id?: string + order_id?: string + order_no?: string + biz_type?: string + status?: string + provider?: string + page?: number + page_size?: number +} + +export async function fetchAdminPayments(query: AdminPaymentQuery = {}) { + const params = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) + const { data } = await apiClient.get>>( + '/admin/payments', + { params } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + } +} diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index 4d9019c..0bc8ee7 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -3,6 +3,7 @@ export * from './api/adminDashboard' export * from './api/adminUsers' export * from './api/adminMgr' export * from './api/adminWallet' +export * from './api/adminPayments' export * from './api/adminAudit' export * from './api/systemConfigs' export * from './composables/useAdminTable' diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index d2fe394..ab481ea 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -14,6 +14,7 @@ import { type Order, type RefundStatus, } from '@/features/orders' +import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' import { formatListingNo } from '@/utils/listingDisplay' @@ -23,6 +24,7 @@ const loading = ref(false) const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) +const paymentRecords = ref([]) const actionType = ref<'close' | 'abnormal' | ''>('') const reason = ref('') const refundStatus = ref(null) @@ -44,6 +46,7 @@ async function loadOrder() { try { order.value = await fetchAdminOrder(String(route.params.id)) handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id)) + paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items await loadRefundStatus() } finally { loading.value = false @@ -124,12 +127,49 @@ async function handleRefund() { function refundStatusLabel(status: string) { const map: Record = { pending: '待退款', + refunding: '退款中', refunded: '已退款', failed: '退款失败', } return map[status] || status || '未退款' } +function paymentStatusLabel(status: string) { + const map: Record = { + created: '已创建', + paying: '支付中', + paid: '已支付', + refunding: '退款中', + refunded: '已退款', + failed: '失败', + } + return map[status] || status +} + +function paymentStatusType(status: string) { + if (['paid', 'refunded'].includes(status)) return 'success' + if (status === 'failed') return 'danger' + if (['paying', 'refunding'].includes(status)) return 'warning' + return 'info' +} + +function paymentBizTypeLabel(type: string) { + const map: Record = { + order_pay: '订单支付', + checkout_refund: '结账退款', + arbitration_refund: '仲裁退款', + admin_refund: '人工退款', + cancel_refund: '取消退款', + admin_close_refund: '客服关闭退款', + wallet_recharge: '钱包充值', + } + return map[type] || type +} + +function moneyCent(value: number) { + return `¥${(Number(value || 0) / 100).toFixed(2)}` +} + function formatHandoffRecordType(type: string) { const typeMap: Record = { owner_handoff: '卖家交接', @@ -137,6 +177,7 @@ function formatHandoffRecordType(type: string) { owner_counter_checkout: '卖家反驳结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', + admin_arbitration: '客服仲裁', } return typeMap[type] || type } @@ -214,6 +255,27 @@ function formatHandoffRecordType(type: string) {

预计截止:{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}

+
+

支付与退款

+ +
+ + {{ paymentBizTypeLabel(record.biz_type) }} · {{ moneyCent(record.amount_cent) }} + +

+ {{ record.provider || '-' }} · {{ record.third_order_id }} + / {{ record.provider_order_id }} +

+

+ {{ record.error_code }} {{ record.error_message }} +

+ + {{ paymentStatusLabel(record.status) }} + + {{ formatDateTime(record.created_at) }} +
+
+

交接记录

diff --git a/frontend/src/features/admin/views/AdminPaymentsView.vue b/frontend/src/features/admin/views/AdminPaymentsView.vue new file mode 100644 index 0000000..2fa04c0 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPaymentsView.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 8174942..7649fa1 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -540,6 +540,18 @@ function money(value: unknown) { return `${roundMoney(readNumber(value))}` } +function formatHandoffRecordType(type: string) { + const typeMap: Record = { + owner_handoff: '卖家交接', + renter_checkout: '买家结账', + owner_counter_checkout: '卖家反驳结账', + renter_confirm_checkout: '买家确认结账', + owner_accept_checkout: '卖家接受结账', + admin_arbitration: '客服仲裁', + } + return typeMap[type] || type +} + function orderRentAmount(item: Order) { if (item.owner_id === session.userId) return Number(item.owner_rent_amount ?? item.display_amount ?? 0) @@ -792,7 +804,7 @@ async function copyListingCode() {
- {{ record.type }} + {{ formatHandoffRecordType(record.type) }}

{{ record.content }}

{{ formatDateTime(record.created_at) }}
diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 49a2ddd..c92046d 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -677,6 +677,7 @@ function formatHandoffRecordType(type: string) { owner_counter_checkout: '卖家反驳结账', renter_confirm_checkout: '买家确认结账', owner_accept_checkout: '卖家接受结账', + admin_arbitration: '客服仲裁', } return typeMap[type] || type } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index f87d3ce..dd57259 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -98,6 +98,7 @@ const allNavGroups: NavGroup[] = [ icon: Coin, children: [ { label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' }, + { label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' }, { label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' }, { label: '支付配置', diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 5387301..5687633 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -70,6 +70,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminWalletLedgerView.vue'), meta: adminMeta, }, + { + path: '/admin/payments', + name: 'admin-payments', + component: () => import('@/features/admin/views/AdminPaymentsView.vue'), + meta: adminMeta, + }, { path: '/admin/withdrawals', name: 'admin-withdrawals',