diff --git a/backend/internal/integrations/payment/lakala/client.go b/backend/internal/integrations/payment/lakala/client.go new file mode 100644 index 0000000..26777fc --- /dev/null +++ b/backend/internal/integrations/payment/lakala/client.go @@ -0,0 +1,747 @@ +package lakala + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +var ( + ErrConfigIncomplete = errors.New("lakala payment config incomplete") + ErrSignFailed = errors.New("lakala sign failed") + ErrVerifyFailed = errors.New("lakala verify failed") +) + +const ( + endpointCounterCreate = "/api/v3/ccss/counter/order/special_create" + endpointCounterQuery = "/api/v3/ccss/counter/order/query" + endpointRefund = "/api/v3/labs/relation/refund" + endpointRefundQuery = "/api/v3/labs/query/idmrefundquery" +) + +type Config struct { + GatewayURL string + AppID string + SerialNo string + MerchantID string + TermNo string + PrivateKey string + LakalaCert string + NotifyCert string + NotifyURL string + JumpURL string + PayWay string + JSPayFlag string + PayMode string + OrderExpireMinutes int +} + +type Client struct { + cfg Config + httpClient *http.Client + privateKey *rsa.PrivateKey + lakalaCert *x509.Certificate + notifyCert *x509.Certificate +} + +type CreatePaymentRequest struct { + ThirdOrderID string + AmountCent int64 + PayWay string + JSPayFlag string + NotifyURL string + JumpURL string + ClientIP string + Body string + Attach string +} + +type CreatePaymentResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + PayWay string + Status string + PayTime string + TDCode string + JSPayURL string + JSPayInfo string + Raw map[string]string + RawRequest map[string]string +} + +type QueryPaymentResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + Status string + Amount string + PayWay string + PayTime string + Raw map[string]string +} + +type CreateRefundRequest struct { + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + RefundAmountCent int64 + NotifyURL string + Attach string + RefundReason string + ClientIP string +} + +type CreateRefundResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + ProviderRefundID string + Status string + RefundAmount string + Raw map[string]string + RawRequest map[string]string +} + +type QueryRefundRequest struct { + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + ProviderRefundID string +} + +type QueryRefundResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + ProviderRefundID string + Status string + RefundAmount string + RefundTime string + Raw map[string]string +} + +type VerifyNotifyResult struct { + OK bool + MatchedKey string + Got string + Expected map[string]string + BaseString map[string]string + ParamKeys []string +} + +func NewClient(cfg Config) *Client { + return &Client{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error) { + if err := c.initCrypto(); err != nil { + return nil, err + } + payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF") + payMode := firstNonEmpty(c.cfg.PayMode, payModeFromPayWay(payWay), "ALIPAY") + expireMinutes := c.cfg.OrderExpireMinutes + if expireMinutes <= 0 { + expireMinutes = 15 + } + reqData := map[string]any{ + "out_order_no": req.ThirdOrderID, + "merchant_no": c.cfg.MerchantID, + "total_amount": req.AmountCent, + "order_efficient_time": time.Now().Add(time.Duration(expireMinutes) * time.Minute).Format("20060102150405"), + "notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL), + "support_refund": 1, + "support_repeat_pay": 1, + "support_cancel": 0, + "counter_param": fmt.Sprintf(`{"pay_mode":"%s"}`, payMode), + "order_info": sanitizeText(req.Body, 128), + } + if c.cfg.TermNo != "" { + reqData["term_no"] = c.cfg.TermNo + } + if jumpURL := firstNonEmpty(req.JumpURL, c.cfg.JumpURL); jumpURL != "" { + reqData["callback_url"] = jumpURL + } + if req.Attach != "" { + reqData["counter_remark"] = sanitizeText(req.Attach, 128) + } + rawReq := stringifyMap(reqData) + raw, err := c.post(ctx, endpointCounterCreate, reqData) + if err != nil { + return nil, err + } + return &CreatePaymentResponse{ + OK: responseOK(raw), + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["out_order_no"], req.ThirdOrderID), + ProviderOrderID: firstNonEmpty(raw["pay_order_no"], raw["trade_no"], raw["log_no"]), + PayWay: payWay, + Status: normalizePaymentStatus(raw), + PayTime: firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"]), + TDCode: firstNonEmpty(raw["qr_code"], raw["code_url"], raw["pay_url"], raw["counter_url"]), + JSPayURL: firstNonEmpty(raw["counter_url"], raw["pay_url"], raw["qr_code"], raw["code_url"]), + JSPayInfo: firstNonEmpty(raw["pay_info"], raw["credential"], raw["credential_json"], raw["req_data"]), + Raw: raw, + RawRequest: rawReq, + }, nil +} + +func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID string) (*QueryPaymentResponse, error) { + if err := c.initCrypto(); err != nil { + return nil, err + } + reqData := map[string]any{ + "merchant_no": c.cfg.MerchantID, + } + if providerOrderID != "" { + reqData["pay_order_no"] = providerOrderID + } else { + reqData["out_order_no"] = thirdOrderID + } + raw, err := c.post(ctx, endpointCounterQuery, reqData) + if err != nil { + return nil, err + } + return &QueryPaymentResponse{ + OK: responseOK(raw), + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["out_order_no"], thirdOrderID), + ProviderOrderID: firstNonEmpty(raw["pay_order_no"], raw["trade_no"], providerOrderID), + Status: normalizePaymentStatus(raw), + Amount: firstNonEmpty(raw["total_amount"], raw["amount"], raw["trade_amount"]), + PayWay: firstNonEmpty(raw["pay_mode"], raw["account_type"]), + PayTime: firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"]), + Raw: raw, + }, nil +} + +func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, error) { + if err := c.initCrypto(); err != nil { + return nil, err + } + reqData := map[string]any{ + "merchant_no": c.cfg.MerchantID, + "term_no": c.cfg.TermNo, + "out_trade_no": req.MerchantRefundID, + "refund_amount": strconv.FormatInt(req.RefundAmountCent, 10), + "refund_reason": firstNonEmpty(req.RefundReason, "订单退款"), + "origin_out_trade_no": req.ThirdOrderID, + "origin_trade_no": req.ProviderOrderID, + "location_info": defaultLocationInfo(req.ClientIP), + } + if c.cfg.TermNo == "" { + delete(reqData, "term_no") + } + if req.ProviderOrderID == "" { + delete(reqData, "origin_trade_no") + } + rawReq := stringifyMap(reqData) + raw, err := c.post(ctx, endpointRefund, reqData) + if err != nil { + return nil, err + } + return &CreateRefundResponse{ + OK: responseOK(raw), + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["origin_out_trade_no"], req.ThirdOrderID), + ProviderOrderID: firstNonEmpty(raw["origin_trade_no"], req.ProviderOrderID), + MerchantRefundID: firstNonEmpty(raw["out_trade_no"], req.MerchantRefundID), + ProviderRefundID: firstNonEmpty(raw["refund_trade_no"], raw["trade_no"], raw["log_no"]), + Status: normalizeRefundStatus(raw), + RefundAmount: firstNonEmpty(raw["refund_amount"], strconv.FormatInt(req.RefundAmountCent, 10)), + Raw: raw, + RawRequest: rawReq, + }, nil +} + +func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) { + if err := c.initCrypto(); err != nil { + return nil, err + } + reqData := map[string]any{ + "merchant_no": c.cfg.MerchantID, + "term_no": c.cfg.TermNo, + } + if req.MerchantRefundID != "" { + reqData["out_trade_no"] = req.MerchantRefundID + } + if req.ThirdOrderID != "" { + reqData["origin_out_trade_no"] = req.ThirdOrderID + } + if req.ProviderOrderID != "" { + reqData["origin_trade_no"] = req.ProviderOrderID + } + if c.cfg.TermNo == "" { + delete(reqData, "term_no") + } + raw, err := c.post(ctx, endpointRefundQuery, reqData) + if err != nil { + return nil, err + } + return &QueryRefundResponse{ + OK: responseOK(raw), + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["origin_out_trade_no"], req.ThirdOrderID), + ProviderOrderID: firstNonEmpty(raw["origin_trade_no"], req.ProviderOrderID), + MerchantRefundID: firstNonEmpty(raw["out_trade_no"], req.MerchantRefundID), + ProviderRefundID: firstNonEmpty(raw["refund_trade_no"], raw["trade_no"], req.ProviderRefundID), + Status: normalizeRefundStatus(raw), + RefundAmount: raw["refund_amount"], + RefundTime: firstNonEmpty(raw["refund_time"], raw["trade_time"], raw["finish_time"]), + Raw: raw, + }, nil +} + +func (c *Client) VerifyNotifyDetail(rawPayload string, authorization string) VerifyNotifyResult { + result := VerifyNotifyResult{ + Expected: map[string]string{}, + BaseString: map[string]string{}, + } + if err := c.initCrypto(); err != nil { + result.Expected["error"] = err.Error() + return result + } + signature, base, err := parseAuthorization(authorization) + result.Got = signature + result.BaseString["notify_cert"] = base.message(rawPayload) + if err != nil { + result.Expected["error"] = err.Error() + return result + } + ok := verifyRSA(c.notifyCert, []byte(base.message(rawPayload)), signature) + result.OK = ok + if ok { + result.MatchedKey = "notify_cert" + } + return result +} + +func ParsePayload(body []byte) (map[string]string, error) { + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + raw := flattenJSON(payload) + normalizeNotifyAliases(raw) + return raw, nil +} + +func (c *Client) post(ctx context.Context, endpoint string, reqData map[string]any) (map[string]string, error) { + body, err := json.Marshal(map[string]any{ + "req_time": time.Now().Format("20060102150405"), + "version": "3.0", + "req_data": reqData, + }) + if err != nil { + return nil, err + } + url := strings.TrimRight(c.cfg.GatewayURL, "/") + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", c.authorization(string(body))) + req.Header.Set("lkl-op-sdk", "hfb-go-lakala") + req.Header.Set("lkl-op-flowgroup", "NORMAL") + req.Header.Set("lkl-op-appid", c.cfg.AppID) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("lakala http status %d: %s", resp.StatusCode, string(respBody)) + } + if err := c.verifyResponse(resp.Header, string(respBody)); err != nil { + return nil, err + } + var payload any + if len(respBody) == 0 { + return map[string]string{}, nil + } + if err := json.Unmarshal(respBody, &payload); err != nil { + return nil, err + } + raw := flattenJSON(payload) + normalizeNotifyAliases(raw) + return raw, nil +} + +func (c *Client) authorization(body string) string { + nonce := Nonce(32) + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + message := strings.Join([]string{c.cfg.AppID, c.cfg.SerialNo, timestamp, nonce, body, ""}, "\n") + signature, err := signRSA(c.privateKey, []byte(message)) + if err != nil { + return "" + } + return fmt.Sprintf( + `LKLAPI-SHA256withRSA appid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`, + c.cfg.AppID, + nonce, + timestamp, + c.cfg.SerialNo, + signature, + ) +} + +func (c *Client) verifyResponse(header http.Header, body string) error { + signature := header.Get("Lklapi-Signature") + if signature == "" { + return nil + } + appID := header.Get("Lklapi-Appid") + if appID == "" { + appID = c.cfg.AppID + } + serial := header.Get("Lklapi-Serial") + timestamp := header.Get("Lklapi-Timestamp") + nonce := header.Get("Lklapi-Nonce") + if serial == "" || timestamp == "" || nonce == "" { + return ErrVerifyFailed + } + message := strings.Join([]string{appID, serial, timestamp, nonce, body, ""}, "\n") + if !verifyRSA(c.lakalaCert, []byte(message), signature) { + return ErrVerifyFailed + } + return nil +} + +func (c *Client) initCrypto() error { + if strings.TrimSpace(c.cfg.GatewayURL) == "" || + strings.TrimSpace(c.cfg.AppID) == "" || + strings.TrimSpace(c.cfg.SerialNo) == "" || + strings.TrimSpace(c.cfg.MerchantID) == "" || + strings.TrimSpace(c.cfg.PrivateKey) == "" || + strings.TrimSpace(c.cfg.NotifyCert) == "" { + return ErrConfigIncomplete + } + if c.privateKey == nil { + key, err := parsePrivateKey(c.cfg.PrivateKey) + if err != nil { + return err + } + c.privateKey = key + } + if c.notifyCert == nil { + cert, err := parseCertificate(c.cfg.NotifyCert) + if err != nil { + return err + } + c.notifyCert = cert + } + if c.lakalaCert == nil && c.cfg.LakalaCert != "" { + cert, err := parseCertificate(c.cfg.LakalaCert) + if err != nil { + return err + } + c.lakalaCert = cert + } + if c.lakalaCert == nil { + c.lakalaCert = c.notifyCert + } + return nil +} + +type authorizationBase struct { + timestamp string + nonce string +} + +func (b authorizationBase) message(body string) string { + return strings.Join([]string{b.timestamp, b.nonce, body, ""}, "\n") +} + +func parseAuthorization(value string) (string, authorizationBase, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", authorizationBase{}, errors.New("empty authorization") + } + space := strings.Index(value, " ") + if space < 0 { + return "", authorizationBase{}, errors.New("invalid authorization") + } + items := strings.Split(value[space+1:], ",") + params := map[string]string{} + for _, item := range items { + parts := strings.SplitN(strings.TrimSpace(item), "=", 2) + if len(parts) != 2 { + continue + } + params[parts[0]] = strings.Trim(parts[1], `"`) + } + signature := params["signature"] + base := authorizationBase{ + timestamp: params["timestamp"], + nonce: params["nonce_str"], + } + if signature == "" || base.timestamp == "" || base.nonce == "" { + return signature, base, errors.New("authorization missing signature fields") + } + return signature, base, nil +} + +func parsePrivateKey(value string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(value)) + if block == nil { + return nil, errors.New("invalid private key pem") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not rsa") + } + return key, nil +} + +func parseCertificate(value string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(value)) + if block == nil { + return nil, errors.New("invalid certificate pem") + } + return x509.ParseCertificate(block.Bytes) +} + +func signRSA(key *rsa.PrivateKey, message []byte) (string, error) { + if key == nil { + return "", ErrSignFailed + } + digest := sha256.Sum256(message) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +func verifyRSA(cert *x509.Certificate, message []byte, signature string) bool { + if cert == nil || signature == "" { + return false + } + sig, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return false + } + pub, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return false + } + digest := sha256.Sum256(message) + return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig) == nil +} + +func normalizeNotifyAliases(raw map[string]string) { + raw["third_order_id"] = firstNonEmpty(raw["third_order_id"], raw["out_order_no"], raw["out_trade_no"]) + raw["provider_order_id"] = firstNonEmpty(raw["provider_order_id"], raw["pay_order_no"], raw["trade_no"], raw["log_no"]) + raw["amount"] = firstNonEmpty(raw["amount"], raw["total_amount"], raw["trade_amount"]) + raw["status"] = normalizePaymentStatus(raw) + raw["pay_time"] = firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"]) + if raw["refund_amount"] != "" || raw["origin_out_trade_no"] != "" { + raw["merchant_refund_id"] = firstNonEmpty(raw["merchant_refund_id"], raw["out_trade_no"]) + raw["provider_refund_id"] = firstNonEmpty(raw["provider_refund_id"], raw["refund_trade_no"], raw["trade_no"], raw["log_no"]) + raw["status"] = normalizeRefundStatus(raw) + } +} + +func normalizePaymentStatus(raw map[string]string) string { + value := strings.ToUpper(firstNonEmpty(raw["trade_state"], raw["order_status"], raw["trade_status"], raw["status"], raw["pay_status"])) + switch value { + case "SUCCESS", "PAY_SUCCESS", "TRADE_SUCCESS", "PAID", "S", "2", "30": + return "paid" + case "CLOSED", "CLOSE", "CANCEL", "CANCELED", "CANCELLED", "6": + return "closed" + case "FAIL", "FAILED", "PAY_FAIL", "TRADE_FAIL", "F", "8": + return "failed" + case "REFUND", "REFUNDED": + return "refunded" + default: + return "paying" + } +} + +func normalizeRefundStatus(raw map[string]string) string { + value := strings.ToUpper(firstNonEmpty(raw["refund_status"], raw["trade_state"], raw["trade_status"], raw["status"])) + switch value { + case "SUCCESS", "REFUND_SUCCESS", "TRADE_SUCCESS", "REFUNDED", "S", "11": + return "refunded" + case "FAIL", "FAILED", "REFUND_FAIL", "TRADE_FAIL", "F", "12": + return "failed" + default: + return "refunding" + } +} + +func responseOK(raw map[string]string) bool { + code := strings.ToUpper(firstNonEmpty(raw["code"], raw["resp_code"], raw["result_code"], raw["return_code"])) + if code == "" { + return true + } + return code == "0" || code == "000000" || code == "SUCCESS" || code == "BBS00000" +} + +func responseMessage(raw map[string]string) string { + return firstNonEmpty(raw["message"], raw["msg"], raw["error_msg"], raw["resp_msg"], raw["return_msg"]) +} + +func flattenJSON(value any) map[string]string { + out := map[string]string{} + var walk func(prefix string, v any) + walk = func(prefix string, v any) { + switch item := v.(type) { + case map[string]any: + for key, child := range item { + if key == "req_data" || key == "resp_data" || key == "data" { + walk("", child) + continue + } + walk(key, child) + } + case []any: + raw, _ := json.Marshal(item) + if prefix != "" { + out[prefix] = string(raw) + } + case nil: + if prefix != "" { + out[prefix] = "" + } + case string: + if prefix != "" { + out[prefix] = item + } + case float64: + if prefix != "" { + if math.Trunc(item) == item { + out[prefix] = strconv.FormatInt(int64(item), 10) + } else { + out[prefix] = strconv.FormatFloat(item, 'f', -1, 64) + } + } + case bool: + if prefix != "" { + out[prefix] = strconv.FormatBool(item) + } + default: + if prefix != "" { + raw, _ := json.Marshal(item) + out[prefix] = string(raw) + } + } + } + walk("", value) + return out +} + +func stringifyMap(value map[string]any) map[string]string { + out := map[string]string{} + for key, item := range value { + switch typed := item.(type) { + case string: + out[key] = typed + default: + raw, _ := json.Marshal(typed) + out[key] = string(raw) + } + } + return out +} + +func defaultLocationInfo(clientIP string) map[string]string { + info := map[string]string{ + "request_ip": firstNonEmpty(clientIP, "127.0.0.1"), + } + return info +} + +func payModeFromPayWay(payWay string) string { + switch strings.ToUpper(payWay) { + case "WXZF", "WECHAT", "WECHATPAY": + return "WECHAT" + case "UNIONPAY", "UQRCODEPAY": + return "UQRCODEPAY" + default: + return "ALIPAY" + } +} + +func sanitizeText(value string, maxRunes int) string { + value = strings.TrimSpace(value) + if value == "" || maxRunes <= 0 { + return value + } + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) +} + +func Nonce(length int) string { + const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + if length <= 0 { + length = 32 + } + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + for i := range buf { + buf[i] = chars[int(buf[i])%len(chars)] + } + return string(buf) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/integrations/payment/lakala/client_test.go b/backend/internal/integrations/payment/lakala/client_test.go new file mode 100644 index 0000000..f330c3c --- /dev/null +++ b/backend/internal/integrations/payment/lakala/client_test.go @@ -0,0 +1,90 @@ +package lakala + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "strings" + "testing" +) + +func TestVerifyNotifyDetail(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + privatePEM, certPEM := testKeyPairPEM(t, key) + client := NewClient(Config{ + GatewayURL: "https://example.com", + AppID: "app-1", + SerialNo: "serial-1", + MerchantID: "merchant-1", + PrivateKey: privatePEM, + NotifyCert: certPEM, + }) + + body := `{"req_data":{"out_order_no":"NO1","total_amount":100,"trade_state":"SUCCESS"}}` + message := "1710000000\nabc123\n" + body + "\n" + signature, err := signRSA(key, []byte(message)) + if err != nil { + t.Fatalf("signRSA: %v", err) + } + authorization := `LKLAPI-SHA256withRSA timestamp="1710000000",nonce_str="abc123",signature="` + signature + `"` + + result := client.VerifyNotifyDetail(body, authorization) + if !result.OK { + t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v base=%v", result.Got, result.Expected, result.BaseString) + } + + tampered := client.VerifyNotifyDetail(strings.Replace(body, "100", "101", 1), authorization) + if tampered.OK { + t.Fatal("VerifyNotifyDetail() = true after body changed, want false") + } +} + +func TestParsePayloadNormalizesAliases(t *testing.T) { + params, err := ParsePayload([]byte(`{ + "req_data": { + "out_order_no": "ORDER1", + "pay_order_no": "PAY1", + "total_amount": 88, + "trade_state": "SUCCESS", + "trade_time": "20240521101010" + } + }`)) + if err != nil { + t.Fatalf("ParsePayload error = %v", err) + } + if params["third_order_id"] != "ORDER1" { + t.Fatalf("third_order_id = %q, want ORDER1", params["third_order_id"]) + } + if params["provider_order_id"] != "PAY1" { + t.Fatalf("provider_order_id = %q, want PAY1", params["provider_order_id"]) + } + if params["amount"] != "88" { + t.Fatalf("amount = %q, want 88", params["amount"]) + } + if params["status"] != "paid" { + t.Fatalf("status = %q, want paid", params["status"]) + } +} + +func testKeyPairPEM(t *testing.T, key *rsa.PrivateKey) (string, string) { + t.Helper() + privateDER := x509.MarshalPKCS1PrivateKey(key) + privatePEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateDER, + })) + template := &x509.Certificate{} + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + certPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + })) + return privatePEM, certPEM +} diff --git a/backend/internal/model/payment_merchant_config.go b/backend/internal/model/payment_merchant_config.go index 75b462e..2357837 100644 --- a/backend/internal/model/payment_merchant_config.go +++ b/backend/internal/model/payment_merchant_config.go @@ -13,13 +13,13 @@ type PaymentMerchantConfig struct { Provider string `gorm:"size:32;not null;index:idx_payment_merchant_configs_provider" json:"provider"` MerchantID string `gorm:"size:128;not null;index:idx_payment_merchant_configs_merchant_id" json:"merchant_id"` GatewayURL string `gorm:"size:512;not null;default:''" json:"gateway_url"` - SignKey string `gorm:"size:512;not null;default:''" json:"sign_key"` // 加密存储 - NotifyKey string `gorm:"size:512;not null;default:''" json:"notify_key"` // 加密存储 + SignKey string `gorm:"type:text" json:"sign_key"` // 加密存储,拉卡拉使用商户私钥 PEM + NotifyKey string `gorm:"type:text" json:"notify_key"` // 加密存储,拉卡拉使用通知验签证书 PEM NotifyURL string `gorm:"size:512;not null;default:''" json:"notify_url"` JumpURL string `gorm:"size:512;not null;default:''" json:"jump_url"` PayWay string `gorm:"size:32;not null;default:'ZFBZF'" json:"pay_way"` JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:'2'" json:"jspay_flag"` - SignType string `gorm:"size:16;not null;default:'MD5'" json:"sign_type"` + SignType string `gorm:"size:32;not null;default:'MD5'" json:"sign_type"` ExtraConfig JSONMap `gorm:"type:json" json:"extra_config"` IsDefault bool `gorm:"not null;default:0;index:idx_payment_merchant_configs_default" json:"is_default"` Status string `gorm:"size:32;not null;default:'active';index:idx_payment_merchant_configs_status" json:"status"` diff --git a/backend/internal/modules/payment/channel_adapter.go b/backend/internal/modules/payment/channel_adapter.go new file mode 100644 index 0000000..04be806 --- /dev/null +++ b/backend/internal/modules/payment/channel_adapter.go @@ -0,0 +1,447 @@ +package payment + +import ( + "context" + "fmt" + "strconv" + "strings" + + "hfb_sys/backend/internal/integrations/payment/lakala" + "hfb_sys/backend/internal/integrations/payment/leshua" + "hfb_sys/backend/internal/modules/paymentconfig" +) + +type channelClient interface { + CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) + QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) + CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) + QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) + VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) +} + +type channelCreatePaymentRequest struct { + ThirdOrderID string + AmountCent int64 + PayWay string + JSPayFlag string + NotifyURL string + JumpURL string + ClientIP string + Body string + Attach string +} + +type channelCreatePaymentResponse struct { + OK bool + ErrorMessage string + ProviderOrderID string + PayWay string + Status string + PayTime string + TDCode string + JSPayURL string + JSPayInfo string + RawRequest map[string]string + Raw map[string]string +} + +type channelQueryPaymentResponse struct { + OK bool + ErrorMessage string + ProviderOrderID string + Status string + Amount string + PayWay string + PayTime string + Raw map[string]string +} + +type channelCreateRefundRequest struct { + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + RefundAmountCent int64 + NotifyURL string + Attach string + Remark string + ClientIP string +} + +type channelCreateRefundResponse struct { + OK bool + ErrorMessage string + ProviderRefundID string + Status string + RefundAmount string + RawRequest map[string]string + Raw map[string]string +} + +type channelQueryRefundRequest struct { + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + ProviderRefundID string +} + +type channelQueryRefundResponse struct { + OK bool + ErrorMessage string + ProviderRefundID string + Status string + RefundAmount string + RefundTime string + Raw map[string]string +} + +type channelVerifyNotifyResult struct { + OK bool + MatchedKey string + Got string + Expected map[string]string + BaseString map[string]string + ParamKeys []string +} + +type leshuaChannel struct { + client *leshua.Client +} + +func newLeshuaChannel(cfg leshua.Config) channelClient { + return leshuaChannel{client: leshua.NewClient(cfg)} +} + +func (c leshuaChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) { + resp, rawReq, err := c.client.CreatePayment(ctx, leshua.CreatePaymentRequest{ + ThirdOrderID: req.ThirdOrderID, + AmountCent: req.AmountCent, + PayWay: req.PayWay, + JSPayFlag: req.JSPayFlag, + NotifyURL: req.NotifyURL, + JumpURL: req.JumpURL, + ClientIP: req.ClientIP, + Body: req.Body, + Attach: req.Attach, + }) + if err != nil { + return nil, err + } + return &channelCreatePaymentResponse{ + OK: resp.RespCode == "0" && resp.ResultCode == "0", + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + PayWay: resp.PayWay, + Status: normalizeLeshuaPaymentStatus(resp.Raw["status"]), + PayTime: resp.Raw["pay_time"], + TDCode: resp.TDCode, + JSPayURL: resp.JSPayURL, + JSPayInfo: resp.JSPayInfo, + RawRequest: rawReq, + Raw: resp.Raw, + }, nil +} + +func (c leshuaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID) + if err != nil { + return nil, err + } + return &channelQueryPaymentResponse{ + OK: resp.RespCode == "0" && resp.ResultCode == "0", + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + Status: normalizeLeshuaPaymentStatus(resp.Status), + Amount: resp.Amount, + PayWay: resp.PayWay, + PayTime: resp.PayTime, + Raw: resp.Raw, + }, nil +} + +func (c leshuaChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) { + resp, rawReq, err := c.client.CreateRefund(ctx, leshua.CreateRefundRequest{ + ThirdOrderID: req.ThirdOrderID, + LeshuaOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + RefundAmountCent: req.RefundAmountCent, + NotifyURL: req.NotifyURL, + Attach: req.Attach, + }) + if err != nil { + return nil, err + } + return &channelCreateRefundResponse{ + OK: resp.RespCode == "0" && resp.ResultCode == "0", + ErrorMessage: resp.ErrorMessage, + ProviderRefundID: resp.LeshuaRefundID, + Status: normalizeLeshuaRefundStatus(resp.Status), + RefundAmount: resp.RefundAmount, + RawRequest: rawReq, + Raw: resp.Raw, + }, nil +} + +func (c leshuaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) { + resp, err := c.client.QueryRefund(ctx, leshua.QueryRefundRequest{ + ThirdOrderID: req.ThirdOrderID, + LeshuaOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + LeshuaRefundID: req.ProviderRefundID, + }) + if err != nil { + return nil, err + } + return &channelQueryRefundResponse{ + OK: resp.RespCode == "0" && resp.ResultCode == "0", + ErrorMessage: resp.ErrorMessage, + ProviderRefundID: resp.LeshuaRefundID, + Status: normalizeLeshuaRefundStatus(resp.Status), + RefundAmount: resp.RefundAmount, + RefundTime: resp.RefundTime, + Raw: resp.Raw, + }, nil +} + +func (c leshuaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { + verify := c.client.VerifyNotifyDetail(params) + result := channelVerifyNotifyResult{ + OK: verify.OK, + MatchedKey: verify.MatchedKey, + Got: verify.Got, + Expected: verify.Expected, + BaseString: verify.BaseString, + ParamKeys: verify.ParamKeys, + } + if !result.OK { + return result, ErrPaymentVerifyFailed + } + return result, nil +} + +type lakalaChannel struct { + client *lakala.Client +} + +func newLakalaChannel(cfg lakala.Config) channelClient { + return lakalaChannel{client: lakala.NewClient(cfg)} +} + +func (c lakalaChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) { + resp, err := c.client.CreatePayment(ctx, lakala.CreatePaymentRequest{ + ThirdOrderID: req.ThirdOrderID, + AmountCent: req.AmountCent, + PayWay: req.PayWay, + JSPayFlag: req.JSPayFlag, + NotifyURL: req.NotifyURL, + JumpURL: req.JumpURL, + ClientIP: req.ClientIP, + Body: req.Body, + Attach: req.Attach, + }) + if err != nil { + return nil, err + } + return &channelCreatePaymentResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + PayWay: resp.PayWay, + Status: resp.Status, + PayTime: resp.PayTime, + TDCode: resp.TDCode, + JSPayURL: resp.JSPayURL, + JSPayInfo: resp.JSPayInfo, + RawRequest: resp.RawRequest, + Raw: resp.Raw, + }, nil +} + +func (c lakalaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID) + if err != nil { + return nil, err + } + return &channelQueryPaymentResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + Status: resp.Status, + Amount: resp.Amount, + PayWay: resp.PayWay, + PayTime: resp.PayTime, + Raw: resp.Raw, + }, nil +} + +func (c lakalaChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) { + resp, err := c.client.CreateRefund(ctx, lakala.CreateRefundRequest{ + ThirdOrderID: req.ThirdOrderID, + ProviderOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + RefundAmountCent: req.RefundAmountCent, + NotifyURL: req.NotifyURL, + Attach: req.Attach, + RefundReason: req.Remark, + ClientIP: req.ClientIP, + }) + if err != nil { + return nil, err + } + return &channelCreateRefundResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderRefundID: resp.ProviderRefundID, + Status: resp.Status, + RefundAmount: resp.RefundAmount, + RawRequest: resp.RawRequest, + Raw: resp.Raw, + }, nil +} + +func (c lakalaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) { + resp, err := c.client.QueryRefund(ctx, lakala.QueryRefundRequest{ + ThirdOrderID: req.ThirdOrderID, + ProviderOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + ProviderRefundID: req.ProviderRefundID, + }) + if err != nil { + return nil, err + } + return &channelQueryRefundResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderRefundID: resp.ProviderRefundID, + Status: resp.Status, + RefundAmount: resp.RefundAmount, + RefundTime: resp.RefundTime, + Raw: resp.Raw, + }, nil +} + +func (c lakalaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { + verify := c.client.VerifyNotifyDetail(rawPayload, authorization) + result := channelVerifyNotifyResult{ + OK: verify.OK, + MatchedKey: verify.MatchedKey, + Got: verify.Got, + Expected: verify.Expected, + BaseString: verify.BaseString, + ParamKeys: verify.ParamKeys, + } + if !result.OK { + return result, ErrPaymentVerifyFailed + } + return result, nil +} + +func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) { + switch strings.ToLower(strings.TrimSpace(dto.Provider)) { + case "leshua": + return newLeshuaChannel(leshua.Config{ + GatewayURL: dto.GatewayURL, + MerchantID: dto.MerchantID, + SignKey: dto.SignKey, + NotifyKey: dto.NotifyKey, + NotifyURL: dto.NotifyURL, + JumpURL: dto.JumpURL, + PayWay: firstNonEmpty(dto.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(dto.JSPayFlag, "2"), + SignType: firstNonEmpty(dto.SignType, "MD5"), + }), nil + case "lakala": + return newLakalaChannel(lakala.Config{ + GatewayURL: dto.GatewayURL, + AppID: extraString(dto.ExtraConfig, "app_id"), + SerialNo: extraString(dto.ExtraConfig, "serial_no"), + MerchantID: dto.MerchantID, + TermNo: extraString(dto.ExtraConfig, "term_no"), + PrivateKey: dto.SignKey, + LakalaCert: extraString(dto.ExtraConfig, "lakala_cert"), + NotifyCert: dto.NotifyKey, + NotifyURL: dto.NotifyURL, + JumpURL: dto.JumpURL, + PayWay: firstNonEmpty(dto.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(dto.JSPayFlag, "2"), + PayMode: extraString(dto.ExtraConfig, "pay_mode"), + OrderExpireMinutes: extraInt(dto.ExtraConfig, "order_expire_minutes"), + }), nil + case "mock": + return nil, nil + default: + return nil, fmt.Errorf("unsupported payment provider: %s", dto.Provider) + } +} + +func normalizeLeshuaPaymentStatus(status string) string { + switch status { + case "2", "30": + return "paid" + case "6": + return "closed" + case "8": + return "failed" + default: + return "paying" + } +} + +func normalizeLeshuaRefundStatus(status string) string { + switch status { + case "11": + return "refunded" + case "12": + return "failed" + default: + return "refunding" + } +} + +func normalizeNotifyPaymentStatus(provider string, status string) string { + if provider == "leshua" { + return normalizeLeshuaPaymentStatus(status) + } + switch status { + case "paid", "closed", "failed", "paying": + return status + default: + return "paying" + } +} + +func normalizeNotifyRefundStatus(provider string, status string) string { + if provider == "leshua" { + return normalizeLeshuaRefundStatus(status) + } + switch status { + case "refunded", "failed", "refunding": + return status + default: + return "refunding" + } +} + +func extraString(config map[string]any, key string) string { + if config == nil { + return "" + } + value, ok := config[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +func extraInt(config map[string]any, key string) int { + value := extraString(config, key) + if value == "" { + return 0 + } + n, _ := strconv.Atoi(value) + return n +} diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 653c2d6..d918993 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" + "hfb_sys/backend/internal/integrations/payment/lakala" "hfb_sys/backend/internal/integrations/payment/leshua" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/order" @@ -144,6 +145,39 @@ func (h *Handler) LeshuaNotify(c *gin.Context) { c.String(http.StatusOK, result.Message) } +func (h *Handler) LakalaNotify(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "读取失败"}) + return + } + params, err := lakala.ParsePayload(body) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "解析失败"}) + return + } + rawPayload := string(body) + contentType := c.GetHeader("Content-Type") + authorization := c.GetHeader("Authorization") + log.Printf( + "[payment] lakala notify received third_order_id=%s provider_order_id=%s status=%s amount=%s content_type=%s raw_payload=%s", + params["third_order_id"], + params["provider_order_id"], + params["status"], + params["amount"], + contentType, + rawPayload, + ) + result, err := h.service.HandleNotify("lakala", params, rawPayload, contentType, authorization) + if err != nil || result == nil || !result.OK { + log.Printf("[payment] lakala notify failed third_order_id=%s err=%v", params["third_order_id"], err) + c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "失败"}) + return + } + log.Printf("[payment] lakala notify processed third_order_id=%s status=%s", params["third_order_id"], params["status"]) + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "执行成功"}) +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index b6a69fa..649c62c 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -10,7 +10,6 @@ import ( "math" "time" - "hfb_sys/backend/internal/integrations/payment/leshua" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/paymentconfig" @@ -32,7 +31,11 @@ type runtimePaymentConfig struct { ID uint64 Provider string MerchantID string - Leshua leshua.Config + PayWay string + JSPayFlag string + NotifyURL string + JumpURL string + Channel channelClient } const ( @@ -61,11 +64,7 @@ func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo } func (c runtimePaymentConfig) isMockMode() bool { - return c.Provider != "leshua" -} - -func (c runtimePaymentConfig) client() *leshua.Client { - return leshua.NewClient(c.Leshua) + return c.Provider == "mock" } func (r *Repository) defaultRuntimeConfig() (*runtimePaymentConfig, error) { @@ -92,6 +91,13 @@ func (r *Repository) runtimeConfigForPayment(payment *model.PaymentOrder) (*runt } } if provider != "leshua" { + if provider == "lakala" && r.configRepo != nil { + dto, err := r.configRepo.FindDefaultByProvider(provider, true) + if err != nil { + return nil, err + } + return runtimeConfigFromDTO(dto), nil + } return &runtimePaymentConfig{ Provider: provider, MerchantID: merchantID, @@ -111,22 +117,19 @@ func runtimeConfigFromDTO(dto *paymentconfig.ConfigDTO) *runtimePaymentConfig { provider := firstNonEmpty(dto.Provider, "mock") payWay := firstNonEmpty(dto.PayWay, "ZFBZF") jsPayFlag := firstNonEmpty(dto.JSPayFlag, "2") - signType := firstNonEmpty(dto.SignType, "MD5") + client, err := buildChannelClient(dto) + if err != nil { + client = nil + } return &runtimePaymentConfig{ ID: dto.ID, Provider: provider, MerchantID: dto.MerchantID, - Leshua: leshua.Config{ - GatewayURL: dto.GatewayURL, - MerchantID: dto.MerchantID, - SignKey: dto.SignKey, - NotifyKey: dto.NotifyKey, - NotifyURL: dto.NotifyURL, - JumpURL: dto.JumpURL, - PayWay: payWay, - JSPayFlag: jsPayFlag, - SignType: signType, - }, + PayWay: payWay, + JSPayFlag: jsPayFlag, + NotifyURL: dto.NotifyURL, + JumpURL: dto.JumpURL, + Channel: client, } } @@ -170,15 +173,18 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques dto := toDTO(*payment) return &dto, nil } + if runtimeConfig.Channel == nil { + _ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable") + return nil, ErrPaymentUnavailable + } - client := runtimeConfig.client() - resp, rawReq, err := client.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ + resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, - NotifyURL: runtimeConfig.Leshua.NotifyURL, - JumpURL: runtimeConfig.Leshua.JumpURL, + NotifyURL: runtimeConfig.NotifyURL, + JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, Body: "租号订单 " + orderRow.OrderNo, Attach: orderRow.OrderNo, @@ -187,7 +193,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } - if resp.RespCode != "0" || resp.ResultCode != "0" { + if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) return nil, ErrPaymentUnavailable } @@ -198,7 +204,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques "td_code": resp.TDCode, "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, - "raw_request": jsonMap(rawReq), + "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err @@ -242,14 +248,17 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen dto := toDTO(*latest) return &dto, nil } - client := runtimeConfig.client() - resp, rawReq, err := client.CreatePayment(context.Background(), leshua.CreatePaymentRequest{ + if runtimeConfig.Channel == nil { + _ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable") + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{ ThirdOrderID: payment.ThirdOrderID, AmountCent: payment.AmountCent, PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, - NotifyURL: runtimeConfig.Leshua.NotifyURL, - JumpURL: runtimeConfig.Leshua.JumpURL, + NotifyURL: runtimeConfig.NotifyURL, + JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, Body: "钱包充值 " + payment.PaymentNo, Attach: payment.PaymentNo, @@ -258,7 +267,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen _ = r.markPaymentFailed(payment.ID, nil, err.Error()) return nil, err } - if resp.RespCode != "0" || resp.ResultCode != "0" { + if !resp.OK { _ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage) return nil, ErrPaymentUnavailable } @@ -269,7 +278,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen "td_code": resp.TDCode, "jspay_url": resp.JSPayURL, "jspay_info": resp.JSPayInfo, - "raw_request": jsonMap(rawReq), + "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), }).Error; err != nil { return nil, err @@ -299,7 +308,10 @@ func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*Paym dto := toDTO(payment) return &dto, nil } - resp, err := runtimeConfig.client().QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) if err != nil { return nil, err } @@ -330,7 +342,10 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { dto := toDTO(payment) return &dto, nil } - resp, err := runtimeConfig.client().QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryPayment(context.Background(), payment.ThirdOrderID, payment.ProviderOrderID) if err != nil { return nil, err } @@ -346,9 +361,13 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) { } func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { + return r.HandleNotify("leshua", params, rawPayload, contentType, "") +} + +func (r *Repository) HandleNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { // 退款通知会携带 merchant_refund_id 或 leshua_refund_id。 - if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" { - return r.HandleRefundNotify(params, rawPayload, contentType) + if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" || params["provider_refund_id"] != "" { + return r.HandleRefundNotify(provider, params, rawPayload, contentType, authorization) } payment, err := r.findPaymentForNotify(params) @@ -359,19 +378,19 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload str if err != nil { return nil, ErrPaymentUnavailable } - verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType) + verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType, authorization) if err != nil { return nil, err } if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { if err := r.recordNotifyDiagnostic(payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { - log.Printf("[payment] leshua notify diagnostic save failed third_order_id=%s err=%v", params["third_order_id"], err) + log.Printf("[payment] %s notify diagnostic save failed third_order_id=%s err=%v", runtimeConfig.Provider, params["third_order_id"], err) } return nil, ErrPaymentVerifyFailed } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - if err := r.applyChannelStatus(payment, params["status"], params["pay_time"], raw, channelSourceNotify); err != nil { + if err := r.applyChannelStatus(payment, normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]), params["pay_time"], raw, channelSourceNotify); err != nil { return nil, err } return &NotifyResult{OK: true, Message: "000000"}, nil @@ -450,36 +469,41 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) } - resp, rawReq, err := runtimeConfig.client().CreateRefund(context.Background(), leshua.CreateRefundRequest{ + if runtimeConfig.Channel == nil { + _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}) + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.CreateRefund(context.Background(), channelCreateRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, - LeshuaOrderID: originalPayment.ProviderOrderID, + ProviderOrderID: originalPayment.ProviderOrderID, MerchantRefundID: merchantRefundID, RefundAmountCent: refundAmountCent, - NotifyURL: runtimeConfig.Leshua.NotifyURL, + NotifyURL: runtimeConfig.NotifyURL, Attach: originalPayment.OrderNo, + Remark: remark, }) if err != nil { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) return nil, err } - if resp.RespCode != "0" || resp.ResultCode != "0" { + if !resp.OK { _ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw) return nil, ErrPaymentUnavailable } refundStatus := "refunding" var paidAt *time.Time - if resp.Status == "11" { + if resp.Status == "refunded" { refundStatus = "refunded" now := time.Now() paidAt = &now - } else if resp.Status == "12" { + } else if resp.Status == "failed" { refundStatus = "failed" } if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{ "status": refundStatus, - "provider_order_id": resp.LeshuaRefundID, - "raw_request": jsonMap(rawReq), + "provider_order_id": resp.ProviderRefundID, + "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), "paid_at": paidAt, }).Error; err != nil { @@ -495,7 +519,7 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType _ = r.markOrderRefunding(orderID, refundAmountCent) } refundOrder.Status = refundStatus - refundOrder.ProviderOrderID = resp.LeshuaRefundID + refundOrder.ProviderOrderID = resp.ProviderRefundID dto := toRefundDTO(refundOrder) return &dto, nil @@ -518,15 +542,18 @@ func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { dto := toRefundDTO(payment) return &dto, nil } - resp, err := runtimeConfig.client().QueryRefund(context.Background(), leshua.QueryRefundRequest{ + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryRefund(context.Background(), channelQueryRefundRequest{ ThirdOrderID: payment.ThirdOrderID, MerchantRefundID: payment.ThirdOrderID, - LeshuaRefundID: payment.ProviderOrderID, + ProviderRefundID: payment.ProviderOrderID, }) if err != nil { return nil, err } - if resp.Status == "11" { + if resp.Status == "refunded" { now := time.Now() if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunded", @@ -538,7 +565,7 @@ func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { payment.Status = "refunded" payment.PaidAt = &now _ = r.updateOrderRefundStatus(orderID, payment.AmountCent) - } else if resp.Status == "12" { + } else if resp.Status == "failed" { if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), @@ -552,8 +579,8 @@ func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) { return &dto, nil } -// HandleRefundNotify 处理乐刷退款通知。 -func (r *Repository) HandleRefundNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { +// HandleRefundNotify 处理渠道退款通知。 +func (r *Repository) HandleRefundNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { payment, err := r.findRefundPaymentForNotify(params) if err != nil { return nil, err @@ -562,15 +589,15 @@ func (r *Repository) HandleRefundNotify(params map[string]string, rawPayload str if err != nil { return nil, ErrPaymentUnavailable } - verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType) + verify, err := r.verifyNotify(payment, runtimeConfig, params, rawPayload, contentType, authorization) if err != nil { return nil, err } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - status := params["status"] + status := normalizeNotifyRefundStatus(provider, params["status"]) switch status { - case "11": + case "refunded": now := time.Now() if err := r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunded", @@ -581,7 +608,7 @@ func (r *Repository) HandleRefundNotify(params map[string]string, rawPayload str return nil, err } _ = r.updateOrderRefundStatus(payment.OrderID, payment.AmountCent) - case "12": + case "failed": r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "failed", "notified_at": time.Now(), @@ -676,12 +703,12 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym Order("id DESC"). First(&existing).Error if err == nil { - existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.Leshua.PayWay, "ZFBZF") - existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.Leshua.JSPayFlag, "2") + existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") + existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") existing.AmountCent = amountCent existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider) existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID) - if existing.Provider != "leshua" && existing.ProviderOrderID == "" { + if existing.Provider == "mock" && existing.ProviderOrderID == "" { existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID } if err := tx.Save(&existing).Error; err != nil { @@ -707,15 +734,15 @@ func (r *Repository) preparePayment(userID uint64, orderID uint64, req StartPaym MerchantID: runtimeConfig.MerchantID, ThirdOrderID: row.OrderNo, ProviderOrderID: "", - PayWay: firstNonEmpty(req.PayWay, runtimeConfig.Leshua.PayWay, "ZFBZF"), - JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.Leshua.JSPayFlag, "2"), + PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), AmountCent: amountCent, BizType: "order_pay", Status: "created", } if runtimeConfig.isMockMode() { payment.ProviderOrderID = "MOCK" + row.OrderNo - payment.TDCode = "mock://leshua/pay/" + row.OrderNo + payment.TDCode = "mock://payment/pay/" + row.OrderNo } if err := tx.Create(&payment).Error; err != nil { return err @@ -748,15 +775,15 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64 MerchantID: runtimeConfig.MerchantID, ThirdOrderID: paymentNo, ProviderOrderID: "", - PayWay: firstNonEmpty(req.PayWay, runtimeConfig.Leshua.PayWay, "ZFBZF"), - JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.Leshua.JSPayFlag, "2"), + PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), AmountCent: amountCent, BizType: "wallet_recharge", Status: "created", } if runtimeConfig.isMockMode() { payment.ProviderOrderID = "MOCK" + paymentNo - payment.TDCode = "mock://leshua/recharge/" + paymentNo + payment.TDCode = "mock://payment/recharge/" + paymentNo } if err := r.db.Create(&payment).Error; err != nil { return nil, err @@ -766,16 +793,16 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64 func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error { switch status { - case "2", "30": - paidAt := parseLeshuaTime(payTime) + case "paid": + paidAt := parseChannelTime(payTime) if paidAt == nil { now := time.Now() paidAt = &now } return r.confirmPaid(payment, status, *paidAt, raw, source) - case "6": + case "closed": return r.updateChannelStatus(payment.ID, "closed", raw, source) - case "8": + case "failed": return r.updateChannelStatus(payment.ID, "failed", raw, source) default: return r.updateChannelStatus(payment.ID, "paying", raw, source) @@ -813,7 +840,7 @@ func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, pai } updates := map[string]any{ "status": "paid", - "provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID), + "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], payment.ProviderOrderID), "raw_response": jsonMap(withRawSource(raw, source)), "paid_at": paidAt, } @@ -871,28 +898,32 @@ func (r *Repository) findRefundPaymentForNotify(params map[string]string) (*mode return &payment, nil } -func (r *Repository) verifyNotify(payment *model.PaymentOrder, runtimeConfig *runtimePaymentConfig, params map[string]string, rawPayload string, contentType string) (leshua.VerifyNotifyResult, error) { - var verify leshua.VerifyNotifyResult +func (r *Repository) verifyNotify(payment *model.PaymentOrder, runtimeConfig *runtimePaymentConfig, params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { + var verify channelVerifyNotifyResult if runtimeConfig.isMockMode() { return verify, nil } - verify = runtimeConfig.client().VerifyNotifyDetail(params) - if !verify.OK { + if runtimeConfig.Channel == nil { + return verify, ErrPaymentUnavailable + } + verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization) + if err != nil || !verify.OK { log.Printf( - "[payment] leshua notify verify failed payment_id=%d third_order_id=%s got=%s expected=%s keys=%v base_string=%s", + "[payment] %s notify verify failed payment_id=%d third_order_id=%s got=%s expected=%s keys=%v base_string=%s", + runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.Got, - verify.Expected["notify_key"], + firstNonEmpty(verify.Expected["notify_key"], verify.Expected["notify_cert"], verify.Expected["error"]), verify.ParamKeys, - verify.BaseString["notify_key"], + firstNonEmpty(verify.BaseString["notify_key"], verify.BaseString["notify_cert"]), ) if err := r.recordNotifyDiagnostic(payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil { - log.Printf("[payment] leshua notify diagnostic save failed payment_id=%d err=%v", payment.ID, err) + log.Printf("[payment] %s notify diagnostic save failed payment_id=%d err=%v", runtimeConfig.Provider, payment.ID, err) } return verify, ErrPaymentVerifyFailed } - log.Printf("[payment] leshua notify verified payment_id=%d third_order_id=%s matched_key=%s", payment.ID, params["third_order_id"], verify.MatchedKey) + log.Printf("[payment] %s notify verified payment_id=%d third_order_id=%s matched_key=%s", runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.MatchedKey) return verify, nil } @@ -938,11 +969,11 @@ func parseCent(value string) int64 { return amount } -func parseLeshuaTime(value string) *time.Time { +func parseChannelTime(value string) *time.Time { if value == "" { return nil } - for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} { parsed, err := time.ParseInLocation(layout, value, time.Local) if err == nil { return &parsed @@ -974,7 +1005,7 @@ func withRawSource(raw map[string]string, source string) map[string]string { return out } -func (r *Repository) recordNotifyDiagnostic(paymentID uint64, params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, status string) error { +func (r *Repository) recordNotifyDiagnostic(paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error { if paymentID == 0 { return nil } @@ -984,7 +1015,7 @@ func (r *Repository) recordNotifyDiagnostic(paymentID uint64, params map[string] Update("raw_response", jsonMap(raw)).Error } -func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, status string) map[string]string { +func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string { raw := withRawSource(params, channelSourceNotify) raw["_notify_diagnostic_status"] = status raw["_raw_payload"] = rawPayload diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go index c489f74..b23f016 100644 --- a/backend/internal/modules/payment/service.go +++ b/backend/internal/modules/payment/service.go @@ -1,6 +1,9 @@ package payment -import "errors" +import ( + "errors" + "strings" +) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") @@ -15,11 +18,19 @@ var ( const MinWalletRechargeAmount = 0.01 type Service struct { - repo *Repository + repo *Repository + walletRechargeEnabled bool } -func NewService(repo *Repository) *Service { - return &Service{repo: repo} +func NewService(repo *Repository, appEnv ...string) *Service { + env := "production" + if len(appEnv) > 0 { + env = strings.ToLower(strings.TrimSpace(appEnv[0])) + } + return &Service{ + repo: repo, + walletRechargeEnabled: env != "production", + } } func (s *Service) Start(userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { @@ -46,7 +57,10 @@ func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRe if s.repo == nil { return nil, ErrDependencyUnavailable } - return nil, ErrWalletRechargeDisabled + if !s.walletRechargeEnabled { + return nil, ErrWalletRechargeDisabled + } + return s.repo.StartWalletRecharge(userID, req, clientIP) } func (s *Service) QueryWalletRecharge(userID uint64, paymentID uint64) (*PaymentDTO, error) { @@ -66,6 +80,13 @@ func (s *Service) HandleLeshuaNotify(params map[string]string, rawPayload string return s.repo.HandleLeshuaNotify(params, rawPayload, contentType) } +func (s *Service) HandleNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.HandleNotify(provider, params, rawPayload, contentType, authorization) +} + func (s *Service) StartRefund(orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go index 04778cd..4277298 100644 --- a/backend/internal/modules/paymentconfig/dto.go +++ b/backend/internal/modules/paymentconfig/dto.go @@ -18,6 +18,9 @@ var ( ErrNotifyKeyRequired = errors.New("notify_key is required for leshua provider") ErrNotifyURLRequired = errors.New("notify_url is required for leshua provider") ErrInvalidSignType = errors.New("invalid sign_type") + ErrAppIDRequired = errors.New("extra_config.app_id is required for lakala provider") + ErrSerialNoRequired = errors.New("extra_config.serial_no is required for lakala provider") + ErrTermNoRequired = errors.New("extra_config.term_no is required for lakala provider") ) // DTO 数据传输对象 @@ -51,7 +54,7 @@ type ConfigDTO struct { // CreateRequest 创建支付配置请求 type CreateRequest struct { Name string `json:"name" binding:"required"` - Provider string `json:"provider" binding:"required,oneof=leshua mock"` + Provider string `json:"provider" binding:"required,oneof=leshua lakala mock"` MerchantID string `json:"merchant_id" binding:"required"` GatewayURL string `json:"gateway_url"` SignKey string `json:"sign_key"` diff --git a/backend/internal/modules/paymentconfig/handler.go b/backend/internal/modules/paymentconfig/handler.go index 817f758..6addc05 100644 --- a/backend/internal/modules/paymentconfig/handler.go +++ b/backend/internal/modules/paymentconfig/handler.go @@ -95,7 +95,8 @@ func (h *Handler) Create(c *gin.Context) { config, err := h.service.Create(req, adminID, auditMeta(c)) if err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired || err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrInvalidProvider || - err == ErrNotifyURLRequired || err == ErrInvalidSignType { + err == ErrNotifyURLRequired || err == ErrInvalidSignType || err == ErrAppIDRequired || + err == ErrSerialNoRequired || err == ErrTermNoRequired { response.BadRequest(c, err.Error()) return } diff --git a/backend/internal/modules/paymentconfig/repository.go b/backend/internal/modules/paymentconfig/repository.go index 194572a..52d2e96 100644 --- a/backend/internal/modules/paymentconfig/repository.go +++ b/backend/internal/modules/paymentconfig/repository.go @@ -210,7 +210,7 @@ func (r *Repository) Create(req CreateRequest, actorID uint64, meta AuditMeta) ( JumpURL: req.JumpURL, PayWay: firstNonEmpty(req.PayWay, "ZFBZF"), JSPayFlag: firstNonEmpty(req.JSPayFlag, "2"), - SignType: firstNonEmpty(req.SignType, "MD5"), + SignType: firstNonEmpty(req.SignType, defaultSignType(req.Provider)), ExtraConfig: req.ExtraConfig, IsDefault: req.IsDefault, Status: status, @@ -244,7 +244,7 @@ func (r *Repository) Create(req CreateRequest, actorID uint64, meta AuditMeta) ( // Update 更新配置 func (r *Repository) Update(id uint64, req UpdateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { - if req.SignType != nil && *req.SignType != "" && *req.SignType != "MD5" { + if req.SignType != nil && *req.SignType != "" && !isValidSignType(*req.SignType) { return nil, ErrInvalidSignType } if req.NotifyURL != nil && *req.NotifyURL == "" { @@ -310,7 +310,7 @@ func (r *Repository) Update(id uint64, req UpdateRequest, actorID uint64, meta A updates["sign_type"] = *req.SignType } if req.ExtraConfig != nil { - updates["extra_config"] = req.ExtraConfig + updates["extra_config"] = model.JSONMap(req.ExtraConfig) } if req.IsDefault != nil { updates["is_default"] = *req.IsDefault @@ -322,7 +322,7 @@ func (r *Repository) Update(id uint64, req UpdateRequest, actorID uint64, meta A updates["environment"] = *req.Environment } if req.BusinessTags != nil { - updates["business_tags"] = req.BusinessTags + updates["business_tags"] = model.JSONArray(req.BusinessTags) } updates["updated_by"] = actorID @@ -489,7 +489,7 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error { if req.MerchantID == "" { return ErrMerchantIDRequired } - if req.SignType != "" && req.SignType != "MD5" { + if req.SignType != "" && !isValidSignType(req.SignType) { return ErrInvalidSignType } @@ -508,6 +508,29 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error { return ErrNotifyURLRequired } } + if req.Provider == "lakala" { + if req.GatewayURL == "" { + return ErrGatewayURLRequired + } + if req.SignKey == "" { + return ErrSignKeyRequired + } + if req.NotifyKey == "" { + return ErrNotifyKeyRequired + } + if req.NotifyURL == "" { + return ErrNotifyURLRequired + } + if extraString(req.ExtraConfig, "app_id") == "" { + return ErrAppIDRequired + } + if extraString(req.ExtraConfig, "serial_no") == "" { + return ErrSerialNoRequired + } + if extraString(req.ExtraConfig, "term_no") == "" { + return ErrTermNoRequired + } + } return nil } @@ -521,6 +544,31 @@ func firstNonEmpty(vals ...string) string { return "" } +func defaultSignType(provider string) string { + if provider == "lakala" { + return "SHA256withRSA" + } + return "MD5" +} + +func isValidSignType(value string) bool { + return value == "MD5" || value == "SHA256withRSA" +} + +func extraString(config map[string]any, key string) string { + if config == nil { + return "" + } + value, ok := config[key] + if !ok || value == nil { + return "" + } + if s, ok := value.(string); ok { + return s + } + return "" +} + func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { detailJSON, err := json.Marshal(detail) if err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a1397b8..ab0878f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -152,7 +152,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, walletRepo) } - paymentService := payment.NewService(paymentRepo) + paymentService := payment.NewService(paymentRepo, cfg.AppEnv) paymentHandler := payment.NewHandler(paymentService) // Inject refund function into order repo to avoid circular dependency if orderRepo != nil && paymentRepo != nil { @@ -247,6 +247,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/public/files/object", fileHandler.PublicObject) api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) + api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify) openRoutes := api.Group("/open") { diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index a6fc799..246f3b2 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -222,8 +222,8 @@ CREATE TABLE IF NOT EXISTS payment_orders ( order_id BIGINT UNSIGNED NOT NULL COMMENT '关联订单ID', order_no VARCHAR(64) NOT NULL COMMENT '业务订单号', user_id BIGINT UNSIGNED NOT NULL COMMENT '支付用户ID', - provider VARCHAR(32) NOT NULL COMMENT '支付服务商: alipay支付宝, wechat微信, mock模拟', - merchant_id VARCHAR(32) NOT NULL DEFAULT '' COMMENT '商户号', + provider VARCHAR(32) NOT NULL COMMENT '支付服务商: leshua乐刷, lakala拉卡拉, mock模拟', + merchant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商户号', third_order_id VARCHAR(64) NOT NULL COMMENT '第三方订单ID', provider_order_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '支付渠道订单号', pay_way VARCHAR(16) NOT NULL DEFAULT '' COMMENT '支付方式', @@ -254,16 +254,16 @@ CREATE TABLE IF NOT EXISTS payment_orders ( CREATE TABLE IF NOT EXISTS payment_merchant_configs ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT '商户配置名称(用于识别)', - provider VARCHAR(32) NOT NULL COMMENT '支付服务商: leshua乐刷, alipay支付宝, wechat微信, mock模拟', + provider VARCHAR(32) NOT NULL COMMENT '支付服务商: leshua乐刷, lakala拉卡拉, mock模拟', merchant_id VARCHAR(128) NOT NULL COMMENT '商户号', gateway_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付网关地址', - sign_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '签名密钥(加密存储)', - notify_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '通知验签密钥(加密存储)', + sign_key TEXT NULL COMMENT '签名密钥/商户私钥(加密存储)', + notify_key TEXT NULL COMMENT '通知验签密钥/平台通知证书(加密存储)', notify_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '异步通知回调地址', jump_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付完成跳转地址', pay_way VARCHAR(32) NOT NULL DEFAULT 'ZFBZF' COMMENT '支付方式: ZFBZF支付宝, WXZF微信', jspay_flag VARCHAR(8) NOT NULL DEFAULT '2' COMMENT 'JS支付标识: 1 H5, 2 小程序', - sign_type VARCHAR(16) NOT NULL DEFAULT 'MD5' COMMENT '签名类型: MD5, SHA256', + sign_type VARCHAR(32) NOT NULL DEFAULT 'MD5' COMMENT '签名类型: MD5, SHA256withRSA', extra_config JSON NULL COMMENT '扩展配置(JSON格式)', is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认商户: 0否, 1是(每个provider只能有一个默认)', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active启用, disabled禁用, testing测试中', diff --git a/backend/migrations/000002_payment_lakala_multichannel.sql b/backend/migrations/000002_payment_lakala_multichannel.sql new file mode 100644 index 0000000..62c52e0 --- /dev/null +++ b/backend/migrations/000002_payment_lakala_multichannel.sql @@ -0,0 +1,10 @@ +-- 支付多渠道和拉卡拉配置字段扩展 +ALTER TABLE payment_orders + MODIFY COLUMN provider VARCHAR(32) NOT NULL COMMENT '支付服务商: leshua乐刷, lakala拉卡拉, mock模拟', + MODIFY COLUMN merchant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商户号'; + +ALTER TABLE payment_merchant_configs + MODIFY COLUMN provider VARCHAR(32) NOT NULL COMMENT '支付服务商: leshua乐刷, lakala拉卡拉, mock模拟', + MODIFY COLUMN sign_key TEXT NULL COMMENT '签名密钥/商户私钥(加密存储)', + MODIFY COLUMN notify_key TEXT NULL COMMENT '通知验签密钥/平台通知证书(加密存储)', + MODIFY COLUMN sign_type VARCHAR(32) NOT NULL DEFAULT 'MD5' COMMENT '签名类型: MD5, SHA256withRSA'; diff --git a/frontend/src/features/admin/components/PaymentConfigDialog.vue b/frontend/src/features/admin/components/PaymentConfigDialog.vue index 319dbda..c7ea846 100644 --- a/frontend/src/features/admin/components/PaymentConfigDialog.vue +++ b/frontend/src/features/admin/components/PaymentConfigDialog.vue @@ -29,23 +29,48 @@ const showSecrets = ref(false) const loadingSecrets = ref(false) const advancedOpen = ref([]) -const formData = ref({ - name: '', - provider: 'leshua', - merchant_id: '', - gateway_url: 'https://paygate.leshuazf.com/cgi-bin/lepos_pay_gateway.cgi', - sign_key: '', - notify_key: '', - notify_url: '', - jump_url: '', - pay_way: 'ZFBZF', - jspay_flag: '2', - sign_type: 'MD5', - is_default: false, - status: 'active', - environment: 'production', - business_tags: [], -}) +const defaultGateways: Record = { + leshua: 'https://paygate.leshuazf.com/cgi-bin/lepos_pay_gateway.cgi', + lakala: 'https://test.wsmsd.cn/sit', + mock: '', +} + +function defaultExtraConfig(provider: string) { + if (provider === 'lakala') { + return { + app_id: '', + serial_no: '', + term_no: '', + lakala_cert: '', + pay_mode: 'ALIPAY', + order_expire_minutes: 15, + } + } + return {} +} + +function makeDefaultForm(provider = 'leshua'): CreatePaymentConfigRequest { + return { + name: '', + provider, + merchant_id: '', + gateway_url: defaultGateways[provider] || '', + sign_key: '', + notify_key: '', + notify_url: '', + jump_url: '', + pay_way: 'ZFBZF', + jspay_flag: '2', + sign_type: provider === 'lakala' ? 'SHA256withRSA' : 'MD5', + extra_config: defaultExtraConfig(provider), + is_default: false, + status: 'active', + environment: provider === 'lakala' ? 'sandbox' : 'production', + business_tags: [], + } +} + +const formData = ref(makeDefaultForm()) const dialogTitle = computed(() => { const titles = { @@ -57,6 +82,16 @@ const dialogTitle = computed(() => { }) const isReadonly = computed(() => props.mode === 'view') +const isLakala = computed(() => formData.value.provider === 'lakala') +const isMock = computed(() => formData.value.provider === 'mock') +const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥')) +const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥')) +const extraConfig = computed>(() => { + if (!formData.value.extra_config) { + formData.value.extra_config = {} + } + return formData.value.extra_config +}) watch( () => props.modelValue, @@ -72,6 +107,7 @@ watch( pay_way: props.config.pay_way, jspay_flag: props.config.jspay_flag, sign_type: props.config.sign_type, + extra_config: props.config.extra_config || defaultExtraConfig(props.config.provider), is_default: props.config.is_default, status: props.config.status, environment: props.config.environment, @@ -81,23 +117,7 @@ watch( advancedOpen.value = [] } else if (val && !props.config) { // 重置表单 - formData.value = { - name: '', - provider: 'leshua', - merchant_id: '', - gateway_url: 'https://paygate.leshuazf.com/cgi-bin/lepos_pay_gateway.cgi', - sign_key: '', - notify_key: '', - notify_url: '', - jump_url: '', - pay_way: 'ZFBZF', - jspay_flag: '2', - sign_type: 'MD5', - is_default: false, - status: 'active', - environment: 'production', - business_tags: [], - } + formData.value = makeDefaultForm() showSecrets.value = false advancedOpen.value = [] } @@ -105,6 +125,17 @@ watch( { immediate: true } ) +function handleProviderChange(provider: string) { + if (props.mode !== 'create') return + const currentName = formData.value.name + const currentNotifyURL = formData.value.notify_url + formData.value = { + ...makeDefaultForm(provider), + name: currentName, + notify_url: currentNotifyURL, + } +} + async function handleViewSecrets() { if (!props.config) return loadingSecrets.value = true @@ -138,6 +169,7 @@ async function handleSave() { pay_way: formData.value.pay_way, jspay_flag: formData.value.jspay_flag, sign_type: formData.value.sign_type, + extra_config: formData.value.extra_config, is_default: formData.value.is_default, status: formData.value.status, environment: formData.value.environment, @@ -175,36 +207,61 @@ function handleClose() { - + + - + - + - + + + + + + + + + + + + + - + + + + + @@ -213,7 +270,7 @@ function handleClose() { 查看密钥 - + @@ -254,6 +311,18 @@ function handleClose() { + + + + + + + + + + + + @@ -264,6 +333,7 @@ function handleClose() { + diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index e749f59..9f045e3 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -29,6 +29,7 @@ const filterEnvironment = ref('') const providerOptions = [ { label: '全部', value: '' }, { label: '乐刷支付', value: 'leshua' }, + { label: '拉卡拉支付', value: 'lakala' }, { label: '模拟支付', value: 'mock' }, ] @@ -91,6 +92,10 @@ function handleEdit(row: PaymentConfig) { } async function handleDelete(row: PaymentConfig) { + if (!canDeleteConfig(row)) { + ElMessage.warning('该配置已有交易记录,需要保留用于退款、查询和回调验签') + return + } try { await ElMessageBox.confirm(`确定要删除配置"${row.name}"吗?`, '确认删除', { type: 'warning', @@ -118,6 +123,7 @@ function handleSaved() { function formatProvider(provider: string) { const map: Record = { leshua: '乐刷支付', + lakala: '拉卡拉支付', mock: '模拟支付', } return map[provider] || provider @@ -152,6 +158,14 @@ function getStatusType(status: string) { } return map[status] || 'info' } + +function canDeleteConfig(row: PaymentConfig) { + return row.total_transactions <= 0 +} + +function deleteDisabledReason(row: PaymentConfig) { + return canDeleteConfig(row) ? '删除配置' : '已有交易记录,需要保留用于退款、查询和回调验签' +} diff --git a/frontend/src/features/wallet/views/WalletView.vue b/frontend/src/features/wallet/views/WalletView.vue index 5adf807..2bf10df 100644 --- a/frontend/src/features/wallet/views/WalletView.vue +++ b/frontend/src/features/wallet/views/WalletView.vue @@ -2,14 +2,18 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { CircleCheck, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue' +import { CircleCheck, Coin, Loading, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue' +import QRCode from 'qrcode' import { fetchWalletBalance, fetchWalletLedger, + queryWalletRechargePayment, + startWalletRechargePayment, type WalletAccount, type WalletLedger, } from '@/features/wallet' +import type { PaymentOrder } from '@/features/orders' import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' @@ -20,6 +24,15 @@ const ledger = ref([]) const currentPage = ref(1) const currentPageSize = ref(20) const total = ref(0) +const isDev = import.meta.env.DEV +const quickRechargeAmounts = [0.01, 1, 10, 100] +const devRechargeAmount = ref(0.01) +const devRecharging = ref(false) +const rechargeDialogVisible = ref(false) +const activeRechargePayment = ref(null) +const rechargeQRCodeURL = ref('') +const rechargeQRGenerating = ref(false) +const checkingRecharge = ref(false) const walletMetrics = computed(() => { if (!account.value) { @@ -77,6 +90,96 @@ function handleWithdraw() { router.push('/wallet/withdrawal') } +function selectDevRechargeAmount(amount: number) { + devRechargeAmount.value = amount +} + +function rechargePayURL(payment = activeRechargePayment.value) { + return payment?.jspay_url || payment?.td_code || payment?.jspay_info || '' +} + +async function renderRechargeQRCode(payment = activeRechargePayment.value) { + const payURL = rechargePayURL(payment) + rechargeQRCodeURL.value = '' + if (!payURL) return + rechargeQRGenerating.value = true + try { + rechargeQRCodeURL.value = await QRCode.toDataURL(payURL, { + width: 220, + margin: 1, + errorCorrectionLevel: 'M', + color: { + dark: '#111827', + light: '#ffffff', + }, + }) + } catch { + ElMessage.error('二维码生成失败') + } finally { + rechargeQRGenerating.value = false + } +} + +async function handleDevRecharge() { + if (!isDev) return + if (devRechargeAmount.value <= 0) { + ElMessage.warning('请输入充值金额') + return + } + devRecharging.value = true + try { + const payment = await startWalletRechargePayment(devRechargeAmount.value) + activeRechargePayment.value = payment + rechargeDialogVisible.value = true + await renderRechargeQRCode(payment) + if (payment.paid) { + ElMessage.success('充值成功') + await loadWallet() + } else { + ElMessage.success('测试支付单已创建') + } + } catch (error) { + ElMessage.error(readError(error, '创建测试充值失败')) + } finally { + devRecharging.value = false + } +} + +async function checkDevRechargeStatus() { + if (!activeRechargePayment.value || checkingRecharge.value) return + checkingRecharge.value = true + try { + const payment = await queryWalletRechargePayment(activeRechargePayment.value.id) + activeRechargePayment.value = payment + await renderRechargeQRCode(payment) + if (payment.paid) { + ElMessage.success('充值成功') + rechargeDialogVisible.value = false + await loadWallet() + } else { + ElMessage.info('支付暂未完成') + } + } catch (error) { + ElMessage.error(readError(error, '查询支付状态失败')) + } finally { + checkingRecharge.value = false + } +} + +function openRechargePayURL() { + const payURL = rechargePayURL() + if (!payURL) return + window.open(payURL, '_blank', 'noopener,noreferrer') +} + +function readError(error: unknown, fallback: string) { + if (typeof error === 'object' && error && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response + return response?.data?.message || fallback + } + return fallback +} + function formatMoney(value: number) { return `¥${Number(value || 0).toFixed(2)}` } @@ -156,6 +259,35 @@ function amountPrefix(direction: string) {
+
+
+
+ +
+
+

测试充值

+

开发环境可见,用于快速验证当前支付渠道配置。

+
+
+
+ +
+
+ + + 创建测试支付 + +
+
+
@@ -216,6 +348,38 @@ function amountPrefix(direction: string) { />
+ +
+
+ 支付金额 + {{ activeRechargePayment ? formatMoney(activeRechargePayment.amount_cent / 100) : formatMoney(devRechargeAmount) }} +
+
+
+ + 测试充值支付二维码 + 支付链接已生成 +
+ 打开支付链接 +
+ +
+ 支付单号:{{ activeRechargePayment.payment_no }} + 渠道:{{ activeRechargePayment.provider }} + 状态:{{ activeRechargePayment.status }} +
+
+ +
@@ -394,6 +558,11 @@ function amountPrefix(direction: string) { color: #2563eb; } +.panel-title-icon.is-orange { + background: #fff4ec; + color: #ff6b00; +} + .panel-title h2 { margin: 0; color: #111827; @@ -437,6 +606,70 @@ function amountPrefix(direction: string) { gap: 12px; } +.recharge-cashier { + display: grid; + gap: 18px; +} + +.cashier-amount { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid #e6eaf2; + border-radius: 8px; + background: #f8fafc; +} + +.cashier-amount span, +.cashier-meta { + color: #64748b; + font-size: 13px; +} + +.cashier-amount strong { + color: #111a44; + font-size: 24px; +} + +.cashier-qr { + display: grid; + justify-items: center; + gap: 10px; +} + +.qr-box { + display: grid; + width: 236px; + height: 236px; + place-items: center; + border: 1px solid #e6eaf2; + border-radius: 8px; + background: #ffffff; +} + +.qr-box img { + width: 220px; + height: 220px; +} + +.qr-loading { + color: #64748b; + font-size: 28px; + animation: spin 0.9s linear infinite; +} + +.cashier-meta { + display: grid; + gap: 6px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + .wallet-ledger-table { overflow-x: auto; border: 1px solid #e6eaf2;