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

249
internal/model/model.go Normal file
View File

@@ -0,0 +1,249 @@
// Package model — DTO/доменные структуры learning-сервиса. Поля -> JSON
// маппятся 1-в-1 на сообщения frontend'а; cross-service id'шники (owner_user_id,
// candidate_id) — opaque UUID/int64, портал валидирует существование сам.
package model
import (
"time"
"github.com/google/uuid"
)
// ============================================================
// TESTS
// ============================================================
type Test struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
PassingScore *int `json:"passing_score"`
MaxAttempts int `json:"max_attempts"`
TimeLimitSec int `json:"time_limit_sec"`
ShuffleQuestions bool `json:"shuffle_questions"`
ShowCorrectAfter bool `json:"show_correct_after"`
IsPublished bool `json:"is_published"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// QuestionsCount — populate'ится через correlated subquery в List
// (не отдельной таблицей), даёт UI быстрый бейдж «N вопросов».
QuestionsCount int `json:"questions_count,omitempty"`
}
type CreateTestRequest struct {
Title string `json:"title"`
Description string `json:"description"`
PassingScore *int `json:"passing_score"`
MaxAttempts int `json:"max_attempts"`
TimeLimitSec int `json:"time_limit_sec"`
ShuffleQuestions bool `json:"shuffle_questions"`
ShowCorrectAfter bool `json:"show_correct_after"`
}
// UpdateTestRequest — все поля nullable, обновляем только заполненные
// (паттерн COALESCE($X, current) или conditional UPDATE).
type UpdateTestRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
PassingScore *int `json:"passing_score"`
MaxAttempts *int `json:"max_attempts"`
TimeLimitSec *int `json:"time_limit_sec"`
ShuffleQuestions *bool `json:"shuffle_questions"`
ShowCorrectAfter *bool `json:"show_correct_after"`
IsPublished *bool `json:"is_published"`
}
type Question struct {
ID uuid.UUID `json:"id"`
TestID uuid.UUID `json:"test_id"`
Position int `json:"position"`
Kind string `json:"kind"` // single | multi | text
Text string `json:"text"`
Points int `json:"points"`
Explanation string `json:"explanation"`
CreatedAt time.Time `json:"created_at"`
Answers []Answer `json:"answers,omitempty"`
}
type Answer struct {
ID uuid.UUID `json:"id"`
QuestionID uuid.UUID `json:"question_id"`
Position int `json:"position"`
Text string `json:"text"`
// IsCorrect — фильтруется handler'ом при выдаче респонденту: показываем
// только после сабмита (если show_correct_after) или владельцу теста.
IsCorrect bool `json:"is_correct,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CreateQuestionRequest struct {
Position int `json:"position"`
Kind string `json:"kind"`
Text string `json:"text"`
Points int `json:"points"`
Explanation string `json:"explanation"`
Answers []CreateAnswerRequest `json:"answers"`
}
type CreateAnswerRequest struct {
Position int `json:"position"`
Text string `json:"text"`
IsCorrect bool `json:"is_correct"`
}
// ============================================================
// ATTEMPTS
// ============================================================
type Attempt struct {
ID uuid.UUID `json:"id"`
TestID uuid.UUID `json:"test_id"`
UserID *uuid.UUID `json:"user_id"`
PublicTokenID *uuid.UUID `json:"public_token_id"`
CandidateID *int64 `json:"candidate_id"`
Status string `json:"status"` // in_progress | submitted | graded | expired
Score *int `json:"score"`
MaxScore *int `json:"max_score"`
Passed *bool `json:"passed"`
RespondentName string `json:"respondent_name"`
RespondentEmail string `json:"respondent_email"`
StartedAt time.Time `json:"started_at"`
SubmittedAt *time.Time `json:"submitted_at"`
GradedAt *time.Time `json:"graded_at"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// SubmitAttemptRequest — отправка ответов. AttemptAnswers идут одной
// пачкой: для каждого вопроса либо single/multi (по id'шникам), либо text.
type SubmitAttemptRequest struct {
Answers []SubmitAnswer `json:"answers"`
}
type SubmitAnswer struct {
QuestionID uuid.UUID `json:"question_id"`
AnswerID *uuid.UUID `json:"answer_id,omitempty"` // single
AnswerIDs []uuid.UUID `json:"answer_ids,omitempty"` // multi
Text string `json:"text,omitempty"` // text
}
// ============================================================
// COURSES
// ============================================================
type Course struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Slug string `json:"slug"`
CoverImageKey string `json:"cover_image_key"`
IsPublished bool `json:"is_published"`
OwnerUserID uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LessonsCount int `json:"lessons_count,omitempty"`
}
type CreateCourseRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Slug string `json:"slug"`
}
type UpdateCourseRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Slug *string `json:"slug"`
CoverImageKey *string `json:"cover_image_key"`
IsPublished *bool `json:"is_published"`
}
type Lesson struct {
ID uuid.UUID `json:"id"`
CourseID uuid.UUID `json:"course_id"`
Position int `json:"position"`
Title string `json:"title"`
Markdown string `json:"markdown"`
VideoKey string `json:"video_key"`
VideoDurationSec int `json:"video_duration_sec"`
TestID *uuid.UUID `json:"test_id"`
IsRequired bool `json:"is_required"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateLessonRequest struct {
Position int `json:"position"`
Title string `json:"title"`
Markdown string `json:"markdown"`
TestID *uuid.UUID `json:"test_id"`
IsRequired *bool `json:"is_required"`
}
type UpdateLessonRequest struct {
Position *int `json:"position"`
Title *string `json:"title"`
Markdown *string `json:"markdown"`
VideoKey *string `json:"video_key"`
VideoDurationSec *int `json:"video_duration_sec"`
TestID *uuid.UUID `json:"test_id"`
IsRequired *bool `json:"is_required"`
}
// ============================================================
// ACCESS GRANTS
// ============================================================
type AccessGrant struct {
ID uuid.UUID `json:"id"`
ResourceType string `json:"resource_type"` // test | course
ResourceID uuid.UUID `json:"resource_id"`
SubjectType string `json:"subject_type"` // user | role | department | position | public
SubjectID *uuid.UUID `json:"subject_id"`
CanManage bool `json:"can_manage"`
GrantedBy uuid.UUID `json:"granted_by"`
CreatedAt time.Time `json:"created_at"`
}
type CreateAccessGrantRequest struct {
SubjectType string `json:"subject_type"`
SubjectID *uuid.UUID `json:"subject_id"`
CanManage bool `json:"can_manage"`
}
// ============================================================
// PUBLIC TOKENS
// ============================================================
type PublicToken struct {
ID uuid.UUID `json:"id"`
Token string `json:"token"`
ResourceType string `json:"resource_type"`
ResourceID uuid.UUID `json:"resource_id"`
IntendedEmail string `json:"intended_email"`
CandidateID *int64 `json:"candidate_id"`
MaxAttempts int `json:"max_attempts"`
UsedAttempts int `json:"used_attempts"`
ExpiresAt *time.Time `json:"expires_at"`
OpenedAt *time.Time `json:"opened_at"`
UsedAt *time.Time `json:"used_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type CreatePublicTokenRequest struct {
ResourceType string `json:"resource_type"`
ResourceID uuid.UUID `json:"resource_id"`
IntendedEmail string `json:"intended_email"`
CandidateID *int64 `json:"candidate_id"`
MaxAttempts int `json:"max_attempts"`
ExpiresAt *time.Time `json:"expires_at"`
}
// PublicTokenResolveRequest — что приходит при «открытии» public-ссылки:
// email из формы, его сверяем с intended_email. UA/IP берём из request'а.
type PublicTokenResolveRequest struct {
Email string `json:"email"`
}