dana-medical-site/internal/api/onec_webhook.go
2026-10-14 13:33:13 +00:00

136 lines
4.9 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 (
"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)
}
}()
}