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 }