- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads - Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes - Add async lead intake dispatch to new CRM in handlers_leads.go - Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go - Add LinkCRM endpoint to bridge_1c.py and onec.Client - Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
171 lines
5.0 KiB
Go
171 lines
5.0 KiB
Go
package crm
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Client — клиент для передачи заявок в новую CRM (dana-medccrm).
|
||
type Client struct {
|
||
BaseURL string
|
||
HMACSecret string
|
||
HTTP *http.Client
|
||
}
|
||
|
||
// New инициализирует клиент новой CRM.
|
||
func New(baseURL, hmacSecret string) *Client {
|
||
return &Client{
|
||
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||
HMACSecret: strings.TrimSpace(hmacSecret),
|
||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||
}
|
||
}
|
||
|
||
// Configured возвращает true, если адрес CRM и секрет настроены.
|
||
func (c *Client) Configured() bool {
|
||
return c != nil && c.BaseURL != "" && c.HMACSecret != ""
|
||
}
|
||
|
||
// IsConfigured — алиас для Configured.
|
||
func (c *Client) IsConfigured() bool {
|
||
return c.Configured()
|
||
}
|
||
|
||
// CanonicalJSON рекурсивно формирует каноническую строку JSON
|
||
// с отсортированными ключами объектов (идентично реализации в dana-medccrm publicLeads).
|
||
func CanonicalJSON(v any) (string, error) {
|
||
if v == nil {
|
||
return "null", nil
|
||
}
|
||
switch val := v.(type) {
|
||
case bool:
|
||
if val {
|
||
return "true", nil
|
||
}
|
||
return "false", nil
|
||
case string:
|
||
b, err := json.Marshal(val)
|
||
return string(b), err
|
||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||
b, err := json.Marshal(val)
|
||
return string(b), err
|
||
case []any:
|
||
parts := make([]string, len(val))
|
||
for i, elem := range val {
|
||
s, err := CanonicalJSON(elem)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
parts[i] = s
|
||
}
|
||
return "[" + strings.Join(parts, ",") + "]", nil
|
||
case map[string]any:
|
||
keys := make([]string, 0, len(val))
|
||
for k := range val {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
parts := make([]string, len(keys))
|
||
for i, k := range keys {
|
||
kb, _ := json.Marshal(k)
|
||
vstr, err := CanonicalJSON(val[k])
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
parts[i] = string(kb) + ":" + vstr
|
||
}
|
||
return "{" + strings.Join(parts, ",") + "}", nil
|
||
default:
|
||
// Для структур сначала приводим к generic типу через JSON round-trip
|
||
raw, err := json.Marshal(v)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var generic any
|
||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||
return "", err
|
||
}
|
||
return CanonicalJSON(generic)
|
||
}
|
||
}
|
||
|
||
// SignPayload рассчитывает SHA256-хэш тела и HMAC-SHA256 подпись по протоколу publicLeads.
|
||
func SignPayload(secret, timestamp, idempotencyKey, canonicalBody string) string {
|
||
h := sha256.New()
|
||
h.Write([]byte(canonicalBody))
|
||
payloadHash := hex.EncodeToString(h.Sum(nil))
|
||
|
||
signInput := timestamp + "." + idempotencyKey + "." + payloadHash
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(signInput))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// CreateLead отправляет лид в CRM через POST /api/public/leads.
|
||
// При успехе возвращает ID созданной сделки (dealId).
|
||
func (c *Client) CreateLead(ctx context.Context, draft LeadDraft, idempotencyKey string) (string, error) {
|
||
if !c.Configured() {
|
||
return "", errors.New("crm: клиент не настроен (отсутствует CRM_API_URL или CRM_LEAD_INTAKE_SECRET)")
|
||
}
|
||
|
||
canonicalBody, err := CanonicalJSON(draft)
|
||
if err != nil {
|
||
return "", fmt.Errorf("crm: ошибка сериализации канонического тела: %w", err)
|
||
}
|
||
|
||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||
if idempotencyKey == "" {
|
||
idempotencyKey = fmt.Sprintf("site-%d-%d", time.Now().Unix(), time.Now().Nanosecond())
|
||
}
|
||
|
||
sig := SignPayload(c.HMACSecret, timestamp, idempotencyKey, canonicalBody)
|
||
|
||
url := c.BaseURL + "/api/public/leads"
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(canonicalBody))
|
||
if err != nil {
|
||
return "", fmt.Errorf("crm: ошибка создания запроса: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("x-gt-timestamp", timestamp)
|
||
req.Header.Set("x-gt-idempotency-key", idempotencyKey)
|
||
req.Header.Set("x-gt-signature", sig)
|
||
|
||
resp, err := c.HTTP.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("crm: сетевая ошибка при вызове %s: %w", url, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 400 {
|
||
var errResp ErrorResponse
|
||
_ = json.NewDecoder(resp.Body).Decode(&errResp)
|
||
if errResp.Error != "" {
|
||
return "", fmt.Errorf("crm HTTP %d: %s", resp.StatusCode, errResp.Error)
|
||
}
|
||
return "", fmt.Errorf("crm HTTP %d", resp.StatusCode)
|
||
}
|
||
|
||
var leadResp LeadResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&leadResp); err != nil {
|
||
return "", fmt.Errorf("crm: ошибка разбора ответа: %w", err)
|
||
}
|
||
|
||
if !leadResp.OK && leadResp.DealID == "" {
|
||
return "", errors.New("crm: неожиданный ответ без dealId")
|
||
}
|
||
|
||
return leadResp.DealID, nil
|
||
}
|