init: learning-service skeleton

Микросервис обучения портала: тесты, курсы, видео-уроки, доступы,
public-ссылки для кандидатов с email-валидацией.

В этой итерации:
- Skeleton (config, migrate, main, health) по паттерну tasks/candidates
- Migration 001_init: 10 таблиц (tests/questions/answers/attempts/
  attempt_answers + courses/lessons/lesson_progress + access_grants +
  public_tokens) с подробными комментариями why
- Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и
  explanation скрываются в выдаче
- Заглушки 501 для attempts / courses / lessons / video-stream /
  access / public-tokens — следующие итерации
- k8s: namespace, configmap, secrets, postgres, deployment с HPA,
  service с portal-discovery annotations
- Dockerfile, Makefile, .gitignore

См. README.md для полного списка отложенного и инструкций запуска.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 22:43:37 +03:00
commit 62519081e7
24 changed files with 1915 additions and 0 deletions

97
internal/config/config.go Normal file
View File

@@ -0,0 +1,97 @@
// Package config — env-based конфиг learning-сервиса. Стиль envStr идентичен
// candidates/tasks/booking — чтобы k8s-манифесты выглядели единообразно.
package config
import (
"os"
"strconv"
)
type Config struct {
ServerPort string
DatabaseURL string
MigrationsDir string
// InternalAPIKey — общий ключ с порталом для server-to-server auth.
// Portal-proxy ставит X-Internal-Key + X-User-Id; сервис верифицирует
// ключ и доверяет X-User-Id (JWT уже провалидирован на портале).
InternalAPIKey string
// PortalURL — для in-app уведомлений (назначен курс/тест/пройден тест).
// Если пусто — notify-вызовы тихо no-op'ятся, основной flow работает.
PortalURL string
// PublicBaseURL — внешний origin, под которым отдаются public-ссылки
// для кандидатов (например, https://portal.estateliga.work). Используется
// в формате URL'ей: <PUBLIC_BASE>/public/learning/test/<token>.
PublicBaseURL string
// MinIO для видео-уроков. Структура та же что в telephony (records bucket),
// но bucket отдельный — public-link'и читают видео через стрим-прокси.
MinIOEndpoint string
MinIOAccessKey string
MinIOSecretKey string
MinIOBucket string
MinIOUseSSL bool
// RedisAddr — пустой = eventbus отключён, сервис работает без публикации
// событий (попадание кандидата на тест не уведомит HR в realtime).
RedisAddr string
RedisPassword string
RedisDB int
PodName string
}
func Load() *Config {
return &Config{
ServerPort: envStr("SERVER_PORT", "3001"),
DatabaseURL: envStr("DATABASE_URL", "postgres://learning:learning@localhost:5432/learning?sslmode=disable"),
MigrationsDir: envStr("MIGRATIONS_DIR", "/migrations"),
InternalAPIKey: envStr("INTERNAL_API_KEY", envStr("PORTAL_INTERNAL_API_KEY", "")),
PortalURL: envStr("PORTAL_URL", ""),
PublicBaseURL: envStr("PUBLIC_BASE_URL", ""),
MinIOEndpoint: envStr("MINIO_ENDPOINT", ""),
MinIOAccessKey: envStr("MINIO_ACCESS_KEY", ""),
MinIOSecretKey: envStr("MINIO_SECRET_KEY", ""),
MinIOBucket: envStr("MINIO_BUCKET", "learning-videos"),
MinIOUseSSL: envBool("MINIO_USE_SSL", false),
RedisAddr: envStr("REDIS_ADDR", ""),
RedisPassword: envStr("REDIS_PASSWORD", ""),
RedisDB: envInt("REDIS_DB", 0),
PodName: envStr("POD_NAME", hostname()),
}
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func hostname() string {
h, err := os.Hostname()
if err != nil {
return "unknown"
}
return h
}