- 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
354 lines
17 KiB
Go
354 lines
17 KiB
Go
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)
|
||
})
|
||
}
|