Files
LMS/docs/03-domain-model.md
Vladislav Kopnin a1392a469e Content import: sync sections and articles (archive is the source of truth)
- importZip now updates titles/positions of existing chapters and sections and
  removes sections/articles of archived chapters that are missing in the archive
  (so restructured material no longer leaves stale sections behind); removed
  count is reported in ContentImportResult and in the admin flash message
- docs: note about import semantics in the class diagram and sequences
2026-09-16 22:00:22 +03:00

18 KiB

Модель данных и классов

Ниже — диаграммы классов по сервисам (JPA-сущности + репозитории + сервисы), ER-модель БД и модель web-слоя.

1. identity-service

classDiagram
    class Account {
        +UUID id
        +String login
        +String passwordHash
        +LmsRole role
        +Boolean mustChangePassword
        +String tempPassword
        +Instant createdAt
        +Instant updatedAt
    }
    class Student {
        +UUID accountId
        +String studentNo
        +String lastName
        +String firstName
        +String middleName
        +String basis
        +String note
        +String fullName
    }
    class GroupEntity {
        +UUID id
        +String name
        +Int taskDurationMin
        +Instant createdAt
    }
    class Enrollment {
        +UUID id
        +Instant enrolledAt
    }
    class Setting {
        +String key
        +String value
    }
    class LmsRole {
        <<enumeration>>
        ADMIN
        STUDENT
    }

    Account "1" --> "1" Student : @MapsId account_id
    GroupEntity "1" --> "*" Enrollment : group_id
    Student "1" --> "*" Enrollment : student_id
    Account --> LmsRole : role
classDiagram
    class AuthController
    class GroupController
    class StudentController
    class AuthService {
        +adminExists() Boolean
        +setup(SetupRequest) AuthResponse
        +login(LoginRequest) AuthResponse
        +me(UUID) MeResponse
        +changePassword(UUID, ChangePasswordRequest)
    }
    class GroupService {
        +list() List~GroupSummary~
        +create(CreateGroupRequest) GroupSummary
        +createStudent(UUID, CreateStudentRequest) Credential
        +candidates(UUID) List~StudentCandidate~
        +enroll(UUID, UUID) GroupDetail
        +unenroll(UUID, UUID) GroupDetail
        +import(String, String, ByteArray) ImportResult
        +groupOfStudent(UUID) GroupSummary
        +detail(UUID) GroupDetail
        +resetPassword(UUID) ResetPasswordResponse
        +credentials(UUID, String) ExportFile
    }
    class AccountRepository
    class GroupRepository
    class EnrollmentRepository
    class StudentRepository
    class SettingRepository
    class StudentImportParser {
        +parse(String, ByteArray) ParsedGroup
    }
    class CredentialsExporter {
        +csv(List~StudentRow~) ByteArray
        +xlsx(List~StudentRow~) ByteArray
    }
    class Passwords {
        +generate(Int) String
    }

    AuthController --> AuthService
    GroupController --> GroupService
    StudentController --> GroupService
    AuthService --> AccountRepository
    AuthService --> StudentRepository
    GroupService --> GroupRepository
    GroupService --> EnrollmentRepository
    GroupService --> StudentRepository
    GroupService --> AccountRepository
    GroupService --> StudentImportParser
    GroupService --> CredentialsExporter
    GroupService --> Passwords

2. academic-service

classDiagram
    class Semester {
        +UUID id
        +String title
        +Int year
        +Boolean active
    }
    class Lab {
        +UUID id
        +Int number
        +String title
        +String descriptionMd
        +Instant deadlineAt
        +Instant defenseAt
        +BigDecimal pointsMax
        +String status
        +Instant createdAt
    }
    class LabLevel {
        +UUID id
        +String code
        +String title
        +String description
        +Int position
        +BigDecimal pointsMax
    }
    class Variant {
        +UUID id
        +String number
        +String title
        +String statementMd
        +Int position
    }
    class ContentChapter {
        +UUID id
        +String title
        +String slug
        +Int position
        +Boolean published
    }
    class ContentSection {
        +UUID id
        +UUID chapterId
        +String title
        +String slug
        +Int position
        +Boolean published
    }
    class ContentArticle {
        +UUID id
        +UUID sectionId
        +String title
        +String slug
        +String bodyMd
        +Int position
        +Boolean published
    }

    Semester "1" --> "*" Lab : semester_id
    Lab "1" --> "*" LabLevel : lab_id
    LabLevel "1" --> "*" Variant : lab_level_id
    Lab "1" --> "*" Variant : lab_id
    ContentChapter "1" --> "*" ContentSection : chapter_id
    ContentSection "1" --> "*" ContentArticle : section_id
classDiagram
    class LabController
    class ContentPublicController
    class ContentAdminController
    class LabService {
        +importFromFile(String, ByteArray) LabImportResult
        +list() List~LabSummary~
        +detail(UUID) LabDetail
        +create(LabCreateRequest) LabDetail
        +update(UUID, LabUpdateRequest) LabDetail
        +delete(UUID)
        +toggleStatus(UUID)
        +addLevel(UUID, LevelSaveRequest)
        +renameLevel(UUID, UUID, LevelRenameRequest)
        +deleteLevel(UUID, UUID)
        +addVariant(UUID, VariantSaveRequest)
        +updateVariant(UUID, UUID, VariantSaveRequest)
        +deleteVariant(UUID, UUID)
    }
    class ContentService {
        +tree(Boolean) ContentTree
        +treeAll() ContentTree
        +findArticle(String, String, String, Boolean) ContentArticleView
        +setChapterPublished(UUID, Boolean)
        +setSectionPublished(UUID, Boolean)
        +setArticlePublished(UUID, Boolean)
        +listAll() List~ContentArticleView~
        +createChapter(String, String) ChapterTreeNode
        +createSection(UUID, String, String) SectionTreeNode
        +createArticlePlaced(ArticleCreatePayload) ContentArticleView
        +updateArticle(UUID, String, String)
        +moveArticle(UUID, UUID) ContentArticleView
        +deleteArticle(UUID)
        +importZip(String, ByteArray) ContentImportResult
        +reorder(ReorderTree)
    }
    class LabImportParser {
        +parse(String, ByteArray) LabImportBundle
    }
    class AssetStorage {
        +store(String, ByteArray) StoredAsset
        +read(String) Pair
    }
    class LabRepository
    class LabLevelRepository
    class VariantRepository
    class SemesterRepository
    class ContentChapterRepository
    class ContentSectionRepository
    class ContentArticleRepository

    note for ContentService "Импорт ZIP — источник истины для глав из архива: устаревшие разделы и статьи удаляются (removed)"

    LabController --> LabService
    ContentPublicController --> ContentService
    ContentAdminController --> ContentService
    LabService --> LabRepository
    LabService --> LabLevelRepository
    LabService --> VariantRepository
    LabService --> SemesterRepository
    LabService --> LabImportParser
    ContentService --> ContentChapterRepository
    ContentService --> ContentSectionRepository
    ContentService --> ContentArticleRepository
    ContentService --> AssetStorage

3. progress-service

classDiagram
    class VariantAssignment {
        +UUID id
        +UUID studentId
        +UUID labId
        +UUID variantId
        +Boolean manual
        +Instant assignedAt
    }
    class StudentLabLevel {
        +UUID studentId
        +UUID labId
        +UUID labLevelId
        +Boolean manual
    }
    class LabGrade {
        +UUID studentId
        +UUID labId
        +BigDecimal score
        +Instant updatedAt
    }
    class GradePublication {
        +UUID groupId
        +UUID labId
        +Instant publishedAt
    }
    class GradeComment {
        +UUID id
        +UUID studentId
        +UUID labId
        +String text
        +String tone
        +Instant createdAt
    }
    class CommentTemplate {
        +UUID id
        +String text
        +String tone
        +Int useCount
    }
    class Submission {
        +UUID id
        +UUID studentId
        +UUID labId
        +Int seq
        +Instant createdAt
    }
    class DefenseSession {
        +UUID id
        +UUID studentId
        +UUID labId
        +String assignmentMd
        +String status
        +String code
        +Instant startedAt
        +Instant readyAt
        +String readyReason
    }
    class DefenseStatus {
        <<enumeration>>
        waiting
        started
        ready
    }

    CommentTemplate "1" --> "*" GradeComment : подсказки (text/tone)
    GradeComment "*" --> "1" LabGrade : поясняет оценку
    LabGrade "1" --> "0..1" GradePublication : видна студенту после публикации
    DefenseSession --> DefenseStatus : status
    Note for VariantAssignment : studentId / labId / variantId — идентификаторы других сервисов (FK на уровне БД нет)
    Note for LabGrade : groupId для публикации берётся из identity
classDiagram
    class ProgressController
    class DefenseAdminController
    class DefenseMyController
    class ProgressService {
        +setLevel(LevelAssignmentRequest, String) LevelAssignmentResponse
        +getLevel(UUID, UUID) StudentLabLevel
        +getLevelsForLab(UUID) List~StudentLabLevel~
        +distribute(DistributeRequest, String) DistributeResult
        +assignWithInfo(AssignRequest, String) VariantAssignmentResponse
        +assignmentsForLabEnriched(UUID, String) List~VariantAssignmentResponse~
        +setGrade(GradeSetRequest) GradeResponse
        +suggestComments(String) List~CommentOut~
        +myComments(UUID) List~GradeComment~
        +getStudentAssignment(UUID, UUID, String) MeAssignmentResponse
    }
    class SubmissionService {
        +submit(UUID, UUID, List~MultipartFile~, String) SubmissionDto
        +mySubmissions(UUID, UUID) List~SubmissionDto~
        +download(UUID, UUID, UUID, Boolean) Pair
        +latestPerStudent(UUID) List~SubmissionDto~
    }
    class DefenseService {
        +syncTimeouts() Int
        +assign(UUID, UUID, String) DefenseDto
        +issueCode(UUID) String
        +reset(UUID)
        +listForLab(UUID) List~DefenseDto~
        +startByCode(UUID, String) DefenseDto
        +finish(UUID, UUID) DefenseDto
        +mySessions(UUID) List~DefenseDto~
        +resetByStudentLab(UUID, UUID)
    }
    class GradePublicationService {
        +setPublished(UUID, UUID, Boolean)
        +isPublished(UUID, UUID) Boolean
        +publishedLabIdsForGroup(UUID) List~String~
        +publishedGroupIdsForLab(UUID) List~String~
    }
    class AcademicClient {
        +getLab(String, String) LabInfo
        +getLabSummary(String, String) LabInfo
    }
    class IdentityClient {
        +getGroup(String, String) GroupInfo
        +listGroups(String) List~Map~
    }
    class SubmissionStorage {
        +store(UUID, UUID, Int, List~MultipartFile~) StoredSubmission
        +zipOf(Path) ByteArray
        +listFiles(Path) List~String~
        +dirFor(UUID, UUID, Int) Path
    }
    class VariantAssignmentRepository
    class StudentLabLevelRepository
    class LabGradeRepository
    class GradeCommentRepository
    class CommentTemplateRepository
    class SubmissionRepository
    class DefenseSessionRepository
    class GradePublicationRepository

    ProgressController --> ProgressService
    DefenseAdminController --> DefenseService
    DefenseMyController --> DefenseService
    ProgressController --> SubmissionService
    ProgressController --> GradePublicationService
    ProgressService --> VariantAssignmentRepository
    ProgressService --> StudentLabLevelRepository
    ProgressService --> LabGradeRepository
    ProgressService --> GradeCommentRepository
    ProgressService --> CommentTemplateRepository
    ProgressService --> AcademicClient
    ProgressService --> IdentityClient
    SubmissionService --> SubmissionRepository
    SubmissionService --> SubmissionStorage
    SubmissionService --> AcademicClient
    DefenseService --> DefenseSessionRepository
    GradePublicationService --> GradePublicationRepository

4.web (BFF)

classDiagram
    class AuthPageController
    class LearnController
    class StudentController
    class AdminController
    class ContentAdminController
    class ApiClient {
        +login(String, String) AuthResponseDto
        +contentTree(String, Boolean) ContentTreeNodeDto
        +contentArticle(String, String, String, String, Boolean) ContentArticleDto
        +setContentPublished(String, String, String, Boolean)
        +groupDetail(String, String) GroupDetailDto
        +distribute(String, DistributeRequestPayload) DistributeResultDto
        +setGrade(String, String, String, String, String) GradeResponseDto 
        +submit(String, String, List~MultipartFile~, String) List~SubmissionDto~
        +myGrades(String, String) List~GradeResponseDto~
    }
    class SessionUser {
        +String id
        +String login
        +String role
        +String name
        +Boolean mustChangePassword
        +isAdmin() Boolean
        +isStudent() Boolean
    }
    class SessionKeys {
        +String TOKEN
        +String USER
    }
    class CsrfInterceptor
    class AuthInterceptor
    class AdminInterceptor
    class MustChangePasswordInterceptor
    class MarkdownRenderer {
        +render(String) String
    }

    AuthPageController --> ApiClient
    LearnController --> ApiClient
    StudentController --> ApiClient
    AdminController --> ApiClient
    ContentAdminController --> ApiClient
    LearnController --> MarkdownRenderer
    AuthPageController --> SessionUser
    AuthPageController --> SessionKeys
    CsrfInterceptor ..> SessionKeys
    AuthInterceptor ..> SessionUser
    AdminInterceptor ..> SessionUser
    MustChangePasswordInterceptor ..> SessionUser

5. ER-модель БД

Схемы identity, academic, progress живут в одной БД lms. Между схемами внешних ключей нет: progress хранит studentId/labId/groupId как UUID и получает связанные данные по REST.

erDiagram
    ACCOUNTS {
        uuid id PK
        varchar login UK
        varchar password_hash
        varchar role
        boolean must_change_password
        varchar temp_password
        timestamptz created_at
        timestamptz updated_at
    }
    STUDENTS {
        uuid account_id PK
        varchar student_no UK
        varchar last_name
        varchar first_name
        varchar middle_name
        varchar basis
        text note
    }
    GROUPS {
        uuid id PK
        varchar name UK
        int task_duration_min
        timestamptz created_at
    }
    ENROLLMENTS {
        uuid id PK
        uuid group_id FK
        uuid student_id FK
        timestamptz enrolled_at
    }
    SETTINGS {
        varchar key PK
        varchar value
    }

    ACCOUNTS ||--|| STUDENTS : "account_id"
    GROUPS ||--o{ ENROLLMENTS : "group_id"
    STUDENTS ||--o{ ENROLLMENTS : "student_id"

    SEMESTERS {
        uuid id PK
        varchar title
        int year
        boolean active
    }
    LABS {
        uuid id PK
        int number
        varchar title
        uuid semester_id FK
        text description_md
        timestamptz deadline_at
        timestamptz defense_at
        numeric points_max
        varchar status
        timestamptz created_at
    }
    LAB_LEVELS {
        uuid id PK
        uuid lab_id FK
        varchar code
        varchar title
        text description
        int position
        numeric points_max
    }
    VARIANTS {
        uuid id PK
        uuid lab_id FK
        uuid lab_level_id FK
        varchar number
        varchar title
        text statement_md
        int position
    }
    CHAPTERS {
        uuid id PK
        varchar title UK
        varchar slug UK
        int position
        boolean published
    }
    SECTIONS {
        uuid id PK
        uuid chapter_id FK
        varchar title
        varchar slug
        int position
        boolean published
    }
    ARTICLES {
        uuid id PK
        uuid section_id FK
        varchar title
        varchar slug
        text body_md
        int position
        boolean published
    }

    SEMESTERS ||--o{ LABS : "semester_id"
    LABS ||--o{ LAB_LEVELS : "lab_id"
    LAB_LEVELS ||--o{ VARIANTS : "lab_level_id"
    CHAPTERS ||--o{ SECTIONS : "chapter_id"
    SECTIONS ||--o{ ARTICLES : "section_id"

    VARIANT_ASSIGNMENTS {
        uuid id PK
        uuid student_id
        uuid lab_id
        uuid variant_id
        boolean manual
        timestamptz assigned_at
    }
    STUDENT_LAB_LEVELS {
        uuid student_id PK
        uuid lab_id PK
        uuid lab_level_id
        boolean manual
    }
    LAB_GRADES {
        uuid student_id PK
        uuid lab_id PK
        numeric score
        timestamptz updated_at
    }
    GRADE_PUBLICATIONS {
        uuid group_id PK
        uuid lab_id PK
        timestamptz published_at
    }
    GRADE_COMMENTS {
        uuid id PK
        uuid student_id
        uuid lab_id
        text text
        varchar tone
        timestamptz created_at
    }
    COMMENT_TEMPLATES {
        uuid id PK
        varchar text UK
        varchar tone
        int use_count
    }
    SUBMISSIONS {
        uuid id PK
        uuid student_id
        uuid lab_id
        int seq
        timestamptz created_at
    }
    DEFENSE_SESSIONS {
        uuid id PK
        uuid student_id
        uuid lab_id
        text assignment_md
        varchar status
        varchar code
        timestamptz started_at
        timestamptz ready_at
        varchar ready_reason
    }

    VARIANT_ASSIGNMENTS }o--|| VARIANTS : "variant_id (не FK)"
    VARIANT_ASSIGNMENTS }o--|| LABS : "lab_id (не FK)"
    STUDENT_LAB_LEVELS }o--|| LABS : "lab_id (не FK)"
    LAB_GRADES }o--|| LABS : "lab_id (не FK)"
    GRADE_PUBLICATIONS }o--|| LABS : "lab_id (не FK)"
    GRADE_COMMENTS }o--|| LABS : "lab_id (не FK)"
    SUBMISSIONS }o--|| LABS : "lab_id (не FK)"
    DEFENSE_SESSIONS }o--|| LABS : "lab_id (не FK)"