package api import ( "context" "crypto/sha256" "crypto/subtle" "encoding/json" "log/slog" "net/http" "strings" "sync/atomic" "time" ) // Входящие события от 1С. Опрос раз в 5 минут остаётся основным способом, вебхук только ускоряет обновление: // 1С сообщает, что расписание врача изменилось или запись сменила статус, а сайт сразу перечитывает данные из 1С. // Данные из тела события в расписание не пишутся — источник правды всегда ответ API 1С. const maxOnecEventBody = 8 << 10 // WithOneCWebhook включает приём событий от 1С; пустой ключ — вебхук выключен. func (a *API) WithOneCWebhook(key string) { a.onecHook = strings.TrimSpace(key) } var onecEventSyncBusy atomic.Bool type onecEvent struct { Event string `json:"event"` DoctorID string `json:"doctor_id"` AppointmentID string `json:"appointment_id"` Status string `json:"status"` } func sameKey(got, want string) bool { g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want)) return subtle.ConstantTimeCompare(g[:], w[:]) == 1 } func (a *API) onecEvents(w http.ResponseWriter, r *http.Request) { if a.onecHook == "" || !a.onec.Configured() { fail(w, http.StatusNotFound, "not_found", "Не найдено") return } ip := clientIP(r) if a.tooMany(w, "onec-ev:"+ip, 300, time.Minute) { return } key := r.Header.Get("X-Api-Key") if key == "" { key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } if !sameKey(key, a.onecHook) { // неверные ключи считаем отдельно: перебор упирается в лимит if a.tooMany(w, "onec-bad:"+ip, 20, 10*time.Minute) { return } fail(w, http.StatusUnauthorized, "unauthorized", "Неверный ключ") return } var ev onecEvent if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOnecEventBody)).Decode(&ev); err != nil { fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать событие") return } ev.DoctorID = strings.TrimSpace(ev.DoctorID) switch ev.Event { case "schedule_changed": if ev.DoctorID == "" { a.onecFullResyncAsync(false) writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "schedule"}}) return } if err := a.resyncOnecDoctor(r.Context(), ev.DoctorID); err != nil { fail(w, http.StatusBadGateway, "onec_sync", onecErrText(err)) return } ok(w, map[string]any{"ok": true}, nil) case "directory_changed": a.onecFullResyncAsync(true) writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "directory"}}) case "appointment_status": if ev.AppointmentID == "" || (ev.Status != "booked" && ev.Status != "arrived" && ev.Status != "cancelled") { fail(w, http.StatusBadRequest, "validation", "Нужны appointment_id и status: booked, arrived или cancelled") return } doctorID, _, found, err := a.store.SetOnecAppointmentStatus(r.Context(), ev.AppointmentID, ev.Status) if err != nil { handleErr(w, err) return } if !found { // запись сделана не с сайта — нам она не нужна, но для 1С это не ошибка ok(w, map[string]any{"ok": true, "known": false}, nil) return } if ev.Status == "cancelled" { if err := a.resyncOnecDoctor(r.Context(), doctorID); err != nil { slog.Warn("onec webhook: окна после отмены не обновились", "doctor", doctorID, "err", err) } } ok(w, map[string]any{"ok": true, "known": true}, nil) default: fail(w, http.StatusBadRequest, "validation", "Неизвестное событие: допустимы schedule_changed, directory_changed, appointment_status") } } // resyncOnecDoctor заново забирает окна одного врача на весь горизонт. func (a *API) resyncOnecDoctor(ctx context.Context, onecID string) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() from, to := onecWindow() slots, err := a.onec.Schedule(ctx, from, to, onecID) if err != nil { return err } return a.store.ReplaceOnecDoctorSlots(ctx, onecID, from, to, slots) } // onecFullResyncAsync запускает полную синхронизацию в фоне, чтобы 1С не ждала ответа; пока идёт одна — вторая не ставится. func (a *API) onecFullResyncAsync(dir bool) { if !onecEventSyncBusy.CompareAndSwap(false, true) { return } go func() { defer onecEventSyncBusy.Store(false) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if err := a.syncOneC(ctx, dir); err != nil { slog.Warn("onec webhook sync", "err", err) } }() }