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

328 lines
8.4 KiB
Plaintext
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)
}
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
}
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) {
fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.")
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 {
slog.Error("smsc wait_call request failed", "err", 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) {
_ = 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")
}
mes := r.FormValue("mes")
if phone == "" && r.Body != nil {
var j struct {
Phone string `json:"phone"`
Waitcall any `json:"waitcall"`
Mes string `json:"mes"`
}
_ = json.NewDecoder(r.Body).Decode(&j)
if j.Phone != "" {
phone = j.Phone
}
if j.Waitcall != nil {
waitcall = fmt.Sprint(j.Waitcall)
}
if j.Mes != "" {
mes = j.Mes
}
}
slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes)
norm := normalizeRuPhone(phone)
if norm != "" {
verifiedCount := wcm.markVerified(norm)
slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}