Files
learning/internal/model/model.go
Ilya 62519081e7 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>
2026-05-25 22:43:37 +03:00

250 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"`
}