From a1942c8453cfd788432b0d7d56ee94b305caae8a Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 3 Jun 2026 17:41:24 +0800 Subject: [PATCH] =?UTF-8?q?=E9=92=B1=E5=8C=85=E7=BB=93=E7=AE=97=E6=89=80?= =?UTF-8?q?=E6=9C=89,=20=E7=AC=AC=E4=B8=89=E6=96=B9=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=88=B0=E9=92=B1=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/payment/leshua/client.go | 71 +++++++++-- .../payment/leshua/client_test.go | 26 ++++ backend/internal/modules/payment/handler.go | 4 + .../internal/modules/payment/repository.go | 113 +++++++++++++----- backend/internal/router/router.go | 3 +- docs/api.md | 1 - docs/乐刷支付接入文档.md | 13 +- frontend/src/api/orders.ts | 11 +- .../src/views/mobile/MobileOrdersView.vue | 71 ++++------- 9 files changed, 211 insertions(+), 102 deletions(-) diff --git a/backend/internal/integrations/payment/leshua/client.go b/backend/internal/integrations/payment/leshua/client.go index 98cf231..26781eb 100644 --- a/backend/internal/integrations/payment/leshua/client.go +++ b/backend/internal/integrations/payment/leshua/client.go @@ -72,6 +72,14 @@ type QueryPaymentResponse struct { Raw map[string]string } +type VerifyNotifyResult struct { + OK bool + MatchedKey string + Got string + Expected map[string]string + ParamKeys []string +} + func NewClient(cfg config.LeshuaPaymentConfig) *Client { return &Client{ cfg: cfg, @@ -171,19 +179,60 @@ func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID } func (c *Client) VerifyNotify(params map[string]string) bool { - key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SignKey) - if key == "" { - return false - } + return c.VerifyNotifyDetail(params).OK +} + +func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult { got := strings.ToUpper(params["sign"]) - if got == "" { - return false + result := VerifyNotifyResult{ + Got: got, + Expected: map[string]string{}, + ParamKeys: notifyParamKeys(params), } - expected := Sign(params, key, SignOptions{ - IncludeEmpty: true, - ExcludeKeys: []string{"error_code", "sign"}, - }) - return got == expected + if got == "" { + return result + } + for _, item := range c.notifyKeyCandidates() { + expected := Sign(params, item.key, SignOptions{ + IncludeEmpty: true, + ExcludeKeys: []string{"error_code", "sign"}, + }) + result.Expected[item.name] = expected + if got == expected { + result.OK = true + result.MatchedKey = item.name + return result + } + } + return result +} + +type notifyKeyCandidate struct { + name string + key string +} + +func (c *Client) notifyKeyCandidates() []notifyKeyCandidate { + candidates := []notifyKeyCandidate{} + if c.cfg.NotifyKey != "" { + candidates = append(candidates, notifyKeyCandidate{name: "notify_key", key: c.cfg.NotifyKey}) + } + if c.cfg.SignKey != "" && c.cfg.SignKey != c.cfg.NotifyKey { + candidates = append(candidates, notifyKeyCandidate{name: "sign_key", key: c.cfg.SignKey}) + } + return candidates +} + +func notifyParamKeys(params map[string]string) []string { + keys := make([]string, 0, len(params)) + for key := range params { + if key == "sign" || key == "error_code" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + return keys } func (c *Client) validate() error { diff --git a/backend/internal/integrations/payment/leshua/client_test.go b/backend/internal/integrations/payment/leshua/client_test.go index 1afd3e2..22b0f1d 100644 --- a/backend/internal/integrations/payment/leshua/client_test.go +++ b/backend/internal/integrations/payment/leshua/client_test.go @@ -50,6 +50,32 @@ func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) { } } +func TestVerifyNotifyFallsBackToSignKey(t *testing.T) { + client := NewClient(config.LeshuaPaymentConfig{ + NotifyKey: "wrong-notify-secret", + SignKey: "sign-secret", + }) + params := map[string]string{ + "merchant_id": "1234567890", + "third_order_id": "NO1", + "leshua_order_id": "LS1", + "amount": "100", + "status": "2", + } + params["sign"] = Sign(params, "sign-secret", SignOptions{ + IncludeEmpty: true, + ExcludeKeys: []string{"error_code", "sign"}, + }) + + result := client.VerifyNotifyDetail(params) + if !result.OK { + t.Fatal("VerifyNotifyDetail().OK = false, want true") + } + if result.MatchedKey != "sign_key" { + t.Fatalf("MatchedKey = %s, want sign_key", result.MatchedKey) + } +} + func TestParsePayloadSupportsFormAndXML(t *testing.T) { form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100")) if err != nil { diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 42d3ca0..a91d0ac 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -3,6 +3,7 @@ package payment import ( "errors" "io" + "log" "net/http" "strconv" @@ -109,11 +110,14 @@ func (h *Handler) LeshuaNotify(c *gin.Context) { c.String(http.StatusBadRequest, "FAIL") return } + log.Printf("[payment] leshua notify received third_order_id=%s leshua_order_id=%s status=%s amount=%s", params["third_order_id"], params["leshua_order_id"], params["status"], params["amount"]) result, err := h.service.HandleLeshuaNotify(params) if err != nil || result == nil || !result.OK { + log.Printf("[payment] leshua notify failed third_order_id=%s err=%v", params["third_order_id"], err) c.String(http.StatusOK, "FAIL") return } + log.Printf("[payment] leshua notify processed third_order_id=%s status=%s", params["third_order_id"], params["status"]) c.String(http.StatusOK, result.Message) } diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index b6afd88..2bc12ae 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log" "math" "time" @@ -30,6 +31,13 @@ type Repository struct { isMockMode bool } +const ( + channelSourceCreate = "create" + channelSourceQuery = "query" + channelSourceNotify = "notify" + channelSourceMock = "mock" +) + func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository { provider := cfg.Provider if provider == "" { @@ -61,7 +69,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques "third_order_id": payment.ThirdOrderID, "leshua_order_id": payment.ProviderOrderID, "status": "2", - }); err != nil { + }, channelSourceMock); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) @@ -103,7 +111,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, "raw_request": jsonMap(rawReq), - "raw_response": jsonMap(resp.Raw), + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err } @@ -130,7 +138,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen "third_order_id": payment.ThirdOrderID, "leshua_order_id": payment.ProviderOrderID, "status": "2", - }); err != nil { + }, channelSourceMock); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) @@ -167,7 +175,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, "raw_request": jsonMap(rawReq), - "raw_response": jsonMap(resp.Raw), + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err } @@ -195,7 +203,7 @@ func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*Paym if err != nil { return nil, err } - if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil { + if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) @@ -222,7 +230,7 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { if err != nil { return nil, err } - if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil { + if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { return nil, err } latest, err := r.findPaymentByID(payment.ID) @@ -234,8 +242,19 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { } func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) { - if !r.isMockMode && !r.leshua.VerifyNotify(params) { - return nil, ErrPaymentVerifyFailed + if !r.isMockMode { + verify := r.leshua.VerifyNotifyDetail(params) + if !verify.OK { + log.Printf( + "[payment] leshua notify verify failed third_order_id=%s got=%s expected=%v keys=%v", + params["third_order_id"], + shortSign(verify.Got), + shortExpectedSigns(verify.Expected), + verify.ParamKeys, + ) + return nil, ErrPaymentVerifyFailed + } + log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey) } thirdOrderID := params["third_order_id"] if thirdOrderID == "" { @@ -251,7 +270,7 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { return nil, ErrPaymentVerifyFailed } - if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], params); err != nil { + if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], params, channelSourceNotify); err != nil { return nil, err } return &NotifyResult{OK: true, Message: "000000"}, nil @@ -366,7 +385,7 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64 return &payment, nil } -func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string) error { +func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error { switch status { case "2", "30": paidAt := parseLeshuaTime(payTime) @@ -374,29 +393,28 @@ func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status stri now := time.Now() paidAt = &now } - return r.confirmPaid(payment, status, *paidAt, raw) + return r.confirmPaid(payment, status, *paidAt, raw, source) case "6": - return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "closed", - "raw_response": jsonMap(raw), - "notified_at": time.Now(), - }).Error + return r.updateChannelStatus(payment.ID, "closed", raw, source) case "8": - return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "failed", - "raw_response": jsonMap(raw), - "notified_at": time.Now(), - }).Error + return r.updateChannelStatus(payment.ID, "failed", raw, source) default: - return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "paying", - "raw_response": jsonMap(raw), - "notified_at": time.Now(), - }).Error + return r.updateChannelStatus(payment.ID, "paying", raw, source) } } -func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string) error { +func (r *Repository) updateChannelStatus(paymentID uint64, status string, raw map[string]string, source string) error { + updates := map[string]any{ + "status": status, + "raw_response": jsonMap(withRawSource(raw, source)), + } + if source == channelSourceNotify { + updates["notified_at"] = time.Now() + } + return r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error +} + +func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error { if payment.Status != "paid" { if payment.OrderID == 0 { if r.walletRepo == nil { @@ -414,13 +432,16 @@ func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, pai } } } - return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + updates := map[string]any{ "status": "paid", "provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID), - "raw_response": jsonMap(raw), + "raw_response": jsonMap(withRawSource(raw, source)), "paid_at": paidAt, - "notified_at": time.Now(), - }).Error + } + if source == channelSourceNotify { + updates["notified_at"] = time.Now() + } + return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error } func (r *Repository) markPaymentFailed(paymentID uint64, raw map[string]string, message string) error { @@ -498,6 +519,18 @@ func jsonMap(value map[string]string) datatypes.JSON { return datatypes.JSON(raw) } +func withRawSource(raw map[string]string, source string) map[string]string { + out := map[string]string{} + for key, value := range raw { + out[key] = value + } + if source != "" { + out["_source"] = source + } + out["_recorded_at"] = time.Now().Format(time.RFC3339) + return out +} + func newPaymentNo() (string, error) { buf := make([]byte, 4) if _, err := rand.Read(buf); err != nil { @@ -514,3 +547,21 @@ func firstNonEmpty(values ...string) string { } return "" } + +func shortExpectedSigns(values map[string]string) map[string]string { + out := map[string]string{} + for key, value := range values { + out[key] = shortSign(value) + } + return out +} + +func shortSign(value string) string { + if value == "" { + return "" + } + if len(value) <= 12 { + return value + } + return value[:12] + "..." +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f53feab..2244b3a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -222,8 +222,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.GET("", orderHandler.List) orderRoutes.GET("/:id", orderHandler.Detail) orderRoutes.GET("/:id/chat", chatHandler.OrderConversation) - orderRoutes.POST("/:id/pay", paymentHandler.Start) - orderRoutes.POST("/:id/pay/query", paymentHandler.Query) + orderRoutes.POST("/:id/pay", orderHandler.Pay) orderRoutes.POST("/:id/cancel", orderHandler.Cancel) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) diff --git a/docs/api.md b/docs/api.md index d2962b4..7970cf9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,7 +36,6 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/orders` - `GET /api/orders/{id}` - `POST /api/orders/{id}/pay` -- `POST /api/orders/{id}/pay/query` - `POST /api/orders/{id}/cancel` - `POST /api/orders/{id}/handoff` - `GET /api/orders/{id}/handoff-records` diff --git a/docs/乐刷支付接入文档.md b/docs/乐刷支付接入文档.md index 6fa8427..e174605 100644 --- a/docs/乐刷支付接入文档.md +++ b/docs/乐刷支付接入文档.md @@ -354,18 +354,19 @@ MD5 签名步骤: | API | 说明 | | --- | --- | -| `POST /api/orders/:id/pay` | 创建或复用支付单,mock 模式会立即模拟渠道支付成功。 | -| `POST /api/orders/:id/pay/query` | 查询支付单并主动向乐刷查单。 | +| `POST /api/wallet/recharge/pay` | 创建钱包充值支付单,返回扫码支付二维码链接;mock 模式会立即模拟充值成功。 | +| `POST /api/wallet/recharge/pay/:id/query` | 查询钱包充值支付单,并主动向乐刷查单。 | +| `POST /api/orders/:id/pay` | 订单只使用钱包余额支付,不再直接调用乐刷。 | | `POST /api/payments/leshua/notify` | 乐刷支付通知回调,不需要登录鉴权,成功返回 `000000`。 | 数据模型: - 新增 `payment_orders` 表保存支付单、渠道单号、支付链接、请求/响应原文、状态和支付时间。 -- `third_order_id` 当前使用租号订单号,后续如果要支持关闭后重新发起多次支付,应改为支付单号或订单号加支付轮次。 -- 渠道确认支付成功后,不扣用户 `available`,直接向租客 `frozen` 写入 `channel_order_lock`,并推进订单到 `pending_handoff`,后续结账沿用现有冻结释放/结算逻辑。 +- 乐刷支付单仅用于钱包充值,`order_id = 0`,`third_order_id` 使用 `payment_no`。 +- 渠道确认钱包充值成功后,向用户 `available` 写入 `channel_recharge` 流水。 +- 订单支付不再创建乐刷支付单,只扣用户钱包 `available` 并转入 `frozen`,后续结账沿用现有冻结释放/结算逻辑。 当前范围: -- 已接:扫码/简易支付下单、查单、支付成功通知、mock 跑通链路。 +- 已接:钱包扫码/简易支付充值、查单、支付成功通知、mock 跑通链路;订单钱包余额支付。 - 暂缓:条码支付、JSAPI/小程序必要 openid 获取、退款、退款通知、关单、刷卡交易查询、SM3 签名。 - diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index d7ac833..00a58b0 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -91,6 +91,10 @@ export interface PaymentOrder { updated_at: string } +export interface PayOrderResult { + paid: boolean +} + export interface SubmitCheckoutPayload { content: string consumable_amount: number @@ -116,12 +120,7 @@ export async function createOrder(listingId: number) { } export async function payOrder(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/pay`) - return data.data -} - -export async function queryOrderPayment(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/pay/query`) + const { data } = await apiClient.post>(`/orders/${id}/pay`) return data.data } diff --git a/frontend/src/views/mobile/MobileOrdersView.vue b/frontend/src/views/mobile/MobileOrdersView.vue index 7328d8a..0f693e4 100644 --- a/frontend/src/views/mobile/MobileOrdersView.vue +++ b/frontend/src/views/mobile/MobileOrdersView.vue @@ -1,10 +1,10 @@