dana-medical-site/internal/api/api.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

354 lines
17 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 api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"reflect"
"strings"
"time"
"dana/internal/bitrix"
"dana/internal/crm"
"dana/internal/megafon"
"dana/internal/notify"
"dana/internal/onec"
"dana/internal/prodoctorov"
"dana/internal/sberhealth"
"dana/internal/storage"
)
type API struct {
store *storage.Storage
mediaDir string
notifier notify.Notifier
website *website
pbx *megafon.Client
pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go)
onec *onec.Client
onecHook string
onecEvery time.Duration
onecLongPoll bool
bitrix *bitrix.Client
crm *crm.Client
crmMode string
smscLogin string
smscPassword string
smscCallbackToken string
prodoctorov *prodoctorov.Client
sberhealth *sberhealth.Client
limiter *rateLimiter
confirmKick chan struct{} // будит подтверждение записей (booking_confirm.go)
}
// WithBitrix включает разовую выгрузку номеров из Битрикс24 для «Отчёта по каналам».
// Без вебхука кнопка в отчёте просто не предлагается.
func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c }
// WithCRM подключает интеграцию с новой CRM (создание сделок и синхронизация).
func (a *API) WithCRM(c *crm.Client, mode string) *API {
a.crm = c
a.crmMode = strings.ToLower(strings.TrimSpace(mode))
return a
}
func (a *API) bitrixEnabled() bool {
if a.bitrix == nil || !a.bitrix.Configured() {
return false
}
m := a.crmMode
return m == "" || m == "both" || m == "bitrix_only"
}
func (a *API) crmEnabled() bool {
if a.crm == nil || !a.crm.IsConfigured() {
return false
}
m := a.crmMode
return m == "" || m == "both" || m == "crm_only"
}
func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password }
// WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются.
func (a *API) WithSMSCCallbackToken(t string) { a.smscCallbackToken = strings.TrimSpace(t) }
func New(store *storage.Storage, mediaDir string) *API {
return &API{store: store, mediaDir: mediaDir, limiter: newRateLimiter(), confirmKick: make(chan struct{}, 1)}
}
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
if a.website != nil {
mux.HandleFunc("GET /robots.txt", a.robots)
mux.HandleFunc("GET /sitemap.xml", a.sitemap)
mux.HandleFunc("GET /", a.websitePage)
}
mux.HandleFunc("GET /api/v1/health", a.health)
mux.HandleFunc("GET /api/v1/services", a.services)
mux.HandleFunc("GET /api/v1/services/{path...}", a.service)
mux.HandleFunc("GET /api/v1/specialties", a.specialties)
mux.HandleFunc("GET /api/v1/doctors", a.doctors)
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
mux.HandleFunc("GET /api/v1/prices", a.prices)
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)
mux.HandleFunc("GET /api/v1/checkups", a.checkups)
mux.HandleFunc("GET /api/v1/checkups/{slug}", a.checkup)
mux.HandleFunc("GET /api/v1/news", a.posts("news"))
mux.HandleFunc("GET /api/v1/news/{slug}", a.post("news"))
mux.HandleFunc("GET /api/v1/articles", a.posts("article"))
mux.HandleFunc("GET /api/v1/articles/{slug}", a.post("article"))
mux.HandleFunc("GET /api/v1/promos", a.posts("promo"))
mux.HandleFunc("GET /api/v1/promos/{slug}", a.post("promo"))
mux.HandleFunc("GET /api/v1/reviews", a.reviews)
mux.HandleFunc("GET /api/v1/faq", a.faq)
mux.HandleFunc("GET /api/v1/documents", a.documents)
mux.HandleFunc("GET /api/v1/partners", a.partners)
mux.HandleFunc("GET /api/v1/vacancies", a.vacancies)
mux.HandleFunc("GET /api/v1/branches", a.branches)
mux.HandleFunc("GET /api/v1/gallery", a.gallery)
mux.HandleFunc("GET /api/v1/settings", a.settings)
mux.HandleFunc("GET /api/v1/pages/{slug}", a.page)
mux.HandleFunc("GET /api/v1/banners", a.banners)
mux.HandleFunc("GET /api/v1/search", a.search)
// Формы и заявки с сайта.
mux.HandleFunc("GET /api/v1/forms", a.forms)
mux.HandleFunc("POST /api/v1/leads", a.createLead)
mux.HandleFunc("POST /api/v1/appointments", a.createAppointment)
mux.HandleFunc("POST /api/v1/auth/wait-call/init", a.waitCallInit)
mux.HandleFunc("GET /api/v1/auth/wait-call/status", a.waitCallStatus)
mux.HandleFunc("POST /api/v1/auth/smsc-callback", a.smscCallback)
mux.HandleFunc("GET /api/v1/auth/smsc-callback", a.smscCallback)
// --- админка ---
mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin)
mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout)
mux.HandleFunc("GET /api/v1/admin/me", a.requireAuth(a.adminMe))
mux.HandleFunc("GET /api/v1/admin/services", a.requireAuth(a.adminServices))
mux.HandleFunc("POST /api/v1/admin/services", a.requireAuth(a.adminCreateService))
mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService))
mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents)
mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec))
mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/booking", a.requireAuth(a.adminOnecBooking))
mux.HandleFunc("GET /api/v1/admin/onec/grid", a.requireAuth(a.adminOnecGrid))
mux.HandleFunc("GET /api/v1/admin/onec/appointments", a.requireAuth(a.adminOnecAppointments))
mux.HandleFunc("GET /api/v1/admin/booking-settings", a.requireAuth(a.adminBookingSettings))
mux.HandleFunc("PUT /api/v1/admin/booking-settings", a.requireAuth(a.adminUpdateBookingSettings))
mux.HandleFunc("GET /api/v1/admin/leads", a.requireAuth(a.adminLeads))
mux.HandleFunc("PUT /api/v1/admin/leads/{id}", a.requireAuth(a.adminUpdateLead))
mux.HandleFunc("GET /api/v1/admin/reviews", a.requireAuth(a.adminReviews))
mux.HandleFunc("POST /api/v1/admin/reviews", a.requireAuth(a.adminCreateReview))
mux.HandleFunc("PUT /api/v1/admin/reviews/{id}", a.requireAuth(a.adminUpdateReview))
mux.HandleFunc("DELETE /api/v1/admin/reviews/{id}", a.requireAuth(a.adminDeleteReview))
mux.HandleFunc("GET /api/v1/admin/banners", a.requireAuth(a.adminBanners))
mux.HandleFunc("POST /api/v1/admin/banners", a.requireAuth(a.adminCreateBanner))
mux.HandleFunc("PUT /api/v1/admin/banners/{id}", a.requireAuth(a.adminUpdateBanner))
mux.HandleFunc("DELETE /api/v1/admin/banners/{id}", a.requireAuth(a.adminDeleteBanner))
mux.HandleFunc("POST /api/v1/admin/media", a.requireAuth(a.adminUploadMedia))
mux.HandleFunc("GET /api/v1/admin/doctors", a.requireAuth(a.adminDoctors))
mux.HandleFunc("GET /api/v1/admin/doctors-quality", a.requireAuth(a.adminDoctorQuality))
mux.HandleFunc("POST /api/v1/admin/doctors", a.requireAuth(a.adminCreateDoctor))
mux.HandleFunc("PUT /api/v1/admin/doctors/{id}", a.requireAuth(a.adminUpdateDoctor))
mux.HandleFunc("GET /api/v1/admin/specialties", a.requireAuth(a.adminSpecialties))
mux.HandleFunc("POST /api/v1/admin/specialties", a.requireAuth(a.adminCreateSpecialty))
mux.HandleFunc("PUT /api/v1/admin/specialties/{id}", a.requireAuth(a.adminUpdateSpecialty))
mux.HandleFunc("DELETE /api/v1/admin/specialties/{id}", a.requireAuth(a.adminDeleteSpecialty))
mux.HandleFunc("PUT /api/v1/admin/doctors/{id}/links", a.requireAuth(a.adminSetDoctorLinks))
mux.HandleFunc("GET /api/v1/admin/doctors/{id}/bio", a.requireAuth(a.adminBioEntries))
mux.HandleFunc("POST /api/v1/admin/bio-entries", a.requireAuth(a.adminCreateBioEntry))
mux.HandleFunc("PUT /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminUpdateBioEntry))
mux.HandleFunc("DELETE /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminDeleteBioEntry))
mux.HandleFunc("GET /api/v1/admin/price-sections", a.requireAuth(a.adminPriceSections))
mux.HandleFunc("GET /api/v1/admin/price-items", a.requireAuth(a.adminPriceItems))
mux.HandleFunc("POST /api/v1/admin/price-items", a.requireAuth(a.adminCreatePriceItem))
mux.HandleFunc("PUT /api/v1/admin/price-items/{id}", a.requireAuth(a.adminUpdatePriceItem))
mux.HandleFunc("DELETE /api/v1/admin/price-items/{id}", a.requireAuth(a.adminDeletePriceItem))
mux.HandleFunc("GET /api/v1/admin/promos", a.requireAuth(a.adminPromos))
mux.HandleFunc("POST /api/v1/admin/promos", a.requireAuth(a.adminCreatePromo))
mux.HandleFunc("PUT /api/v1/admin/promos/{id}", a.requireAuth(a.adminUpdatePromo))
mux.HandleFunc("DELETE /api/v1/admin/promos/{id}", a.requireAuth(a.adminDeletePromo))
mux.HandleFunc("GET /api/v1/admin/checkups", a.requireAuth(a.adminCheckups))
mux.HandleFunc("POST /api/v1/admin/checkups", a.requireAuth(a.adminCreateCheckup))
mux.HandleFunc("PUT /api/v1/admin/checkups/{id}", a.requireAuth(a.adminUpdateCheckup))
// Новости (/admin/news) и статьи (/admin/articles) — одинаковые обработчики с разным видом записи.
for section, kind := range postKinds {
base := "/api/v1/admin/" + section
mux.HandleFunc("GET "+base, a.requireAuth(a.adminPosts(kind)))
mux.HandleFunc("POST "+base, a.requireAuth(a.adminCreatePost(kind)))
mux.HandleFunc("PUT "+base+"/{id}", a.requireAuth(a.adminUpdatePost(kind)))
mux.HandleFunc("DELETE "+base+"/{id}", a.requireAuth(a.adminDeletePost(kind)))
}
mux.HandleFunc("GET /api/v1/admin/vacancies", a.requireAuth(a.adminVacancies))
mux.HandleFunc("POST /api/v1/admin/vacancies", a.requireAuth(a.adminCreateVacancy))
mux.HandleFunc("PUT /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminUpdateVacancy))
mux.HandleFunc("DELETE /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminDeleteVacancy))
mux.HandleFunc("GET /api/v1/admin/documents", a.requireAuth(a.adminDocuments))
mux.HandleFunc("POST /api/v1/admin/documents", a.requireAuth(a.adminCreateDocument))
mux.HandleFunc("PUT /api/v1/admin/documents/{id}", a.requireAuth(a.adminUpdateDocument))
mux.HandleFunc("DELETE /api/v1/admin/documents/{id}", a.requireAuth(a.adminDeleteDocument))
mux.HandleFunc("GET /api/v1/admin/partners", a.requireAuth(a.adminPartners))
mux.HandleFunc("POST /api/v1/admin/partners", a.requireAuth(a.adminCreatePartner))
mux.HandleFunc("PUT /api/v1/admin/partners/{id}", a.requireAuth(a.adminUpdatePartner))
mux.HandleFunc("DELETE /api/v1/admin/partners/{id}", a.requireAuth(a.adminDeletePartner))
mux.HandleFunc("GET /api/v1/admin/dashboard", a.requireAuth(a.adminDashboard))
mux.HandleFunc("GET /api/v1/admin/stats", a.requireAuth(a.adminStats))
mux.HandleFunc("GET /api/v1/admin/clinics", a.requireAuth(a.adminClinics))
mux.HandleFunc("GET /api/v1/admin/share-link", a.requireAuth(a.adminShareLink))
mux.HandleFunc("POST /api/v1/admin/share-link", a.requireAuth(a.adminCreateShareLink))
mux.HandleFunc("DELETE /api/v1/admin/share-link", a.requireAuth(a.adminDeleteShareLink))
mux.HandleFunc("GET /api/v1/share/weekly-reports", a.shareWeeklyReports)
mux.HandleFunc("GET /api/v1/admin/pbx/status", a.requireAuth(a.adminPBXStatus))
mux.HandleFunc("POST /api/v1/admin/pbx/check", a.requireAuth(a.adminPBXCheck))
mux.HandleFunc("POST /api/v1/admin/pbx/sync", a.requireAuth(a.adminPBXSync))
mux.HandleFunc("GET /api/v1/admin/pbx/lines", a.requireAuth(a.adminPBXLines))
mux.HandleFunc("PUT /api/v1/admin/pbx/lines", a.requireAuth(a.adminSetPBXLine))
mux.HandleFunc("GET /api/v1/admin/pbx/report", a.requireAuth(a.adminPBXReport))
mux.HandleFunc("GET /api/v1/admin/reports", a.requireAuth(a.adminReports))
mux.HandleFunc("POST /api/v1/admin/reports", a.requireAuth(a.adminCreateReport))
mux.HandleFunc("POST /api/v1/admin/reports/period", a.requireAuth(a.adminReportPeriod))
mux.HandleFunc("GET /api/v1/admin/reports/{id}", a.requireAuth(a.adminReport))
mux.HandleFunc("POST /api/v1/admin/reports/{id}/cancel", a.requireAuth(a.adminCancelReport))
mux.HandleFunc("DELETE /api/v1/admin/reports/{id}", a.requireAuth(a.adminDeleteReport))
mux.HandleFunc("GET /api/v1/admin/reports/{id}/download", a.requireAuth(a.adminDownloadReport))
mux.HandleFunc("GET /api/v1/admin/weekly-reports", a.requireAuth(a.adminWeeklyReports))
mux.HandleFunc("DELETE /api/v1/admin/weekly-reports/{id}", a.requireAuth(a.adminDeleteWeeklyReport))
mux.HandleFunc("PUT /api/v1/admin/weekly-reports/{id}/budget", a.requireAuth(a.adminSetWeeklyBudget))
mux.HandleFunc("GET /api/v1/admin/redirects", a.requireAuth(a.adminRedirects))
mux.HandleFunc("POST /api/v1/admin/redirects", a.requireAuth(a.adminCreateRedirect))
mux.HandleFunc("DELETE /api/v1/admin/redirects/{id}", a.requireAuth(a.adminDeleteRedirect))
mux.HandleFunc("DELETE /api/v1/admin/not-found", a.requireAuth(a.adminDeleteNotFound))
mux.HandleFunc("GET /api/v1/admin/pages", a.requireAuth(a.adminPages))
mux.HandleFunc("PUT /api/v1/admin/pages/{id}", a.requireAuth(a.adminUpdatePage))
mux.HandleFunc("GET /api/v1/admin/settings", a.requireAuth(a.adminSettings))
mux.HandleFunc("PUT /api/v1/admin/settings", a.requireAuth(a.adminUpdateSettings))
mux.HandleFunc("GET /api/v1/admin/branches", a.requireAuth(a.adminBranches))
mux.HandleFunc("POST /api/v1/admin/branches", a.requireAuth(a.adminCreateBranch))
mux.HandleFunc("PUT /api/v1/admin/branches/{id}", a.requireAuth(a.adminUpdateBranch))
mux.HandleFunc("GET /api/v1/admin/faq", a.requireAuth(a.adminFAQ))
mux.HandleFunc("POST /api/v1/admin/faq", a.requireAuth(a.adminCreateFAQ))
mux.HandleFunc("PUT /api/v1/admin/faq/{id}", a.requireAuth(a.adminUpdateFAQ))
mux.HandleFunc("DELETE /api/v1/admin/faq/{id}", a.requireAuth(a.adminDeleteFAQ))
// Интерфейс админки (встроен в бинарник).
mux.Handle("GET /admin/", http.StripPrefix("/admin/", http.FileServer(http.FS(adminFS()))))
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusFound)
})
// Раздача загруженных картинок: /media/... -> файлы из mediaDir.
if a.mediaDir != "" {
mux.Handle("GET /media/", mediaHandler(a.mediaDir))
}
return logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Заголовки безопасности на все ответы. CSP только frame-ancestors —
// защита от кликджекинга без риска сломать SPA (скрипты/стили не трогаем).
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "SAMEORIGIN")
// Безопасные директивы, не ломающие SPA: запрет плагинов, чужого <base>,
// отправки форм на сторонние адреса и встраивания сайта в чужие фреймы.
// Полный script-src/style-src добавляется отдельно (сначала в Report-Only).
h.Set("Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Strict-Transport-Security", "max-age=31536000") // браузер учитывает только по HTTPS
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") {
h.Set("X-Robots-Tag", "noindex, nofollow")
}
mux.ServeHTTP(w, r)
}))
}
type envelope struct {
Data any `json:"data"`
Meta map[string]any `json:"meta,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
slog.Error("encode response", "err", err)
}
}
func ok(w http.ResponseWriter, data any, meta map[string]any) {
writeJSON(w, http.StatusOK, envelope{Data: emptyIfNil(data), Meta: meta})
}
// emptyIfNil превращает nil-срез в пустой: Go кодирует его как null, и фронт
// падал бы на пустой выборке (поиск без результатов, врачи без отзывов).
func emptyIfNil(data any) any {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice && v.IsNil() {
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
}
return data
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func fail(w http.ResponseWriter, status int, code, message string) {
var e apiError
e.Error.Code = code
e.Error.Message = message
writeJSON(w, status, e)
}
// handleErr переводит ошибки хранилища в ответ API.
func handleErr(w http.ResponseWriter, err error) {
if errors.Is(err, storage.ErrNotFound) {
fail(w, http.StatusNotFound, "not_found", "Не найдено")
return
}
if errors.Is(err, storage.ErrBadReference) {
fail(w, http.StatusUnprocessableEntity, "bad_reference", "Связанная запись не найдена, обновите страницу")
return
}
if errors.Is(err, storage.ErrConflict) {
fail(w, http.StatusConflict, "conflict", "Такой адрес уже занят другой записью")
return
}
slog.Error("request failed", "err", err)
fail(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка")
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Debug("request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}