init: portal-common (db + middleware + portal client)
This commit is contained in:
235
portal/client.go
Normal file
235
portal/client.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user