dana-medical-site/internal/crm/client.go
Abdulkadyr Karagishiev 121f286016 feat(crm): integrate website leads and online booking with new CRM and 1C
- 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
2026-10-16 18:01:49 +03:00

171 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}