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

344 lines
9.3 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/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type WaitCallSession struct {
ID string `json:"session_id"`
Phone string `json:"phone"` // e.g. "79896657520"
TargetPhone string `json:"target_phone"` // e.g. "79019605980"
DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80"
Verified bool `json:"verified"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type waitCallManager struct {
mu sync.RWMutex
sessions map[string]*WaitCallSession
phoneSessions map[string][]*WaitCallSession
lastAnswerPoll time.Time
}
var wcm = &waitCallManager{
sessions: make(map[string]*WaitCallSession),
phoneSessions: make(map[string][]*WaitCallSession),
}
func (m *waitCallManager) set(s *WaitCallSession) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[s.ID] = s
m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s)
}
func (m *waitCallManager) get(id string) *WaitCallSession {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok || time.Now().After(s.ExpiresAt) {
return nil
}
return s
}
func (m *waitCallManager) isVerified(id, phone string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok || time.Now().After(s.ExpiresAt) {
return false
}
norm := normalizeRuPhone(phone)
return s.Verified && (s.Phone == norm || s.Phone == phone)
}
// consume гасит подтверждение после успешной записи: одно подтверждение — одна запись.
func (m *waitCallManager) consume(id string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[id]; ok {
s.Verified = false
s.ExpiresAt = time.Now()
}
}
func (m *waitCallManager) markVerified(phone string) int {
m.mu.Lock()
defer m.mu.Unlock()
norm := normalizeRuPhone(phone)
list, ok := m.phoneSessions[norm]
if !ok {
return 0
}
count := 0
now := time.Now()
for _, s := range list {
if now.Before(s.ExpiresAt) {
s.Verified = true
count++
}
}
return count
}
func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) {
if login == "" || password == "" {
return
}
m.mu.Lock()
if time.Since(m.lastAnswerPoll) < 3*time.Second {
m.mu.Unlock()
return
}
m.lastAnswerPoll = time.Now()
m.mu.Unlock()
endpoint := fmt.Sprintf(
"https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3",
url.QueryEscape(login),
url.QueryEscape(password),
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var answers []struct {
ID any `json:"id"`
Phone string `json:"phone"`
Message string `json:"message"`
Text string `json:"text"`
}
if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil {
return
}
for _, a := range answers {
txt := a.Message
if txt == "" {
txt = a.Text
}
if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" {
norm := normalizeRuPhone(a.Phone)
if norm != "" {
m.markVerified(norm)
}
}
}
}
// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX"
func formatRuPhone(p string) string {
d := onlyDigits(p)
if len(d) == 11 && (d[0] == '7' || d[0] == '8') {
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11])
}
if len(d) == 10 {
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10])
}
return "+" + d
}
// normalizeRuPhone converts various formats to "7XXXXXXXXXX"
func normalizeRuPhone(p string) string {
d := onlyDigits(p)
if len(d) == 10 {
return "7" + d
}
if len(d) == 11 && (d[0] == '8' || d[0] == '7') {
return "7" + d[1:]
}
return d
}
func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) {
if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) {
return
}
if a.smscLogin == "" || a.smscPassword == "" {
fail(w, http.StatusServiceUnavailable, "verification_disabled", "Подтверждение номера сейчас недоступно")
return
}
var in struct {
Phone string `json:"phone"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос")
return
}
phone := normalizeRuPhone(in.Phone)
if len(phone) != 11 || phone[0] != '7' {
fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона")
return
}
if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) {
return
}
endpoint := fmt.Sprintf(
"https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3",
url.QueryEscape(a.smscLogin),
url.QueryEscape(a.smscPassword),
url.QueryEscape(phone),
)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
handleErr(w, err)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
// в адресе логин и пароль SMSC — в лог пишем ошибку без него
slog.Error("smsc wait_call request failed", "err", redactURL(err))
fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен")
return
}
defer resp.Body.Close()
var smscResp struct {
Phone string `json:"phone"`
AllPhones []string `json:"all_phones"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
}
if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil {
slog.Error("smsc wait_call decode failed", "err", err)
fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки")
return
}
if smscResp.ErrorCode != 0 {
slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error)
msg := "Не удалось получить номер для проверки"
if smscResp.ErrorCode == 9 {
msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту."
}
fail(w, http.StatusBadRequest, "smsc_error", msg)
return
}
targetPhone := smscResp.Phone
if targetPhone == "" && len(smscResp.AllPhones) > 0 {
targetPhone = smscResp.AllPhones[0]
}
if targetPhone == "" {
fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка")
return
}
b := make([]byte, 16)
_, _ = rand.Read(b)
sessionID := hex.EncodeToString(b)
sess := &WaitCallSession{
ID: sessionID,
Phone: phone,
TargetPhone: targetPhone,
DisplayPhone: formatRuPhone(targetPhone),
Verified: false,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
wcm.set(sess)
ok(w, map[string]any{
"session_id": sess.ID,
"target_phone": sess.TargetPhone,
"display_phone": sess.DisplayPhone,
"expires_in": 600,
}, nil)
}
func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id")
return
}
sess := wcm.get(sessionID)
if sess == nil {
fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла")
return
}
if !sess.Verified {
wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone)
}
ok(w, map[string]any{
"session_id": sess.ID,
"phone": sess.Phone,
"target_phone": sess.TargetPhone,
"display_phone": sess.DisplayPhone,
"verified": sess.Verified,
}, nil)
}
func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) {
// Обратный вызов приходит из интернета, поэтому без секрета в адресе его не принимаем:
// подтверждение номера всё равно придёт через опрос ответов SMSC (waitCallStatus).
if a.smscCallbackToken == "" || !sameKey(r.URL.Query().Get("token"), a.smscCallbackToken) {
slog.Warn("smsc: callback rejected (нет или неверный token)", "ip", clientIP(r))
fail(w, http.StatusForbidden, "forbidden", "Запрещено")
return
}
_ = r.ParseForm()
phone := r.FormValue("phone")
if phone == "" {
phone = r.URL.Query().Get("phone")
}
waitcall := r.FormValue("waitcall")
if waitcall == "" {
waitcall = r.URL.Query().Get("waitcall")
}
if phone == "" && r.Body != nil {
var j struct {
Phone string `json:"phone"`
Waitcall any `json:"waitcall"`
}
_ = json.NewDecoder(r.Body).Decode(&j)
if j.Phone != "" {
phone = j.Phone
}
if j.Waitcall != nil {
waitcall = fmt.Sprint(j.Waitcall)
}
}
slog.Info("smsc: callback received", "phone", maskPhone(phone), "waitcall", waitcall)
norm := normalizeRuPhone(phone)
if norm != "" {
verifiedCount := wcm.markVerified(norm)
slog.Info("smsc: sessions marked verified", "phone", maskPhone(norm), "count", verifiedCount)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}