init: portal-common (db + middleware + portal client)
This commit is contained in:
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# portal-common
|
||||||
|
|
||||||
|
Shared Go-библиотека для микросервисов портала.
|
||||||
|
|
||||||
|
## Состав
|
||||||
|
|
||||||
|
- `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах.
|
||||||
|
- `middleware/` — `InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков.
|
||||||
|
- `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
В каждом сервисе:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"gitea.estateliga.work/admin/portal-common/db"
|
||||||
|
"gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
"gitea.estateliga.work/admin/portal-common/portal"
|
||||||
|
)
|
||||||
|
|
||||||
|
pool, _ := db.ConnectURL(cfg.DatabaseURL)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.InternalAuth(cfg.InternalAPIKey))
|
||||||
|
|
||||||
|
portalCli := portal.New(cfg.PortalBaseURL, cfg.PortalAPIKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev-режим (без push в Gitea)
|
||||||
|
|
||||||
|
В `go.mod` сервиса добавить `replace`:
|
||||||
|
|
||||||
|
```
|
||||||
|
replace gitea.estateliga.work/admin/portal-common => ../portal-common
|
||||||
|
```
|
||||||
|
|
||||||
|
Когда библиотека стабилизируется, заменить на pinned тег:
|
||||||
|
|
||||||
|
```
|
||||||
|
require gitea.estateliga.work/admin/portal-common v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
До этого 9 сервисов копировали один в один: pgxpool init, slow-query tracer (500ms threshold), InternalAuth middleware. Tweak'ать tuning централизованно было невозможно. Сейчас изменения идут в одном репо, сервисы пересобираются.
|
||||||
140
db/pool.go
Normal file
140
db/pool.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package db — общий pgxpool init для микросервисов: единая конфигурация
|
||||||
|
// connection-pool'а, slow-query tracer, retry на startup.
|
||||||
|
//
|
||||||
|
// История: каждый сервис (booking, candidates, tasks, meet, leaders-reports,
|
||||||
|
// telephony, hhru, webhooks-apps, deals) копировал этот код один в один.
|
||||||
|
// Разъехавшиеся mode сложно tune'ить централизованно — теперь tweak'аем
|
||||||
|
// здесь, а сервисы пересобираются.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolConfig — параметры пула. Дефолты идентичны тому, что было в каждом
|
||||||
|
// сервисе (MaxConns=10, MinConns=2). Slow-query threshold — 500ms.
|
||||||
|
//
|
||||||
|
// Если сервис хочет другие лимиты (например, candidates с большим
|
||||||
|
// объёмом read'ов), можно передать своё значение; nil/zero — берём
|
||||||
|
// default.
|
||||||
|
type PoolConfig struct {
|
||||||
|
DatabaseURL string
|
||||||
|
MaxConns int32
|
||||||
|
MinConns int32
|
||||||
|
MaxConnIdleTime time.Duration
|
||||||
|
MaxConnLifetime time.Duration
|
||||||
|
SlowQueryThreshold time.Duration
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults возвращает PoolConfig с дефолтными значениями, выставлены
|
||||||
|
// только числовые лимиты — DatabaseURL caller подставляет сам.
|
||||||
|
func Defaults() PoolConfig {
|
||||||
|
return PoolConfig{
|
||||||
|
MaxConns: 10,
|
||||||
|
MinConns: 2,
|
||||||
|
MaxConnIdleTime: 5 * time.Minute,
|
||||||
|
MaxConnLifetime: 30 * time.Minute,
|
||||||
|
SlowQueryThreshold: 500 * time.Millisecond,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect открывает pgxpool с заданной конфигурацией и tracer'ом slow-queries.
|
||||||
|
// Возвращает live-pool после успешного Ping; на ошибке pool закрывается.
|
||||||
|
func Connect(cfg PoolConfig) (*pgxpool.Pool, error) {
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("DatabaseURL is required")
|
||||||
|
}
|
||||||
|
def := Defaults()
|
||||||
|
if cfg.MaxConns == 0 {
|
||||||
|
cfg.MaxConns = def.MaxConns
|
||||||
|
}
|
||||||
|
if cfg.MinConns == 0 {
|
||||||
|
cfg.MinConns = def.MinConns
|
||||||
|
}
|
||||||
|
if cfg.MaxConnIdleTime == 0 {
|
||||||
|
cfg.MaxConnIdleTime = def.MaxConnIdleTime
|
||||||
|
}
|
||||||
|
if cfg.MaxConnLifetime == 0 {
|
||||||
|
cfg.MaxConnLifetime = def.MaxConnLifetime
|
||||||
|
}
|
||||||
|
if cfg.SlowQueryThreshold == 0 {
|
||||||
|
cfg.SlowQueryThreshold = def.SlowQueryThreshold
|
||||||
|
}
|
||||||
|
if cfg.ConnectTimeout == 0 {
|
||||||
|
cfg.ConnectTimeout = def.ConnectTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse db config: %w", err)
|
||||||
|
}
|
||||||
|
poolCfg.MaxConns = cfg.MaxConns
|
||||||
|
poolCfg.MinConns = cfg.MinConns
|
||||||
|
poolCfg.MaxConnIdleTime = cfg.MaxConnIdleTime
|
||||||
|
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
|
||||||
|
poolCfg.ConnConfig.Tracer = &slowQueryTracer{threshold: cfg.SlowQueryThreshold}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.ConnectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectURL — короткая форма Connect(PoolConfig{DatabaseURL: url}).
|
||||||
|
// Удобно для сервисов, которым не нужны кастомные лимиты.
|
||||||
|
func ConnectURL(url string) (*pgxpool.Pool, error) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.DatabaseURL = url
|
||||||
|
return Connect(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowQueryTracer пишет WARN в slog'е для запросов, длиннее threshold.
|
||||||
|
// Логируется duration_ms, обрезанный SQL и error (если был). Не префиксируем
|
||||||
|
// сервисом — это делает caller через slog.With() на старте процесса.
|
||||||
|
type slowQueryTracer struct{ threshold time.Duration }
|
||||||
|
|
||||||
|
type traceCtxKey struct{}
|
||||||
|
type traceState struct {
|
||||||
|
start time.Time
|
||||||
|
sql string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *slowQueryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context {
|
||||||
|
return context.WithValue(ctx, traceCtxKey{}, traceState{start: time.Now(), sql: data.SQL})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *slowQueryTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) {
|
||||||
|
st, ok := ctx.Value(traceCtxKey{}).(traceState)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dur := time.Since(st.start)
|
||||||
|
if dur < t.threshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sql := st.sql
|
||||||
|
if len(sql) > 500 {
|
||||||
|
sql = sql[:500] + "…"
|
||||||
|
}
|
||||||
|
attrs := []any{"duration_ms", dur.Milliseconds(), "sql", sql}
|
||||||
|
if data.Err != nil {
|
||||||
|
attrs = append(attrs, "error", data.Err)
|
||||||
|
}
|
||||||
|
slog.Warn("slow query", attrs...)
|
||||||
|
}
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Shared библиотека для микросервисов портала.
|
||||||
|
// Содержит: db (pgxpool init + slow-query tracer), middleware (InternalAuth),
|
||||||
|
// portal (HTTP-клиент portal-сервиса), audit (общий event log).
|
||||||
|
//
|
||||||
|
// Используется из tasks/, candidates/, booking/, meet/, leaders-reports/,
|
||||||
|
// telephony/, hhru/, webhooks-apps/, deals/. Каждый сервис ссылается на
|
||||||
|
// `gitea.estateliga.work/admin/portal-common` через go.mod replace
|
||||||
|
// в dev-сборке (см. README.md в репозитории).
|
||||||
|
module gitea.estateliga.work/admin/portal-common
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.9.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
26
go.sum
Normal file
26
go.sum
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
136
middleware/internal_auth.go
Normal file
136
middleware/internal_auth.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Package middleware — общая InternalAuth для cross-service запросов
|
||||||
|
// через portal-proxy. Каждый сервис до этого имел свой клон файла; теперь
|
||||||
|
// один источник истины.
|
||||||
|
//
|
||||||
|
// Контракт:
|
||||||
|
// - portal-proxy пробрасывает X-Internal-Key (общий с сервисом секрет),
|
||||||
|
// X-User-Id (UUID логиниувшегося юзера), X-User-Name (для логов/UI),
|
||||||
|
// X-User-Is-Admin ('1' если у юзера системная админ-роль), плюс
|
||||||
|
// любые X-User-* кастомные заголовки (роли/scope/subordinates).
|
||||||
|
// - middleware кладёт всё это в context'е, чтобы handler'ы и нижестоящие
|
||||||
|
// middleware'ы читали без повторного парса заголовков.
|
||||||
|
//
|
||||||
|
// Если сервису нужны кастомные роли — ставим дополнительные middleware
|
||||||
|
// поверх (см. WithCustomHeaders в этом же пакете).
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextKey — typedef'нутый string чтобы не сталкиваться с другими пакетами,
|
||||||
|
// которые могут писать в r.Context() свои ключи.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserIDKey contextKey = "user_id"
|
||||||
|
UserNameKey contextKey = "user_name"
|
||||||
|
IsAdminKey contextKey = "is_admin"
|
||||||
|
ClientIPKey contextKey = "client_ip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InternalAuth возвращает middleware, который:
|
||||||
|
// - сверяет X-Internal-Key с expectedKey (constant-time compare против
|
||||||
|
// timing-атак),
|
||||||
|
// - требует X-User-Id (без него запрос неавторизован),
|
||||||
|
// - кладёт user_id, user_name, is_admin, client_ip в r.Context().
|
||||||
|
//
|
||||||
|
// Если expectedKey пустой — сервис намеренно отключил internal API; все
|
||||||
|
// запросы летят с 503. Это удобный «kill switch» для dev без выкатки.
|
||||||
|
func InternalAuth(expectedKey string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if expectedKey == "" {
|
||||||
|
http.Error(w, `{"error":"internal api disabled"}`, http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotKey := r.Header.Get("X-Internal-Key")
|
||||||
|
if len(gotKey) != len(expectedKey) ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(gotKey), []byte(expectedKey)) != 1 {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.Header.Get("X-User-Id")
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, `{"error":"missing X-User-Id header"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
|
ctx = context.WithValue(ctx, UserNameKey, r.Header.Get("X-User-Name"))
|
||||||
|
ctx = context.WithValue(ctx, IsAdminKey, r.Header.Get("X-User-Is-Admin") == "1")
|
||||||
|
ctx = context.WithValue(ctx, ClientIPKey, extractClientIP(r))
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCustomHeader — фабрика для сервисов, которым нужны дополнительные
|
||||||
|
// header→context маппинги. Пример: candidates читает X-Candidates-Scope.
|
||||||
|
//
|
||||||
|
// Использование:
|
||||||
|
// r.Use(middleware.InternalAuth(key))
|
||||||
|
// r.Use(middleware.WithCustomHeader("X-Candidates-Scope", scopeKey))
|
||||||
|
//
|
||||||
|
// Хендлер потом достаёт через context.Value(scopeKey).
|
||||||
|
func WithCustomHeader(headerName string, ctxKey any) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := r.Header.Get(headerName)
|
||||||
|
ctx := r.Context()
|
||||||
|
if v != "" {
|
||||||
|
ctx = context.WithValue(ctx, ctxKey, v)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserID(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserName(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserNameKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdmin(ctx context.Context) bool {
|
||||||
|
if v, ok := ctx.Value(IsAdminKey).(bool); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClientIP(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(ClientIPKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClientIP читает X-Forwarded-For (берём левый — это исходный
|
||||||
|
// клиент по RFC 7239), fallback на RemoteAddr без порта.
|
||||||
|
func extractClientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if comma := strings.IndexByte(xff, ','); comma >= 0 {
|
||||||
|
xff = xff[:comma]
|
||||||
|
}
|
||||||
|
if ip := strings.TrimSpace(xff); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
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