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:
249
internal/model/model.go
Normal file
249
internal/model/model.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user