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" "hfb_sys/backend/internal/timeutil" ) 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 := normalizeCounterPayMode(c.cfg.PayMode) expireMinutes := c.cfg.OrderExpireMinutes if expireMinutes <= 0 { expireMinutes = 15 } now := timeutil.ShanghaiNow() reqData := map[string]any{ "out_order_no": req.ThirdOrderID, "merchant_no": c.cfg.MerchantID, "total_amount": req.AmountCent, "order_efficient_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, "order_info": sanitizeText(req.Body, 128), } if payMode != "" { reqData["counter_param"] = fmt.Sprintf(`{"pay_mode":"%s"}`, payMode) } 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": timeutil.ShanghaiNow().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"])) if value == "" && responseOK(raw) { return "refunded" } 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 normalizeCounterPayMode(value string) string { switch strings.ToUpper(strings.TrimSpace(value)) { case "", "AUTO", "ALL", "NONE", "UNLIMITED": return "" default: return strings.ToUpper(strings.TrimSpace(value)) } } 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 "" }