Микросервис обучения портала: тесты, курсы, видео-уроки, доступы, 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>
250 lines
9.5 KiB
Go
250 lines
9.5 KiB
Go
// 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"`
|
||
}
|