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