// Package portal — общий HTTP-клиент portal-сервиса для cross-service // нужд: directory с кэшем+stale fallback'ом, notification fan-out, // deactivate user. // // Раньше каждый сервис делал свой клон (booking/notifications, // candidates/directory+deactivate, meet/notifications, leaders-reports/ // notifications). Теперь — один источник. package portal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "sync" "time" ) // User — DTO из /api/internal/users (см. portal handler.InternalUserDTO). // Не все поля нужны каждому клиенту, но дублируем целиком, чтобы был // единый shape (caller игнорирует лишнее). type User struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` FirstName string `json:"first_name"` LastName string `json:"last_name"` MiddleName string `json:"middle_name"` FullName string `json:"full_name"` IsActive bool `json:"is_active"` DepartmentID *string `json:"department_id"` DepartmentName *string `json:"department_name"` PositionID *string `json:"position_id"` PositionName *string `json:"position_name"` WorkGeoCode *string `json:"work_geo_code"` } type Client struct { baseURL string apiKey string hc *http.Client // Кэш directory. 5-мин TTL, при ошибке возвращаем stale — temporary // portal outage не должен валить cross-service flow. mu sync.Mutex cache []User cachedAt time.Time cacheTTL time.Duration } // New строит клиент с дефолтами (timeout=10s, TTL=5m). Если baseURL или // apiKey пустые — клиент Disabled, все методы вернут ошибку. func New(baseURL, apiKey string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hc: &http.Client{Timeout: 10 * time.Second}, cacheTTL: 5 * time.Minute, } } // Enabled — true если конфигурация полная. Caller проверяет перед // вызовом, чтобы решить «пытаться или skip». func (c *Client) Enabled() bool { return c != nil && c.baseURL != "" && c.apiKey != "" } // SetCacheTTL — переопределить TTL directory-кэша (для тестов или // сервисов с особыми требованиями к свежести). func (c *Client) SetCacheTTL(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.cacheTTL = d } // ListDirectory возвращает справочник юзеров (с кэшем). При ошибке сети — // stale (если есть), иначе propagated error. Сетевой 5xx ретраится один // раз (200ms backoff). func (c *Client) ListDirectory(ctx context.Context) ([]User, error) { if !c.Enabled() { return nil, errors.New("portal client disabled") } c.mu.Lock() if time.Since(c.cachedAt) < c.cacheTTL && c.cache != nil { out := c.cache c.mu.Unlock() return out, nil } c.mu.Unlock() users, err := c.fetchDirectory(ctx) if err != nil { c.mu.Lock() stale := c.cache c.mu.Unlock() if stale != nil { return stale, fmt.Errorf("fetch directory (using stale cache): %w", err) } return nil, err } c.mu.Lock() c.cache = users c.cachedAt = time.Now() c.mu.Unlock() return users, nil } // CreateNotificationRequest — DTO для /api/internal/notifications. // Channels пока не enforce'ится сервером — задел на будущий fan-out. type CreateNotificationRequest struct { UserID string `json:"user_id"` Title string `json:"title"` Message string `json:"message"` Type string `json:"type,omitempty"` Link string `json:"link,omitempty"` Channels []string `json:"channels,omitempty"` } func (c *Client) CreateNotification(ctx context.Context, req CreateNotificationRequest) error { if !c.Enabled() { return errors.New("portal client disabled") } return c.postJSON(ctx, "/api/internal/notifications", req) } // DeactivateUser помечает юзера is_active=false + invalidate'ит токены. // 404 от portal'а — already-gone, для caller'а это успех. func (c *Client) DeactivateUser(ctx context.Context, userID string) error { if !c.Enabled() { return errors.New("portal client disabled") } if userID == "" { return errors.New("user id required") } url := c.baseURL + "/api/users/" + userID + "/deactivate" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return fmt.Errorf("new request: %w", err) } req.Header.Set("X-Internal-Key", c.apiKey) resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("do request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { return nil } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body)) } return nil } // postJSON — общий helper для POST /api/* с JSON-телом и X-Internal-Key. func (c *Client) postJSON(ctx context.Context, path string, payload any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal: %w", err) } url := c.baseURL + path httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("new request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("X-Internal-Key", c.apiKey) resp, err := c.hc.Do(httpReq) if err != nil { return fmt.Errorf("post: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("portal %s returned %d: %s", path, resp.StatusCode, string(respBody)) } return nil } // fetchDirectory — GET /api/internal/users с retry'ем на 5xx и парсингом // обоих форматов ответа (массив или {users: []}). func (c *Client) fetchDirectory(ctx context.Context) ([]User, error) { url := c.baseURL + "/api/internal/users" var resp *http.Response var lastErr error for attempt := 0; attempt < 2; attempt++ { if attempt > 0 { time.Sleep(200 * time.Millisecond) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("new request: %w", err) } req.Header.Set("X-Internal-Key", c.apiKey) resp, lastErr = c.hc.Do(req) if lastErr != nil { continue } if resp.StatusCode >= 500 && resp.StatusCode < 600 { _ = resp.Body.Close() lastErr = fmt.Errorf("portal 5xx: %d", resp.StatusCode) continue } break } if lastErr != nil && resp == nil { return nil, fmt.Errorf("get directory: %w", lastErr) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body)) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("read body: %w", err) } var asArr []User if err := json.Unmarshal(raw, &asArr); err == nil { return asArr, nil } var asObj struct { Users []User `json:"users"` } if err := json.Unmarshal(raw, &asObj); err != nil { return nil, fmt.Errorf("parse directory: %w", err) } return asObj.Users, nil }