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 @@