diff --git a/backend/.env.example b/backend/.env.example index 5f66713..ccbd695 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,3 +39,15 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE= REALNAME_PROVIDER=mock REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt REALNAME_CLOUDMARKET_APPCODE= + +# 支付服务:本地默认 mock,会创建支付单并立即走渠道支付成功逻辑。 +PAYMENT_PROVIDER=mock +LESHUA_GATEWAY_URL=https://t-paygate.lepass.cn/cgi-bin/lepos_pay_gateway.cgi +LESHUA_MERCHANT_ID= +LESHUA_SIGN_KEY= +LESHUA_NOTIFY_KEY= +LESHUA_NOTIFY_URL= +LESHUA_JUMP_URL= +LESHUA_PAY_WAY=ZFBZF +LESHUA_JSPAY_FLAG=2 +LESHUA_SIGN_TYPE=MD5 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index 82fe0fa..112f1f7 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -40,3 +40,15 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE= REALNAME_PROVIDER=cloudmarket REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt REALNAME_CLOUDMARKET_APPCODE= + +# 乐刷支付:生产需由乐刷提供商户号、请求密钥和通知验签密钥。 +PAYMENT_PROVIDER=leshua +LESHUA_GATEWAY_URL=https://paygate.leshuazf.com/cgi-bin/lepos_pay_gateway.cgi +LESHUA_MERCHANT_ID= +LESHUA_SIGN_KEY= +LESHUA_NOTIFY_KEY= +LESHUA_NOTIFY_URL=https://your-domain.example.com/api/payments/leshua/notify +LESHUA_JUMP_URL=https://your-domain.example.com/m/orders +LESHUA_PAY_WAY=ZFBZF +LESHUA_JSPAY_FLAG=2 +LESHUA_SIGN_TYPE=MD5 diff --git a/backend/internal/.DS_Store b/backend/internal/.DS_Store new file mode 100644 index 0000000..c80bcc3 Binary files /dev/null and b/backend/internal/.DS_Store differ diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c113ccb..e0e3c43 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { Storage StorageConfig SMS SMSConfig Realname RealnameConfig + Payment PaymentConfig Log LogConfig } @@ -41,6 +42,23 @@ type RealnameConfig struct { CloudMarketAppCode string } +type PaymentConfig struct { + Provider string + Leshua LeshuaPaymentConfig +} + +type LeshuaPaymentConfig struct { + GatewayURL string + MerchantID string + SignKey string + NotifyKey string + NotifyURL string + JumpURL string + PayWay string + JSPayFlag string + SignType string +} + type LogConfig struct { Level string Dir string @@ -76,6 +94,20 @@ func Load() Config { CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"), CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""), }, + Payment: PaymentConfig{ + Provider: getEnv("PAYMENT_PROVIDER", "mock"), + Leshua: LeshuaPaymentConfig{ + GatewayURL: getEnv("LESHUA_GATEWAY_URL", "https://t-paygate.lepass.cn/cgi-bin/lepos_pay_gateway.cgi"), + MerchantID: getEnv("LESHUA_MERCHANT_ID", ""), + SignKey: getEnv("LESHUA_SIGN_KEY", ""), + NotifyKey: getEnv("LESHUA_NOTIFY_KEY", ""), + NotifyURL: getEnv("LESHUA_NOTIFY_URL", ""), + JumpURL: getEnv("LESHUA_JUMP_URL", ""), + PayWay: getEnv("LESHUA_PAY_WAY", "ZFBZF"), + JSPayFlag: getEnv("LESHUA_JSPAY_FLAG", "2"), + SignType: getEnv("LESHUA_SIGN_TYPE", "MD5"), + }, + }, Log: LogConfig{ Level: getEnv("LOG_LEVEL", "info"), Dir: getEnv("LOG_DIR", "logs"), diff --git a/backend/internal/integrations/payment/leshua/client.go b/backend/internal/integrations/payment/leshua/client.go new file mode 100644 index 0000000..98cf231 --- /dev/null +++ b/backend/internal/integrations/payment/leshua/client.go @@ -0,0 +1,355 @@ +package leshua + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "hfb_sys/backend/internal/config" +) + +var ( + ErrConfigIncomplete = errors.New("leshua payment config incomplete") + ErrUnsupportedSign = errors.New("unsupported leshua sign type") +) + +type Client struct { + cfg config.LeshuaPaymentConfig + httpClient *http.Client +} + +type CreatePaymentRequest struct { + ThirdOrderID string + AmountCent int64 + PayWay string + JSPayFlag string + NotifyURL string + JumpURL string + ClientIP string + Body string + Attach string +} + +type CreatePaymentResponse struct { + RespCode string + ResultCode string + ErrorCode string + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + PayWay string + TDCode string + JSPayURL string + JSPayInfo string + Raw map[string]string +} + +type QueryPaymentResponse struct { + RespCode string + ResultCode string + ErrorCode string + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + Status string + Amount string + PayWay string + PayTime string + Raw map[string]string +} + +func NewClient(cfg config.LeshuaPaymentConfig) *Client { + return &Client{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, map[string]string, error) { + if err := c.validate(); err != nil { + return nil, nil, err + } + payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF") + jsPayFlag := firstNonEmpty(req.JSPayFlag, c.cfg.JSPayFlag, "2") + params := map[string]string{ + "service": "get_tdcode", + "merchant_id": c.cfg.MerchantID, + "third_order_id": req.ThirdOrderID, + "amount": fmt.Sprintf("%d", req.AmountCent), + "pay_way": payWay, + "jspay_flag": jsPayFlag, + "nonce_str": Nonce(32), + "body": sanitizeText(req.Body, 128), + "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 + } + if req.JumpURL != "" { + params["jump_url"] = req.JumpURL + } + if req.ClientIP != "" { + params["client_ip"] = req.ClientIP + } + params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{}) + raw, err := c.post(ctx, params) + if err != nil { + return nil, params, err + } + resp := &CreatePaymentResponse{ + 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"], + ProviderOrderID: raw["leshua_order_id"], + PayWay: raw["pay_way"], + TDCode: raw["td_code"], + JSPayURL: raw["jspay_url"], + JSPayInfo: raw["jspay_info"], + Raw: raw, + } + return resp, params, nil +} + +func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID string) (*QueryPaymentResponse, error) { + if err := c.validate(); err != nil { + return nil, err + } + params := map[string]string{ + "service": "query_status", + "merchant_id": c.cfg.MerchantID, + "nonce_str": Nonce(32), + } + if providerOrderID != "" { + params["leshua_order_id"] = providerOrderID + } else { + params["third_order_id"] = thirdOrderID + } + 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 &QueryPaymentResponse{ + 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"], + ProviderOrderID: raw["leshua_order_id"], + Status: raw["status"], + Amount: raw["amount"], + PayWay: raw["pay_way"], + PayTime: raw["pay_time"], + Raw: raw, + }, nil +} + +func (c *Client) VerifyNotify(params map[string]string) bool { + key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SignKey) + if key == "" { + return false + } + got := strings.ToUpper(params["sign"]) + if got == "" { + return false + } + expected := Sign(params, key, SignOptions{ + IncludeEmpty: true, + ExcludeKeys: []string{"error_code", "sign"}, + }) + return got == expected +} + +func (c *Client) validate() error { + if c.cfg.GatewayURL == "" || c.cfg.MerchantID == "" || c.cfg.SignKey == "" { + return ErrConfigIncomplete + } + if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { + return ErrUnsupportedSign + } + return nil +} + +func (c *Client) post(ctx context.Context, params map[string]string) (map[string]string, error) { + values := url.Values{} + for key, value := range params { + values.Set(key, value) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.GatewayURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + body, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20)) + if err != nil { + return nil, err + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("leshua http status %d: %s", httpResp.StatusCode, string(body)) + } + return ParsePayload(body) +} + +type SignOptions struct { + IncludeEmpty bool + ExcludeKeys []string +} + +func Sign(params map[string]string, key string, opts SignOptions) string { + excluded := map[string]bool{} + for _, item := range opts.ExcludeKeys { + excluded[item] = true + } + if len(opts.ExcludeKeys) == 0 { + excluded["sign"] = true + } + keys := make([]string, 0, len(params)) + for name, value := range params { + if excluded[name] { + continue + } + if !opts.IncludeEmpty && value == "" { + continue + } + keys = append(keys, name) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)+1) + for _, name := range keys { + parts = append(parts, name+"="+params[name]) + } + parts = append(parts, "key="+key) + sum := md5.Sum([]byte(strings.Join(parts, "&"))) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +func ParsePayload(body []byte) (map[string]string, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return map[string]string{}, nil + } + if trimmed[0] == '<' { + return parseXMLPayload(trimmed) + } + if trimmed[0] == '{' { + var raw map[string]any + if err := json.Unmarshal(trimmed, &raw); err != nil { + return nil, err + } + out := map[string]string{} + for key, value := range raw { + out[key] = fmt.Sprint(value) + } + return out, nil + } + values, err := url.ParseQuery(string(trimmed)) + if err != nil { + return nil, err + } + out := map[string]string{} + for key, item := range values { + if len(item) > 0 { + out[key] = item[0] + } + } + if len(out) == 0 { + return nil, fmt.Errorf("unsupported leshua payload: %s", string(trimmed)) + } + return out, nil +} + +func parseXMLPayload(body []byte) (map[string]string, error) { + decoder := xml.NewDecoder(bytes.NewReader(body)) + out := map[string]string{} + var current string + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + switch item := token.(type) { + case xml.StartElement: + current = item.Name.Local + case xml.CharData: + value := strings.TrimSpace(string(item)) + if current != "" && current != "xml" && value != "" { + out[current] = value + } + case xml.EndElement: + current = "" + } + } + return out, nil +} + +func Nonce(length int) string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + if length <= 0 { + length = 32 + } + buf := make([]byte, length) + random := make([]byte, length) + if _, err := rand.Read(random); err != nil { + for i := range buf { + buf[i] = alphabet[int(time.Now().UnixNano())%len(alphabet)] + } + return string(buf) + } + for i, item := range random { + buf[i] = alphabet[int(item)%len(alphabet)] + } + return string(buf) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sanitizeText(value string, max int) string { + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.TrimSpace(value) + if max > 0 && len([]rune(value)) > max { + return string([]rune(value)[:max]) + } + return value +} diff --git a/backend/internal/integrations/payment/leshua/client_test.go b/backend/internal/integrations/payment/leshua/client_test.go new file mode 100644 index 0000000..1afd3e2 --- /dev/null +++ b/backend/internal/integrations/payment/leshua/client_test.go @@ -0,0 +1,69 @@ +package leshua + +import ( + "testing" + + "hfb_sys/backend/internal/config" +) + +func TestSignUsesASCIISortedNonEmptyParams(t *testing.T) { + params := map[string]string{ + "service": "get_tdcode", + "merchant_id": "1234567890", + "third_order_id": "NO1", + "amount": "100", + "nonce_str": "abc", + "empty": "", + "sign": "ignored", + } + + got := Sign(params, "secret", SignOptions{}) + want := "1E7892034AA77899E8DD609C4FA09E76" + if got != want { + t.Fatalf("Sign() = %s, want %s", got, want) + } +} + +func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(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", + "amount": "100", + "status": "2", + "attach": "", + "error_code": "-20001", + } + params["sign"] = Sign(params, "notify-secret", SignOptions{ + IncludeEmpty: true, + ExcludeKeys: []string{"error_code", "sign"}, + }) + + if !client.VerifyNotify(params) { + t.Fatal("VerifyNotify() = false, want true") + } + + params["amount"] = "101" + if client.VerifyNotify(params) { + t.Fatal("VerifyNotify() = true after amount changed, want false") + } +} + +func TestParsePayloadSupportsFormAndXML(t *testing.T) { + form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100")) + if err != nil { + t.Fatalf("ParsePayload(form) error = %v", err) + } + if form["third_order_id"] != "NO1" || form["status"] != "2" || form["amount"] != "100" { + t.Fatalf("ParsePayload(form) = %#v", form) + } + + xml, err := ParsePayload([]byte("NO26")) + if err != nil { + t.Fatalf("ParsePayload(xml) error = %v", err) + } + if xml["third_order_id"] != "NO2" || xml["status"] != "6" { + t.Fatalf("ParsePayload(xml) = %#v", xml) + } +} diff --git a/backend/internal/model/payment.go b/backend/internal/model/payment.go new file mode 100644 index 0000000..2af50dd --- /dev/null +++ b/backend/internal/model/payment.go @@ -0,0 +1,36 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type PaymentOrder struct { + ID uint64 `gorm:"primaryKey" json:"id"` + PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"` + OrderID uint64 `gorm:"not null;index" json:"order_id"` + OrderNo string `gorm:"size:64;not null;index" json:"order_no"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + Provider string `gorm:"size:32;not null" json:"provider"` + MerchantID string `gorm:"size:32;not null;default:''" json:"merchant_id"` + ThirdOrderID string `gorm:"size:64;not null;uniqueIndex" json:"third_order_id"` + ProviderOrderID string `gorm:"size:64;not null;default:'';index" json:"provider_order_id"` + PayWay string `gorm:"size:16;not null;default:''" json:"pay_way"` + JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:''" json:"jspay_flag"` + AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"` + Status string `gorm:"size:32;not null;default:'created';index" json:"status"` + TDCode string `gorm:"size:512;not null;default:''" json:"td_code"` + JSPayURL string `gorm:"column:jspay_url;size:512;not null;default:''" json:"jspay_url"` + JSPayInfo string `gorm:"column:jspay_info;type:text" json:"jspay_info"` + RawRequest datatypes.JSON `json:"raw_request"` + RawResponse datatypes.JSON `json:"raw_response"` + PaidAt *time.Time `json:"paid_at"` + NotifiedAt *time.Time `json:"notified_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PaymentOrder) TableName() string { + return "payment_orders" +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 86b2ea9..47b3de2 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -307,6 +307,93 @@ func (r *Repository) Pay(userID uint64, orderID uint64) error { return nil } +func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string) error { + var newConvID uint64 + err := r.db.Transaction(func(tx *gorm.DB) error { + var order model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { + return err + } + if order.Status == "pending_handoff" || order.Status == "renting" { + return nil + } + if order.Status != "pending_payment" { + 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 + } + + orderID := order.ID + total := order.RentAmount + order.DepositAmount + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: total, + BalanceType: "frozen", + BizType: "channel_order_lock", + BizNo: firstNonEmpty(providerBizNo, order.OrderNo), + Remark: "渠道支付成功冻结租金和押金", + }); err != nil { + return err + } + + order.Status = "pending_handoff" + order.HandoffStatus = "pending_owner" + listing.Status = "rented" + account.Status = "rented" + conv, err := chat.EnsureOrderConversation(tx, order) + if err != nil { + return err + } + newConvID = conv.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 +} + func (r *Repository) Cancel(userID uint64, orderID uint64) error { return r.db.Transaction(func(tx *gorm.DB) error { var order model.RentalOrder @@ -1067,6 +1154,15 @@ func isTerminalStatus(status string) bool { return status == "completed" || status == "cancelled" || status == "closed" } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) { var record model.HandoffRecord if err := r.db.First(&record, id).Error; err != nil { diff --git a/backend/internal/modules/payment/dto.go b/backend/internal/modules/payment/dto.go new file mode 100644 index 0000000..d83c3db --- /dev/null +++ b/backend/internal/modules/payment/dto.go @@ -0,0 +1,40 @@ +package payment + +import "time" + +type StartPaymentRequest struct { + PayWay string `json:"pay_way"` + JSPayFlag string `json:"jspay_flag"` +} + +type WalletRechargePaymentRequest struct { + Amount float64 `json:"amount"` + PayWay string `json:"pay_way"` + JSPayFlag string `json:"jspay_flag"` +} + +type PaymentDTO struct { + ID uint64 `json:"id"` + PaymentNo string `json:"payment_no"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Provider string `json:"provider"` + ThirdOrderID string `json:"third_order_id"` + ProviderOrderID string `json:"provider_order_id"` + PayWay string `json:"pay_way"` + JSPayFlag string `json:"jspay_flag"` + AmountCent int64 `json:"amount_cent"` + Status string `json:"status"` + TDCode string `json:"td_code,omitempty"` + JSPayURL string `json:"jspay_url,omitempty"` + JSPayInfo string `json:"jspay_info,omitempty"` + Paid bool `json:"paid"` + PaidAt *time.Time `json:"paid_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type NotifyResult struct { + OK bool + Message string +} diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go new file mode 100644 index 0000000..42d3ca0 --- /dev/null +++ b/backend/internal/modules/payment/handler.go @@ -0,0 +1,155 @@ +package payment + +import ( + "errors" + "io" + "net/http" + "strconv" + + "hfb_sys/backend/internal/integrations/payment/leshua" + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Start(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + orderID, ok := parseID(c) + if !ok { + return + } + var req StartPaymentRequest + _ = c.ShouldBindJSON(&req) + item, err := h.service.Start(userID, orderID, req, c.ClientIP()) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Query(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + orderID, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Query(userID, orderID) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) WalletRecharge(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + var req WalletRechargePaymentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "充值金额不正确") + return + } + item, err := h.service.StartWalletRecharge(userID, req, c.ClientIP()) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) WalletRechargeQuery(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + paymentID, ok := parseID(c) + if !ok { + return + } + item, err := h.service.QueryWalletRecharge(userID, paymentID) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) LeshuaNotify(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.String(http.StatusBadRequest, "FAIL") + return + } + params, err := leshua.ParsePayload(body) + if err != nil { + c.String(http.StatusBadRequest, "FAIL") + return + } + result, err := h.service.HandleLeshuaNotify(params) + if err != nil || result == nil || !result.OK { + c.String(http.StatusOK, "FAIL") + return + } + c.String(http.StatusOK, result.Message) +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + userID, ok := value.(uint64) + return userID, ok +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func writePaymentError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrPaymentUnavailable): + response.Error(c, http.StatusBadGateway, "payment_unavailable", "支付渠道暂不可用") + case errors.Is(err, ErrPaymentCannotStart), errors.Is(err, order.ErrOrderCannotPay): + response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付") + case errors.Is(err, ErrPaymentVerifyFailed): + response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败") + case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err): + response.Error(c, http.StatusNotFound, "payment_not_found", "支付单不存在") + case errors.Is(err, order.ErrListingUnavailable): + response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租") + default: + response.Error(c, http.StatusInternalServerError, "payment_error", "支付处理失败") + } +} diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go new file mode 100644 index 0000000..b6afd88 --- /dev/null +++ b/backend/internal/modules/payment/repository.go @@ -0,0 +1,516 @@ +package payment + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "time" + + "hfb_sys/backend/internal/config" + "hfb_sys/backend/internal/integrations/payment/leshua" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/internal/modules/wallet" + + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB + cfg config.PaymentConfig + orderRepo *order.Repository + walletRepo *wallet.Repository + leshua *leshua.Client + provider string + isMockMode bool +} + +func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository { + provider := cfg.Provider + if provider == "" { + provider = "mock" + } + return &Repository{ + db: db, + cfg: cfg, + orderRepo: orderRepo, + walletRepo: walletRepo, + leshua: leshua.NewClient(cfg.Leshua), + provider: provider, + isMockMode: provider != "leshua", + } +} + +func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { + payment, orderRow, err := r.preparePayment(userID, orderID, req) + if err != nil { + return nil, err + } + if payment.Status == "paid" { + dto := toDTO(*payment) + return &dto, nil + } + if r.isMockMode { + if err := r.confirmPaid(payment, "2", time.Now(), map[string]string{ + "mock": "true", + "third_order_id": payment.ThirdOrderID, + "leshua_order_id": payment.ProviderOrderID, + "status": "2", + }); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil + } + if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") { + dto := toDTO(*payment) + return &dto, nil + } + + resp, rawReq, err := r.leshua.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + AmountCent: payment.AmountCent, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + NotifyURL: r.cfg.Leshua.NotifyURL, + JumpURL: r.cfg.Leshua.JumpURL, + ClientIP: clientIP, + Body: "租号订单 " + orderRow.OrderNo, + Attach: orderRow.OrderNo, + }) + if err != nil { + _ = r.markPaymentFailed(payment.ID, nil, err.Error()) + return nil, err + } + if resp.RespCode != "0" || resp.ResultCode != "0" { + _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) + return nil, ErrPaymentUnavailable + } + if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "paying", + "provider_order_id": resp.ProviderOrderID, + "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), + "td_code": resp.TDCode, + "jspay_url": resp.JSPayURL, + "jspay_info": resp.JSPayInfo, + "raw_request": jsonMap(rawReq), + "raw_response": jsonMap(resp.Raw), + }).Error; err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} + +func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { + amountCent := moneyCent(req.Amount) + if userID == 0 || amountCent <= 0 { + return nil, ErrPaymentCannotStart + } + payment, err := r.createWalletRechargePayment(userID, amountCent, req) + if err != nil { + return nil, err + } + if r.isMockMode { + if err := r.confirmPaid(payment, "2", time.Now(), map[string]string{ + "mock": "true", + "third_order_id": payment.ThirdOrderID, + "leshua_order_id": payment.ProviderOrderID, + "status": "2", + }); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil + } + resp, rawReq, err := r.leshua.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + AmountCent: payment.AmountCent, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + NotifyURL: r.cfg.Leshua.NotifyURL, + JumpURL: r.cfg.Leshua.JumpURL, + ClientIP: clientIP, + Body: "钱包充值 " + payment.PaymentNo, + Attach: payment.PaymentNo, + }) + if err != nil { + _ = r.markPaymentFailed(payment.ID, nil, err.Error()) + return nil, err + } + if resp.RespCode != "0" || resp.ResultCode != "0" { + _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) + return nil, ErrPaymentUnavailable + } + if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "paying", + "provider_order_id": resp.ProviderOrderID, + "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), + "td_code": resp.TDCode, + "jspay_url": resp.JSPayURL, + "jspay_info": resp.JSPayInfo, + "raw_request": jsonMap(rawReq), + "raw_response": jsonMap(resp.Raw), + }).Error; err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} + +func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) { + var payment model.PaymentOrder + if err := r.db.Where("id = ? AND user_id = ? AND order_id = 0", paymentID, userID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + if payment.Status == "paid" || r.isMockMode { + dto := toDTO(payment) + return &dto, nil + } + resp, err := r.leshua.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return nil, err + } + if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} + +func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { + var payment model.PaymentOrder + if err := r.db.Where("order_id = ? AND user_id = ?", orderID, userID).Order("id DESC").First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + if payment.Status == "paid" || r.isMockMode { + dto := toDTO(payment) + return &dto, nil + } + resp, err := r.leshua.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return nil, err + } + if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} + +func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) { + if !r.isMockMode && !r.leshua.VerifyNotify(params) { + return nil, ErrPaymentVerifyFailed + } + thirdOrderID := params["third_order_id"] + if thirdOrderID == "" { + return nil, ErrPaymentNotFound + } + var payment model.PaymentOrder + if err := r.db.Where("third_order_id = ?", thirdOrderID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + 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 { + return nil, err + } + return &NotifyResult{OK: true, Message: "000000"}, nil +} + +func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaymentRequest) (*model.PaymentOrder, *model.RentalOrder, error) { + var paymentID uint64 + var orderRow model.RentalOrder + err := r.db.Transaction(func(tx *gorm.DB) error { + var row model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND renter_id = ?", orderID, userID). + First(&row).Error; err != nil { + return err + } + if row.Status != "pending_payment" { + return ErrPaymentCannotStart + } + amountCent := moneyCent(row.RentAmount + row.DepositAmount) + if amountCent <= 0 { + return ErrPaymentCannotStart + } + var existing model.PaymentOrder + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ?", row.ID). + Order("id DESC"). + First(&existing).Error + if err == nil { + existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, r.cfg.Leshua.PayWay, "ZFBZF") + existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2") + existing.AmountCent = amountCent + existing.Provider = r.provider + existing.MerchantID = r.cfg.Leshua.MerchantID + if r.isMockMode && existing.ProviderOrderID == "" { + existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID + } + if err := tx.Save(&existing).Error; err != nil { + return err + } + paymentID = existing.ID + orderRow = row + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + paymentNo, err := newPaymentNo() + if err != nil { + return err + } + payment := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: row.ID, + OrderNo: row.OrderNo, + UserID: row.RenterID, + Provider: r.provider, + MerchantID: r.cfg.Leshua.MerchantID, + ThirdOrderID: row.OrderNo, + ProviderOrderID: "", + PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"), + AmountCent: amountCent, + Status: "created", + } + if r.isMockMode { + payment.ProviderOrderID = "MOCK" + row.OrderNo + payment.TDCode = "mock://leshua/pay/" + row.OrderNo + } + if err := tx.Create(&payment).Error; err != nil { + return err + } + paymentID = payment.ID + orderRow = row + return nil + }) + if err != nil { + return nil, nil, err + } + payment, err := r.findPaymentByID(paymentID) + if err != nil { + return nil, nil, err + } + return payment, &orderRow, nil +} + +func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64, req WalletRechargePaymentRequest) (*model.PaymentOrder, error) { + paymentNo, err := newPaymentNo() + if err != nil { + return nil, err + } + payment := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: 0, + OrderNo: paymentNo, + UserID: userID, + Provider: r.provider, + MerchantID: r.cfg.Leshua.MerchantID, + ThirdOrderID: paymentNo, + ProviderOrderID: "", + PayWay: firstNonEmpty(req.PayWay, r.cfg.Leshua.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, r.cfg.Leshua.JSPayFlag, "2"), + AmountCent: amountCent, + Status: "created", + } + if r.isMockMode { + payment.ProviderOrderID = "MOCK" + paymentNo + payment.TDCode = "mock://leshua/recharge/" + paymentNo + } + if err := r.db.Create(&payment).Error; err != nil { + return nil, err + } + return &payment, nil +} + +func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string) error { + switch status { + case "2", "30": + paidAt := parseLeshuaTime(payTime) + if paidAt == nil { + now := time.Now() + paidAt = &now + } + return r.confirmPaid(payment, status, *paidAt, raw) + 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 + 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 + 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 + } +} + +func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string) error { + if payment.Status != "paid" { + if payment.OrderID == 0 { + if r.walletRepo == nil { + return ErrDependencyUnavailable + } + if err := r.walletRepo.ConfirmRechargeFromChannel(payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil { + return err + } + } else { + if r.orderRepo == nil { + return ErrDependencyUnavailable + } + if err := r.orderRepo.ConfirmPaidFromChannel(payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil { + return err + } + } + } + return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "paid", + "provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID), + "raw_response": jsonMap(raw), + "paid_at": paidAt, + "notified_at": time.Now(), + }).Error +} + +func (r *Repository) markPaymentFailed(paymentID uint64, raw map[string]string, message string) error { + if raw == nil { + raw = map[string]string{"error": message} + } + return r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ + "status": "failed", + "raw_response": jsonMap(raw), + }).Error +} + +func (r *Repository) findPaymentByID(paymentID uint64) (*model.PaymentOrder, error) { + var payment model.PaymentOrder + if err := r.db.First(&payment, paymentID).Error; err != nil { + return nil, err + } + return &payment, nil +} + +func toDTO(payment model.PaymentOrder) PaymentDTO { + return PaymentDTO{ + ID: payment.ID, + PaymentNo: payment.PaymentNo, + OrderID: payment.OrderID, + OrderNo: payment.OrderNo, + Provider: payment.Provider, + ThirdOrderID: payment.ThirdOrderID, + ProviderOrderID: payment.ProviderOrderID, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + AmountCent: payment.AmountCent, + Status: payment.Status, + TDCode: payment.TDCode, + JSPayURL: payment.JSPayURL, + JSPayInfo: payment.JSPayInfo, + Paid: payment.Status == "paid", + PaidAt: payment.PaidAt, + CreatedAt: payment.CreatedAt, + UpdatedAt: payment.UpdatedAt, + } +} + +func moneyCent(value float64) int64 { + return int64(math.Round(value * 100)) +} + +func parseCent(value string) int64 { + var amount int64 + _, _ = fmt.Sscanf(value, "%d", &amount) + return amount +} + +func parseLeshuaTime(value string) *time.Time { + if value == "" { + return nil + } + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err == nil { + return &parsed + } + } + return nil +} + +func jsonMap(value map[string]string) datatypes.JSON { + if value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + return datatypes.JSON(raw) +} + +func newPaymentNo() (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("PAY%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go new file mode 100644 index 0000000..472fe53 --- /dev/null +++ b/backend/internal/modules/payment/service.go @@ -0,0 +1,66 @@ +package payment + +import "errors" + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrPaymentUnavailable = errors.New("payment unavailable") + ErrPaymentCannotStart = errors.New("payment cannot start") + ErrPaymentVerifyFailed = errors.New("payment verify failed") + ErrPaymentNotFound = errors.New("payment not found") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Start(userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 || orderID == 0 { + return nil, ErrPaymentCannotStart + } + return s.repo.Start(userID, orderID, req, clientIP) +} + +func (s *Service) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 || orderID == 0 { + return nil, ErrPaymentNotFound + } + return s.repo.Query(userID, orderID) +} + +func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 || req.Amount <= 0 { + return nil, ErrPaymentCannotStart + } + return s.repo.StartWalletRecharge(userID, req, clientIP) +} + +func (s *Service) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 || paymentID == 0 { + return nil, ErrPaymentNotFound + } + return s.repo.QueryWalletRecharge(userID, paymentID) +} + +func (s *Service) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.HandleLeshuaNotify(params) +} diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index f466c4e..80f08ce 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -81,6 +81,42 @@ func (r *Repository) Recharge(userID uint64, amount float64) (*AccountDTO, error return r.Account(userID) } +func (r *Repository) ConfirmRechargeFromChannel(userID uint64, bizNo string, amountCent int64) error { + if userID == 0 || amountCent <= 0 || bizNo == "" { + return ErrInvalidAmount + } + amount := roundWalletMoney(float64(amountCent) / 100) + return r.db.Transaction(func(tx *gorm.DB) error { + if err := ensureAccount(tx, userID); err != nil { + return err + } + var account model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ?", userID). + First(&account).Error; err != nil { + return err + } + var existing int64 + if err := tx.Model(&model.WalletLedger{}). + Where("user_id = ? AND biz_type = ? AND biz_no = ?", userID, "channel_recharge", bizNo). + Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return nil + } + return AppendEntries(tx, Entry{ + UserID: userID, + Direction: "in", + Amount: amount, + BalanceType: "available", + BizType: "channel_recharge", + BizNo: bizNo, + Remark: "渠道充值入账", + }) + }) +} + func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) { db := r.db.Table("wallet_ledger AS wl"). Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f8ddc3f..f53feab 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -21,6 +21,7 @@ import ( "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/internal/modules/payment" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/user" @@ -98,6 +99,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } walletService := wallet.NewService(walletRepo) walletHandler := wallet.NewHandler(walletService) + var paymentRepo *payment.Repository + if deps.DB != nil { + paymentRepo = payment.NewRepository(deps.DB, cfg.Payment, orderRepo, walletRepo) + } + paymentService := payment.NewService(paymentRepo) + paymentHandler := payment.NewHandler(paymentService) var notificationRepo *notification.Repository if deps.DB != nil { notificationRepo = notification.NewRepository(deps.DB) @@ -172,6 +179,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/public/files/object", fileHandler.PublicObject) + api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) openRoutes := api.Group("/open") { @@ -214,7 +222,8 @@ 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", orderHandler.Pay) + orderRoutes.POST("/:id/pay", paymentHandler.Start) + orderRoutes.POST("/:id/pay/query", paymentHandler.Query) orderRoutes.POST("/:id/cancel", orderHandler.Cancel) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) @@ -239,6 +248,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { walletRoutes.GET("/balance", walletHandler.Balance) walletRoutes.GET("/ledger", walletHandler.Ledger) walletRoutes.POST("/recharge", walletHandler.Recharge) + walletRoutes.POST("/recharge/pay", paymentHandler.WalletRecharge) + walletRoutes.POST("/recharge/pay/:id/query", paymentHandler.WalletRechargeQuery) } fileRoutes := api.Group("/files", requireAuth) diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index 6c36e93..6650434 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -190,6 +190,42 @@ CREATE TABLE IF NOT EXISTS wallet_ledger ( KEY idx_wallet_ledger_biz (biz_type, biz_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ------------------------------------------- +-- 支付渠道订单 +-- ------------------------------------------- + +CREATE TABLE IF NOT EXISTS payment_orders ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + payment_no VARCHAR(64) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + order_no VARCHAR(64) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(32) NOT NULL, + merchant_id VARCHAR(32) NOT NULL DEFAULT '', + third_order_id VARCHAR(64) NOT NULL, + provider_order_id VARCHAR(64) NOT NULL DEFAULT '', + pay_way VARCHAR(16) NOT NULL DEFAULT '', + jspay_flag VARCHAR(8) NOT NULL DEFAULT '', + amount_cent BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'created', + td_code VARCHAR(512) NOT NULL DEFAULT '', + jspay_url VARCHAR(512) NOT NULL DEFAULT '', + jspay_info TEXT NULL, + raw_request JSON NULL, + raw_response JSON NULL, + paid_at DATETIME NULL, + notified_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_payment_orders_payment_no (payment_no), + UNIQUE KEY uk_payment_orders_third_order_id (third_order_id), + KEY idx_payment_orders_order_id (order_id), + KEY idx_payment_orders_order_no (order_no), + KEY idx_payment_orders_user_id (user_id), + KEY idx_payment_orders_provider_order_id (provider_order_id), + KEY idx_payment_orders_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ------------------------------------------- -- 纠纷和通知表 -- ------------------------------------------- diff --git a/docs/api.md b/docs/api.md index 705d738..d2962b4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,8 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/orders` - `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` @@ -48,8 +50,11 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/disputes/{id}` - `GET /api/wallet/balance` - `GET /api/wallet/ledger` +- `POST /api/wallet/recharge/pay` +- `POST /api/wallet/recharge/pay/{id}/query` - `POST /api/files/upload` - `GET /api/files/object` +- `POST /api/payments/leshua/notify` - `GET /api/notifications` - `POST /api/notifications/{id}/read` - `GET /api/admin/auth/captcha` diff --git a/docs/乐刷支付接入文档.md b/docs/乐刷支付接入文档.md new file mode 100644 index 0000000..6fa8427 --- /dev/null +++ b/docs/乐刷支付接入文档.md @@ -0,0 +1,371 @@ +# 乐刷支付接入文档 + +本文根据语雀《联合收单接口文档 / 商户交易》整理,作为本项目接入乐刷扫码/简易支付的内部记录。原始文档地址: +https://www.yuque.com/hayley-boppa/ws2xbg/lk89dvro0oc69f1m + +## 接口总览 + +乐刷交易接口统一使用 `POST`,大部分能力共用网关路径: + +- 测试网关:`https://t-paygate.lepass.cn/cgi-bin/lepos_pay_gateway.cgi` +- 生产网关:`https://paygate.leshuazf.com/cgi-bin/lepos_pay_gateway.cgi` +- Content-Type:`application/x-www-form-urlencoded` +- 金额单位:分,RMB,最小 1 分,不允许小数。 +- 通信返回中的 `resp_code=0` 只表示通信成功,业务是否成功要看 `result_code` 和 `status`。 + +接口目录: + +| 场景 | service / 地址 | 说明 | 本期接入 | +| --- | --- | --- | --- | +| 统一下单 | `service=get_tdcode` | 主扫,C 扫 B,返回二维码链接或简易支付跳转链接 | 是 | +| 条码支付 | `service=upload_authcode` | 被扫,B 扫 C,用户出示付款码 | 否 | +| 支付结果通知 | 商户通知 URL | 乐刷推送支付成功等结果 | 是 | +| 扫码交易结果查询 | `service=query_status` | 查询交易结果 | 是 | +| 扫码退款 | `service=unified_refund` | 发起退款,退款有效期和次数受限制 | 暂缓 | +| 扫码退款查询 | `service=unified_query_refund` | 查询退款状态 | 暂缓 | +| 扫码退款通知 | 商户通知 URL | 乐刷推送退款状态 | 暂缓 | +| 订单关闭 | `service=close_order` | 关闭未支付订单 | 暂缓 | +| 授权码查询 openid | `service=query_openid` | 条码/JSAPI 配套 | 暂缓 | +| 授权码查询银联 user_id | `service=query_userid` | 银联 JS 支付配套 | 暂缓 | + +## 签名规则 + +请求、应答和通知都需要验签。当前代码只实现 MD5,SM3 先记录规则但不接入。 + +MD5 签名步骤: + +1. 取所有参与签名的参数集合 `M`。 +2. 默认过滤空值,按参数名 ASCII 升序排序。 +3. 按 `key=value` 拼接为 `key1=value1&key2=value2`,字段名和值使用原始值,不做 URL Encode。 +4. 末尾追加 `&key=商户请求密钥`。 +5. 对最终字符串做 MD5,结果转大写。 + +注意: + +- 请求签名:一般不包含 `sign` 本身。 +- 应答验签:按乐刷返回参数验签,实际返回字段可能因升级增加,验签时要允许新增字段。 +- 支付/退款通知验签:`error_code` 和 `sign` 不参与签名,空值参与签名,密钥使用乐刷提供的通知验签密钥。当前实现为 `LESHUA_NOTIFY_KEY`,为空时回退 `LESHUA_SIGN_KEY`。 +- `sign_type=SM3` 时签名结果为 64 位;不上传 `sign_type` 默认 MD5。 + +## 统一下单 + +用于主扫支付,消费者主动扫码或打开简易支付收银台。本项目先接扫码/简易支付。 + +请求固定字段: + +| 字段 | 必填 | 说明 | 本项目取值 | +| --- | --- | --- | --- | +| `service` | 是 | 接口名 | `get_tdcode` | +| `merchant_id` | 是 | 乐刷商户号 | `LESHUA_MERCHANT_ID` | +| `third_order_id` | 是 | 商户内部订单号,同商户下唯一 | 当前使用订单号 `order_no` | +| `amount` | 是 | 订单金额,单位分 | 租金 + 押金 | +| `pay_way` | 是 | 支付类型 | 默认 `ZFBZF`,可配置 | +| `jspay_flag` | 是 | 支付形态 | 默认 `2` 简易支付 | +| `nonce_str` | 是 | 32 位随机字符串 | 后端生成 | +| `sign` | 是 | 签名 | MD5 | + +常用可选字段: + +| 字段 | 说明 | +| --- | --- | +| `sign_type` | `MD5` 或 `SM3`,不传默认 MD5。当前只支持 MD5。 | +| `notify_url` | 支付结果通知地址,必须是乐刷可访问的绝对路径。 | +| `jump_url` | 简易支付前台跳转地址。文档写 `jspay_flag=2` 时必传,但也提示受微信支付宝限制可不传;生产建议配置。 | +| `client_ip` | 发起交易的客户端 IP。 | +| `body` | 商品描述,不能包含换行等特殊字符。 | +| `attach` | 附加数据,成功后原样返回。 | +| `order_expiration` | 订单有效期,单位秒,最大 600 秒;支付宝建议 60 的整数倍。 | + +`jspay_flag`: + +| 值 | 说明 | +| --- | --- | +| `0` | 支付宝 Native、银联 Native 扫码支付 | +| `1` | 微信/支付宝/银联 JSAPI | +| `2` | 微信、支付宝简易支付,跳转乐刷收银台 | +| `3` | 微信/支付宝小程序 | + +`pay_way`: + +| 值 | 说明 | +| --- | --- | +| `WXZF` | 微信 | +| `ZFBZF` | 支付宝 | +| `UPSMZF` / `UPSMPAY` | 银联二维码,不同章节/回调字段可能返回不同值 | +| `DCPAY` | 数字货币 | + +下单返回重点字段: + +| 字段 | 说明 | +| --- | --- | +| `resp_code` | 通信状态,`0` 成功 | +| `result_code` | 业务结果,`0` 成功 | +| `error_code` / `error_msg` | 错误码和错误描述 | +| `merchant_id` | 乐刷商户号 | +| `third_order_id` | 商户订单号 | +| `leshua_order_id` | 乐刷订单号 | +| `td_code` | 二维码链接,`jspay_flag=0/1/2` 可能返回 | +| `jspay_url` | 简易支付跳转地址 | +| `jspay_info` | JSAPI/小程序/银联 JS 支付信息 | +| `pay_way` | 支付类型 | +| `sign` | 返回签名 | + +## 支付结果通知 + +通知采用 HTTP `POST`,建议使用 `application/x-www-form-urlencoded` 接收。支付通知可能延迟或失败,文档建议主接交易查询接口。本项目同时接通知和查单。 + +通知特点: + +- 支付成功后乐刷一般约 30 秒推送通知。 +- 通知频率示例:`0s / 15s / 30s / 1m / 4m / 34m / 64m / 94m / 124m / 184m`。 +- 回调处理时间要求 5 秒内。 +- 乐刷可能重复通知,业务必须幂等。 +- 通知成功响应必须是纯文本 `000000`,没有 JSON 和引号;其他响应或超时都会重试。 + +支付成功通知关键字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `sign` | 是 | MD5 签名 | +| `sign_type` | 否 | 签名类型 | +| `merchant_id` | 是 | 乐刷商户号 | +| `leshua_order_id` | 是 | 乐刷订单号 | +| `third_order_id` | 是 | 商户订单号 | +| `amount` | 是 | 订单金额,分 | +| `status` | 是 | 订单状态 | +| `pay_way` | 是 | 支付类型 | +| `pay_time` | 是 | 支付时间,如 `2018-08-20 19:19:19` | +| `bank_type` | 否 | 银行类型 | +| `openid` / `sub_openid` | 否 | 用户标识 | +| `out_transaction_id` | 否 | 微信、支付宝等上游订单号 | +| `attach` | 否 | 下单附加数据原样返回 | +| `trade_type` | 否 | `MICROPAY`、`NATIVE`、`JSAPI`、`SmPgPay`、`JSAPIQuick` 等 | +| `channel_order_id` | 否 | 通道订单号 | +| `settlement_amount` | 否 | 实际结算金额,分 | +| `discount_amount` | 否 | 优惠金额,分 | + +通知验签规则: + +- `error_code`、`sign` 不参与签名。 +- 空值参与签名。 +- 使用乐刷提供的通知验签密钥。 + +## 扫码交易结果查询 + +查询接口用于主动确认交易状态。文档要求:除非返回明确的支付成功、支付失败、订单关闭等最终状态,其他错误或未支付状态都需要继续查询;超过一定时间未支付可调用关单。 + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `service` | 是 | 固定 `query_status` | +| `merchant_id` | 是 | 乐刷商户号 | +| `third_order_id` | 二选一 | 商户订单号 | +| `leshua_order_id` | 二选一 | 乐刷订单号,优先使用 | +| `nonce_str` | 是 | 随机字符串 | +| `sign_type` | 否 | 签名类型 | +| `sign` | 是 | 签名 | + +返回重点字段: + +| 字段 | 说明 | +| --- | --- | +| `resp_code` / `result_code` | 通信和业务结果 | +| `status` | 订单状态 | +| `leshua_order_id` | 乐刷订单号 | +| `third_order_id` | 商户订单号 | +| `amount` | 订单金额 | +| `pay_way` | 支付类型 | +| `pay_time` | 支付完成时间,成功时返回 | +| `refund_amount` | 已退款金额 | +| `simple_url_flag` | `1` 表示简易支付 | +| `interm_state` | 支付中间态,如 `NOTPAY`、`USERPAYING` | + +## 退款与退款查询 + +本期暂不实现退款,但记录接口便于后续接入。 + +退款限制: + +- 超过三个月或发起后超过 60 天的订单无法退款,具体以乐刷返回为准。 +- 同一交易订单最多发起 50 次部分退款。 +- 退款接口一般返回退款中,如需最终状态要调用退款查询。 + +退款请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `service` | 是 | 固定 `unified_refund` | +| `merchant_id` | 是 | 乐刷商户号 | +| `third_order_id` | 二选一 | 商户订单号 | +| `leshua_order_id` | 二选一 | 乐刷订单号,优先使用 | +| `merchant_refund_id` | 是 | 商户退款单号,同商户下唯一,不能含 `_` 等特殊字符 | +| `refund_amount` | 是 | 退款金额,分 | +| `refund_type` | 否 | `0` 不使用余额,`1` 可使用余额,`2` 仅使用余额 | +| `notify_url` | 否 | 退款结果通知地址 | +| `nonce_str` | 是 | 随机字符串 | +| `sign` | 是 | 签名 | + +退款查询请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `service` | 是 | 固定 `unified_query_refund` | +| `merchant_id` | 是 | 乐刷商户号 | +| `third_order_id` / `leshua_order_id` | 二选一 | 支付订单标识 | +| `merchant_refund_id` / `leshua_refund_id` | 二选一 | 退款单标识 | +| `nonce_str` | 是 | 随机字符串 | +| `sign` | 是 | 签名 | + +退款返回/通知重点字段: + +| 字段 | 说明 | +| --- | --- | +| `status` | 退款状态,见订单状态表 | +| `refund_amount` | 退款金额 | +| `merchant_refund_id` | 商户退款单号 | +| `leshua_refund_id` | 乐刷退款单号 | +| `total_amount` | 原订单总金额 | +| `order_balance` | 退款后订单余额,退款请求返回可能有 | +| `refund_time` | 退款成功时间 | +| `settlement_refund_amount` | 实际退款金额 | +| `discount_refund_amount` | 优惠退款金额 | +| `failure_reason` | 退款失败原因 | + +退款通知成功也需要返回纯文本 `000000`。 + +## 订单关闭 + +用于关闭未支付订单。本期暂不实现。 + +限制: + +- 条码支付订单需要下单 15 秒后才允许关单。 +- 统一下单订单无此 15 秒限制。 +- 银联二维码不支持关单。 + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `service` | 是 | 固定 `close_order` | +| `merchant_id` | 是 | 乐刷商户号 | +| `third_order_id` | 二选一 | 商户订单号 | +| `leshua_order_id` | 二选一 | 乐刷订单号,优先使用 | +| `nonce_str` | 是 | 随机字符串 | +| `sign` | 是 | 签名 | + +返回字段包含 `status`、`leshua_order_id`、`pay_way`、`amount` 等。 + +## 授权码相关 + +授权码接口主要服务条码支付、微信 JSAPI、银联 JS 支付,本期不接。 + +查询微信 openid: + +- `service=query_openid` +- 必填:`merchant_id`、`auth_code`、`appid`、`nonce_str`、`sign` +- 返回:`openid`、`sub_openid` 等。 + +查询银联云闪付 user_id: + +- `service=query_userid` +- 必填:`merchant_id`、`user_auth_code`、`nonce_str`、`sign` +- 可选/条件:`app_up_identifier` +- 返回:`user_id` 等。 + +## 状态码 + +扫码/异步通知常见订单状态: + +| 值 | 说明 | 本项目处理 | +| --- | --- | --- | +| `0` | 支付中 | `paying` | +| `2` | 支付成功 | `paid`,推进订单待交接 | +| `6` | 订单关闭 | `closed` | +| `8` | 支付失败 | `failed` | +| `10` | 退款中 | 暂按 `paying`/后续扩展退款状态 | +| `11` | 退款成功 | 暂缓 | +| `12` | 退款失败 | 暂缓 | +| `30` | 刷卡支付成功 | 当前也按支付成功处理 | +| `31` | 刷卡撤销成功 | 暂缓 | +| `32` | 刷卡退货成功 | 暂缓 | +| `33` | 刷卡冲正成功 | 暂缓 | +| `41` | 预授权成功 | 暂缓 | +| `43` | 预授权撤销成功 | 暂缓 | +| `45` | 预授权完成请求成功 | 暂缓 | +| `47` | 预授权完成通知成功 | 暂缓 | +| `49` | 预授权完成撤销成功 | 暂缓 | + +## 常见错误码 + +| 错误码 | 说明 | +| --- | --- | +| `-20001` | 参数错误或无效 | +| `-20002` / `-1002` | 无效乐刷商户号 | +| `-20003` | 商户订单号格式有误 | +| `-20004` / `-4006` | 乐刷订单不存在 | +| `-20005` | 服务错误 | +| `-20006` | 验签失败 | +| `-20007` | 签名 Key 未配置 | +| `-20008` | 支付类型错误 | +| `-20009` | 订单金额填写有误 | +| `-20010` / `-4027` | 系统错误/系统异常 | +| `-20011` | 非法参数 | +| `-20012` | 授权码为空 | +| `-20013` | 非法授权码 | +| `-20014` | 未知类型授权码 | +| `-20015` | 非法公众号支付标识 | +| `-20016` | 无效乐刷订单号 | +| `-20018` | 无效第三方订单号或乐刷订单号 | +| `-20019` | 订单状态不允许退款 | +| `-20020` | 订单未退款 | +| `-20023` / `-5017` | 订单可退余额不足 | +| `-20024` | 乐刷或第三方订单号必填 1 个 | +| `-20025` | 乐刷或第三方退款订单号必填 1 个 | +| `-20026` / `-1006` | 第三方订单号已存在 | +| `-20027` | 该订单已支付 | +| `-4004` | 订单正在处理中 | +| `-5103` | 商户退款 ID 已存在 | +| `-5014` | 商户无退款权限 | +| `-5015` | 暂不支持退款 | +| `-5018` | 订单退款次数超限 | +| `-2125` | 交易被风控拦截 | +| `-5035` | 下单异常,可能是通道网络、通道状态或通道商户异常,以 `error_msg` 为准 | + +## 本项目接入映射 + +后端环境变量: + +| 变量 | 说明 | +| --- | --- | +| `PAYMENT_PROVIDER` | `mock` 或 `leshua`。本地默认 `mock`,生产设为 `leshua`。 | +| `LESHUA_GATEWAY_URL` | 乐刷网关地址。 | +| `LESHUA_MERCHANT_ID` | 乐刷商户号。 | +| `LESHUA_SIGN_KEY` | 请求签名密钥。 | +| `LESHUA_NOTIFY_KEY` | 通知验签密钥。 | +| `LESHUA_NOTIFY_URL` | 支付结果通知地址,公网绝对 URL。 | +| `LESHUA_JUMP_URL` | 简易支付完成后的跳转地址。 | +| `LESHUA_PAY_WAY` | 默认支付方式,当前默认 `ZFBZF`。 | +| `LESHUA_JSPAY_FLAG` | 默认支付形态,当前默认 `2`。 | +| `LESHUA_SIGN_TYPE` | 当前仅支持 `MD5`。 | + +后端 API: + +| API | 说明 | +| --- | --- | +| `POST /api/orders/:id/pay` | 创建或复用支付单,mock 模式会立即模拟渠道支付成功。 | +| `POST /api/orders/:id/pay/query` | 查询支付单并主动向乐刷查单。 | +| `POST /api/payments/leshua/notify` | 乐刷支付通知回调,不需要登录鉴权,成功返回 `000000`。 | + +数据模型: + +- 新增 `payment_orders` 表保存支付单、渠道单号、支付链接、请求/响应原文、状态和支付时间。 +- `third_order_id` 当前使用租号订单号,后续如果要支持关闭后重新发起多次支付,应改为支付单号或订单号加支付轮次。 +- 渠道确认支付成功后,不扣用户 `available`,直接向租客 `frozen` 写入 `channel_order_lock`,并推进订单到 `pending_handoff`,后续结账沿用现有冻结释放/结算逻辑。 + +当前范围: + +- 已接:扫码/简易支付下单、查单、支付成功通知、mock 跑通链路。 +- 暂缓:条码支付、JSAPI/小程序必要 openid 获取、退款、退款通知、关单、刷卡交易查询、SM3 签名。 + diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 8e5304f..d7ac833 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -70,6 +70,27 @@ export interface HandoffRecord { created_at: string } +export interface PaymentOrder { + id: number + payment_no: string + order_id: number + order_no: string + provider: string + third_order_id: string + provider_order_id: string + pay_way: string + jspay_flag: string + amount_cent: number + status: string + td_code?: string + jspay_url?: string + jspay_info?: string + paid: boolean + paid_at?: string + created_at: string + updated_at: string +} + export interface SubmitCheckoutPayload { content: string consumable_amount: number @@ -95,7 +116,12 @@ export async function createOrder(listingId: number) { } export async function payOrder(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/pay`) + 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`) return data.data } diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts index 6e69820..89dcaaf 100644 --- a/frontend/src/api/wallet.ts +++ b/frontend/src/api/wallet.ts @@ -2,6 +2,7 @@ import { apiClient } from './client' import type { ApiResponse, PaginatedResult } from './types' import type { BalanceType, LedgerDirection, WalletStatus } from '@/types/status' +import type { PaymentOrder } from './orders' export interface WalletAccount { user_id: number @@ -41,3 +42,13 @@ export async function rechargeWallet(amount: number) { const { data } = await apiClient.post>('/wallet/recharge', { amount }) return data.data } + +export async function startWalletRechargePayment(amount: number) { + const { data } = await apiClient.post>('/wallet/recharge/pay', { amount }) + return data.data +} + +export async function queryWalletRechargePayment(id: number) { + const { data } = await apiClient.post>(`/wallet/recharge/pay/${id}/query`) + return data.data +} diff --git a/frontend/src/views/account/WalletView.vue b/frontend/src/views/account/WalletView.vue index 2240fa7..8fe1644 100644 --- a/frontend/src/views/account/WalletView.vue +++ b/frontend/src/views/account/WalletView.vue @@ -1,8 +1,16 @@