- 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
299 lines
9.6 KiB
Go
299 lines
9.6 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"dana/internal/crm"
|
||
"dana/internal/domain"
|
||
"dana/internal/storage"
|
||
)
|
||
|
||
// В телефоне оставляем только цифры и считаем их: так «+7 (928) 962-00-94»,
|
||
// «8 928 962 00 94» и «79289620094» проходят одинаково.
|
||
var digitsRE = regexp.MustCompile(`\D+`)
|
||
|
||
const maxLeadBody = 64 << 10 // 64 КБ — заявка столько не весит, всё сверху отсекаем
|
||
|
||
func (a *API) forms(w http.ResponseWriter, r *http.Request) {
|
||
list, err := a.store.Forms(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, list, map[string]any{"total": len(list)})
|
||
}
|
||
|
||
// fieldRequired сообщает, обязательно ли поле в описании формы.
|
||
func fieldRequired(f *domain.Form, code string) bool {
|
||
if f == nil {
|
||
return false
|
||
}
|
||
for _, fl := range f.Fields {
|
||
if fl.Code == code {
|
||
return fl.Required
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// createLead принимает заявку с сайта: запись на приём, обратный звонок и прочее.
|
||
func (a *API) createLead(w http.ResponseWriter, r *http.Request) {
|
||
// защита от спама: не больше 15 заявок с одного IP за 10 минут
|
||
if a.tooMany(w, "lead:"+clientIP(r), 15, 10*time.Minute) {
|
||
return
|
||
}
|
||
var in domain.LeadInput
|
||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxLeadBody))
|
||
if err := dec.Decode(&in); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать запрос")
|
||
return
|
||
}
|
||
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
in.Phone = strings.TrimSpace(in.Phone)
|
||
in.Email = strings.TrimSpace(in.Email)
|
||
in.Message = strings.TrimSpace(in.Message)
|
||
|
||
// Не в UTF-8 (например, запрос из консоли Windows) декодер JSON молча превращает
|
||
// кириллицу в «<>», и в базу попадает имя, которое уже не восстановить. Лучше отказать.
|
||
for _, v := range []string{in.Name, in.Phone, in.Email, in.Message} {
|
||
if strings.ContainsRune(v, utf8.RuneError) {
|
||
fail(w, http.StatusBadRequest, "bad_encoding", "Текст заявки пришёл в неверной кодировке — отправьте форму ещё раз")
|
||
return
|
||
}
|
||
}
|
||
|
||
in.UTM = cleanUTM(in.UTM)
|
||
|
||
// Согласие на обработку персональных данных — обязательное условие (152-ФЗ),
|
||
// без него заявку не сохраняем вообще.
|
||
if !in.Consent {
|
||
fail(w, http.StatusBadRequest, "consent_required",
|
||
"Нужно согласие на обработку персональных данных")
|
||
return
|
||
}
|
||
// Телефон нужен всегда: без него с человеком не связаться.
|
||
if len(digitsRE.ReplaceAllString(in.Phone, "")) < 10 {
|
||
fail(w, http.StatusBadRequest, "phone_invalid", "Укажите корректный телефон")
|
||
return
|
||
}
|
||
// не больше 5 заявок в час с одного номера, даже если IP меняются
|
||
if a.tooMany(w, "lead-phone:"+lastDigits(in.Phone, 10), 5, time.Hour) {
|
||
return
|
||
}
|
||
if in.Email != "" && !strings.Contains(in.Email, "@") {
|
||
fail(w, http.StatusBadRequest, "email_invalid", "Укажите корректный e-mail")
|
||
return
|
||
}
|
||
|
||
// Форма необязательна: если код не передан или неизвестен, заявка всё равно
|
||
// сохранится — терять обращение из-за рассинхрона фронта и админки нельзя.
|
||
var form *domain.Form
|
||
if in.FormCode != "" {
|
||
f, err := a.store.FormByCode(r.Context(), in.FormCode)
|
||
switch {
|
||
case err == nil:
|
||
form = f
|
||
case errors.Is(err, storage.ErrFormNotFound):
|
||
slog.Warn("lead with unknown form code", "code", in.FormCode)
|
||
default:
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Какие поля обязательны, решает описание формы: в блоке «Консультация»
|
||
// на главной у обратного звонка есть только телефон.
|
||
if fieldRequired(form, "name") && in.Name == "" {
|
||
fail(w, http.StatusBadRequest, "name_required", "Укажите имя")
|
||
return
|
||
}
|
||
if fieldRequired(form, "message") && in.Message == "" {
|
||
fail(w, http.StatusBadRequest, "message_required", "Заполните текст сообщения")
|
||
return
|
||
}
|
||
if fieldRequired(form, "email") && in.Email == "" {
|
||
fail(w, http.StatusBadRequest, "email_required", "Укажите e-mail")
|
||
return
|
||
}
|
||
|
||
var formID *int64
|
||
successText := ""
|
||
if form != nil {
|
||
formID = &form.ID
|
||
successText = form.SuccessText
|
||
}
|
||
|
||
id, err := a.store.CreateLead(r.Context(), formID, in, clientIP(r), r.UserAgent())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
|
||
// Отзыв с сайта дополнительно кладём в очередь модерации — иначе он остался бы
|
||
// только среди заявок и на карточку врача не попал бы никогда.
|
||
if form != nil && form.Code == "review" {
|
||
if err := a.store.CreateSiteReview(r.Context(), in.Name, in.Message,
|
||
extraRating(in.Extra), in.DoctorID); err != nil {
|
||
// Заявка уже сохранена, поэтому ответ не роняем — только пишем в лог.
|
||
slog.Error("create review from lead", "lead_id", id, "err", err)
|
||
}
|
||
}
|
||
|
||
a.notifyLead(id)
|
||
a.sendLeadToCRM(id)
|
||
|
||
if successText == "" {
|
||
successText = "Спасибо! Мы свяжемся с вами в ближайшее время."
|
||
}
|
||
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
|
||
"id": id,
|
||
"message": successText,
|
||
}})
|
||
}
|
||
|
||
// extraRating достаёт оценку 1–5 из дополнительных полей формы отзыва.
|
||
func extraRating(extra map[string]any) *int {
|
||
v, found := extra["rating"]
|
||
if !found {
|
||
return nil
|
||
}
|
||
var n int
|
||
switch x := v.(type) {
|
||
case float64:
|
||
n = int(x)
|
||
case string:
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(x))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
n = parsed
|
||
default:
|
||
return nil
|
||
}
|
||
if n < 1 || n > 5 {
|
||
return nil
|
||
}
|
||
return &n
|
||
}
|
||
|
||
// utmKeys — что сохраняем об источнике заявки: UTM-метки и внешний сайт, с которого пришли.
|
||
var utmKeys = map[string]bool{
|
||
"utm_source": true, "utm_medium": true, "utm_campaign": true,
|
||
"utm_content": true, "utm_term": true, "referrer": true,
|
||
}
|
||
|
||
// cleanUTM оставляет только известные поля и обрезает длинные значения:
|
||
// это поле приходит из браузера и не должно разрастаться произвольными ключами.
|
||
func cleanUTM(in map[string]string) map[string]string {
|
||
out := map[string]string{}
|
||
for k, v := range in {
|
||
v = strings.TrimSpace(v)
|
||
if !utmKeys[k] || v == "" || strings.ContainsRune(v, utf8.RuneError) {
|
||
continue
|
||
}
|
||
if r := []rune(v); len(r) > 200 {
|
||
v = string(r[:200])
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sendLeadToCRM отправляет заявку с сайта в новую CRM в фоновом режиме.
|
||
func (a *API) sendLeadToCRM(id int64) {
|
||
if !a.crmEnabled() {
|
||
return
|
||
}
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
it, err := a.store.LeadForCRM(ctx, id)
|
||
if err != nil {
|
||
slog.Error("crm lead fetch", "lead_id", id, "err", err)
|
||
return
|
||
}
|
||
|
||
draft := leadItemToCRMDraft(it)
|
||
dealID, err := a.crm.CreateLead(ctx, draft, "")
|
||
if err != nil {
|
||
slog.Error("crm lead create failed", "lead_id", id, "err", err)
|
||
_ = a.store.SetLeadCrmError(ctx, id, err.Error())
|
||
return
|
||
}
|
||
|
||
if err := a.store.SetLeadCrmDeal(ctx, id, dealID); err != nil {
|
||
slog.Error("crm save lead deal id", "lead_id", id, "deal_id", dealID, "err", err)
|
||
return
|
||
}
|
||
slog.Info("crm lead created", "lead_id", id, "deal_id", dealID)
|
||
}()
|
||
}
|
||
|
||
func leadItemToCRMDraft(it storage.LeadCRMItem) crm.LeadDraft {
|
||
phone := it.Phone
|
||
if n := normalizeRuPhone(it.Phone); n != "" {
|
||
phone = "+" + n
|
||
}
|
||
|
||
var commentLines []string
|
||
if it.FormTitle != "" {
|
||
commentLines = append(commentLines, "Форма: "+it.FormTitle)
|
||
}
|
||
if it.Service != "" {
|
||
commentLines = append(commentLines, "Услуга: "+it.Service)
|
||
}
|
||
if it.Doctor != "" {
|
||
commentLines = append(commentLines, "Врач: "+it.Doctor)
|
||
}
|
||
if it.Branch != "" {
|
||
commentLines = append(commentLines, "Филиал: "+it.Branch)
|
||
}
|
||
if it.Message != "" {
|
||
commentLines = append(commentLines, "Сообщение: "+it.Message)
|
||
}
|
||
|
||
sourceLabel := "Заявка с сайта"
|
||
if it.FormTitle != "" {
|
||
sourceLabel = "Сайт: " + it.FormTitle
|
||
}
|
||
|
||
pagePath := ""
|
||
if it.PageURL != "" {
|
||
if u, err := url.Parse(it.PageURL); err == nil && u.Path != "" {
|
||
pagePath = u.Path
|
||
}
|
||
}
|
||
|
||
return crm.LeadDraft{
|
||
LocalLeadId: fmt.Sprintf("site_lead_%d", it.ID),
|
||
Name: it.Name,
|
||
Phone: phone,
|
||
Email: it.Email,
|
||
Comment: strings.Join(commentLines, "\n"),
|
||
SourceKey: "site",
|
||
SourceLabel: sourceLabel,
|
||
PagePath: pagePath,
|
||
PageUrl: it.PageURL,
|
||
Referer: it.UTM["referrer"],
|
||
UtmSource: it.UTM["utm_source"],
|
||
UtmMedium: it.UTM["utm_medium"],
|
||
UtmCampaign: it.UTM["utm_campaign"],
|
||
UtmTerm: it.UTM["utm_term"],
|
||
UtmContent: it.UTM["utm_content"],
|
||
AttributionTimestamp: &it.CreatedAt,
|
||
}
|
||
}
|