init: portal-common (db + middleware + portal client)

This commit is contained in:
Grendgi
2026-05-20 14:00:06 +03:00
commit db1660a390
6 changed files with 604 additions and 0 deletions

235
portal/client.go Normal file
View 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
}