package api import ( "encoding/json" "errors" "log/slog" "net/http" "regexp" "strconv" "strings" "time" "unicode/utf8" "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) 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 }