From 6ffc6304e3d964ee576f64a99fb2a409dbf85046 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 16 Sep 2026 18:44:14 +0300 Subject: [PATCH] docs: add UML and architecture documentation (Mermaid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/README.md — index and short system overview - 01-architecture.md — context, per-service components, Gradle modules, authentication/authorization, web interceptors (flowcharts) - 02-deployment.md — deployment diagram, containers/env/volumes, build stages, nginx, update sequence - 03-domain-model.md — class diagrams for identity/academic/progress and web, ER model of all schemas (cross-schema references noted) - 04-sequences.md — 10 sequence diagrams (login, JWT check, forced password change, lab import, distribution, submission, defense, grades+publication, content publishing, group page aggregation) - 05-states.md — state diagrams for defense session, grade publication, lab status, content visibility, submission lifecycle, variant assignment, password MS git log --oneline -3 --- README.md | 10 + docs/01-architecture.md | 160 ++++++++++ docs/02-deployment.md | 108 +++++++ docs/03-domain-model.md | 639 ++++++++++++++++++++++++++++++++++++++++ docs/04-sequences.md | 324 ++++++++++++++++++++ docs/05-states.md | 127 ++++++++ docs/README.md | 30 ++ 7 files changed, 1398 insertions(+) create mode 100644 docs/01-architecture.md create mode 100644 docs/02-deployment.md create mode 100644 docs/03-domain-model.md create mode 100644 docs/04-sequences.md create mode 100644 docs/05-states.md create mode 100644 docs/README.md diff --git a/README.md b/README.md index b35d1b7..19a94fb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Микросервисная система для проведения практических занятий (Kotlin + Spring Boot + PostgreSQL 18). +## Документация + +UML- и архитектурные диаграммы (Mermaid) — в каталоге [`docs/`](docs/README.md): + +- [Архитектура](docs/01-architecture.md) — контекст, компоненты, модули, аутентификация; +- [Развёртывание](docs/02-deployment.md) — Docker, nginx, тома, обновление; +- [Модель данных и классов](docs/03-domain-model.md) — диаграммы классов по сервисам и ER-модель; +- [Диаграммы последовательностей](docs/04-sequences.md) — вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы; +- [Диаграммы состояний](docs/05-states.md) — защита, публикация оценок и материалов, сдача, пароль. + ## Модули - `libs/common-http` — общий формат ошибок и обработчик исключений. diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..a7cc3b9 --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,160 @@ +# Архитектура системы + +## 1. Контекст (какие внешние участники и системы) + +```mermaid +flowchart LR + user["Преподаватель / студент
(браузер)"] + nginx["nginx
lms.our-place.ru
TLS, reverse proxy"] + web["web BFF
Thymeleaf + htmx
:8090"] + gw["gateway
Spring Cloud Gateway
:8080"] + id["identity-service
:8081"] + ac["academic-service
:8082"] + pr["progress-service
:8083"] + db[("PostgreSQL 18
схемы identity / academic / progress")] + fs[("Файловое хранилище
content-assets, submissions")] + + user -->|HTTPS| nginx + nginx -->|HTTP 127.0.0.1:8090| web + web -->|Bearer JWT| gw + gw --> id + gw --> ac + gw --> pr + id --> db + ac --> db + pr --> db + ac --> fs + pr --> fs + pr -->|REST, внутренняя сеть| ac + pr -->|REST, внутренняя сеть| id + id -.->|JWKS: /api/v1/auth/jwks.json| gw + id -.->|JWKS| ac + id -.->|JWKS| pr +``` + +- Браузер никогда не получает JWT: токен живёт в HTTP-сессии web-сервиса + (`lms.token`), а в браузер уходит только cookie сессии (`HttpOnly`, 30 дней). +- Все REST-запросы web проксирует на gateway, добавляя `Authorization: Bearer`. +- Gateway проверяет подпись токена по JWKS identity и передаёт вниз заголовки + `X-Account-Id`, `X-Account-Login`, `X-Account-Role`. +- Каждый сервис дополнительно валидирует JWT самостоятельно (resource server). + +## 2. Компоненты по сервисам + +```mermaid +flowchart TB + subgraph web["web (BFF, :8090)"] + wc["@Controller
AuthPage, Learn, Student, Admin, ContentAdmin"] + wi["Interceptor'ы:
Csrf, Auth, Admin, MustChangePassword"] + wapi["ApiClient (WebClient)
+ DTO"] + wmd["MarkdownRenderer
(libs:markdown)"] + wc --> wapi + wc --> wmd + wi --> wc + end + + subgraph gw["gateway (:8080)"] + routes["Маршруты
identity / academic / progress"] + filter["JwtAuthenticationFilter
order -100"] + filter --> routes + end + + subgraph id["identity-service (:8081)"] + idc["AuthController, GroupController, StudentController"] + ids["AuthService, GroupService, Passwords,
StudentImportParser, CredentialsExporter"] + idr["AccountRepository, GroupRepository,
EnrollmentRepository, StudentRepository, SettingRepository"] + idc --> ids --> idr + end + + subgraph ac["academic-service (:8082)"] + acc["LabController, ContentPublicController,
ContentAdminController"] + acs["LabService, ContentService,
LabImportParser, AssetStorage"] + acr["LabRepository, LabLevelRepository, VariantRepository,
SemesterRepository, Content*Repository"] + acc --> acs --> acr + end + + subgraph pr["progress-service (:8083)"] + prc["ProgressController, DefenseAdminController,
DefenseMyController"] + prs["ProgressService, SubmissionService,
DefenseService, GradePublicationService"] + prr["VariantAssignmentRepository, StudentLabLevelRepository,
LabGradeRepository, GradeCommentRepository,
CommentTemplateRepository, SubmissionRepository,
DefenseSessionRepository, GradePublicationRepository"] + prcl["AcademicClient, IdentityClient"] + prc --> prs + prs --> prr + prs --> prcl + end + + wapi -->|HTTP| routes + routes --> idc + routes --> acc + routes --> prc + prcl -->|GET /api/v1/labs/{id}| acc + prcl -->|GET /api/v1/groups и /{id}| idc +``` + +## 3. Модули Gradle + +```mermaid +flowchart LR + web[":web"] --> common[":libs:common-http"] + web --> md[":libs:markdown"] + gw[":services:gateway"] --> sec[":libs:security-jwt"] + gw --> common + id[":services:identity-service"] --> common + id --> sec + ac[":services:academic-service"] --> common + ac --> sec + ac --> md + pr[":services:progress-service"] --> common + pr --> sec + tests[":libs:test-support"] -.-> id + tests -.-> ac + tests -.-> pr +``` + +| Модуль | Назначение | +|---|---| +| `:libs:common-http` | единый формат ошибок (`ApiError`), иерархия `LmsException`, `LmsExceptionHandler`, генератор UUIDv7 (`Ids`) | +| `:libs:security-jwt` | свойства JWT, парсинг RSA-ключей, автоконфигурация `JwtEncoder`/`JwtDecoder`/`PasswordEncoder`, enum `LmsRole` | +| `:libs:markdown` | markdown → безопасный HTML (OWASP-санитайзер), таблицы, спойлеры `:::spoiler` | +| `:libs:test-support` | константы для интеграционных тестов с PostgreSQL | + +## 4. Аутентификация и авторизация + +```mermaid +flowchart TD + a["Клиент: POST /api/v1/auth/login"] --> b["gateway: путь публичный
(login/setup/health/jwks)"] + b --> c["identity: AuthService.login"] + c --> d{"BCrypt совпал?"} + d -->|нет| e["401 + ApiError"] + d -->|да| f["JwtEncoder (RSA-2048, kid=lms)
claims: sub, login, role, exp"] + f --> g["AuthResponse{token,...}"] + + h["Клиент: запрос к защищённому API
Authorization: Bearer ..."] --> i["gateway JwtAuthenticationFilter"] + i --> j{"Токен валиден по JWKS?"} + j -->|нет| k["401 Unauthorized"] + j -->|да| l["Добавляет X-Account-Id/Login/Role"] + l --> m["Сервис: SecurityConfig
oauth2ResourceServer(jwt)"] + m --> n{"@PreAuthorize
ROLE_ADMIN / isAuthenticated"} + n -->|нет| o["403 Forbidden"] + n -->|да| p["Контроллер → сервис → БД"] +``` + +- Роли: `ADMIN`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_`. +- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,jwks.json}`, + `/api/v1/content/tree`, `/api/v1/content/assets/**`, + `/api/v1/content/{chapter}/{section}/{article}`. +- RSA-ключи identity хранятся в таблице `settings` и публикуются в JWKS. + +## 5. Рендеринг страниц и защита форм (web) + +```mermaid +flowchart LR + req["HTTP-запрос"] --> csrf["CsrfInterceptor /**
GET: выдаёт token в сессию
POST: сверяет _csrf"] + csrf --> must["MustChangePasswordInterceptor /**
(кроме /change-password, /logout, /login, статики)"] + must --> auth["AuthInterceptor
/admin/**, /student/**, /change-password"] + auth --> adm["AdminInterceptor /admin/**"] + adm --> ctrl["Контроллер → ApiClient → gateway"] +``` + +- `GlobalModelAdvice` кладёт `currentUser` (SessionUser) во все шаблоны. +- Скрытые материалы и полное дерево материалов доступны только администратору. diff --git a/docs/02-deployment.md b/docs/02-deployment.md new file mode 100644 index 0000000..2eeab89 --- /dev/null +++ b/docs/02-deployment.md @@ -0,0 +1,108 @@ +# Развёртывание + +## 1. Диаграмма развёртывания + +```mermaid +flowchart TB + internet["Интернет"] + subgraph host["Сервер lms.our-place.ru (Ubuntu, Docker)"] + nginx["nginx (host)
80/443, TLS (certbot)
proxy → 127.0.0.1:8090"] + subgraph net["docker network: lms"] + web["container: web
lms-web:latest
:8090"] + gw["container: gateway
lms-gateway:latest
:8080"] + id["container: identity
lms-identity:latest
:8081"] + ac["container: academic
lms-academic:latest
:8082"] + pr["container: progress
lms-progress:latest
:8083"] + pg["container: postgres
postgres:18
:5432"] + vol1[("volume lms_pgdata")] + vol2[("volume lms_assets
/data/content-assets")] + vol3[("volume lms_submissions
/data/submissions")] + end + end + + internet -->|HTTPS| nginx + nginx -->|HTTP| web + web --> gw + gw --> id + gw --> ac + gw --> pr + id --> pg + ac --> pg + pr --> pg + ac --> vol2 + pr --> vol3 + pg --> vol1 +``` + +Наружу публикуется только web на `127.0.0.1:8090`; доступ снаружи — через nginx и TLS. + +## 2. Контейнеры и переменные окружения + +| Сервис | Образ / стадия Dockerfile | Порт | Ключевые переменные | Тома | +|---|---|---|---|---| +| postgres | `postgres:18` | 5432 (внутр.) | `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | `lms_pgdata` | +| identity | `target: identity` | 8081 | `LMS_DB_URL` (схема `identity`), `LMS_DB_USER`, `LMS_DB_PASSWORD` | — | +| academic | `target: academic` | 8082 | `LMS_DB_URL` (`academic`), `LMS_CONTENT_ASSETS_DIR`, `LMS_JWT_JWK_SET_URI` | `lms_assets` | +| progress | `target: progress` | 8083 | `LMS_DB_URL` (`progress`), `LMS_SUBMISSIONS_DIR`, `LMS_IDENTITY_URI`, `LMS_ACADEMIC_URI`, `LMS_JWT_JWK_SET_URI` | `lms_submissions` | +| gateway | `target: gateway` | 8080 | `LMS_IDENTITY_URI`, `LMS_ACADEMIC_URI`, `LMS_PROGRESS_URI`, `LMS_JWT_JWK_SET_URI` | — | +| web | `target: web` | 8090 | `LMS_GATEWAY_URL`, `SERVER_PORT` | — | + +`LMS_JWT_JWK_SET_URI` обязателен для gateway, academic и progress — +в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401). + +## 3. Сборка + +```mermaid +flowchart LR + src["Исходники
services/, libs/, web/, buildSrc/"] + subgraph build["Dockerfile, стадия build (eclipse-temurin:21-jdk)"] + g["./gradlew bootJar -x test
(Gradle-кэш в cache-mount)"] + end + jar["bootJar-артефакты"] + subgraph run["Runtime-стадии (eclipse-temurin:21-jre)"] + r["java -jar app.jar
TZ=Europe/Moscow"] + end + src --> g --> jar --> r +``` + +Java на сервере не нужна: сборка и запуск — внутри контейнеров. +`deploy/deploy.sh up` собирает образы и запускает стек из `docker-compose.prod.yml`. + +## 4. Nginx + +`deploy/nginx/lms.our-place.ru.conf`: + +- `listen 80` (TLS добавляет `certbot --nginx`), `server_name lms.our-place.ru`; +- ACME-челлендж через `/.well-known/acme-challenge/`; +- `client_max_body_size 120m` — загрузка ZIP-архивов лабораторных и решений; +- `proxy_pass http://127.0.0.1:8090` c заголовками + `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`; +- таймауты 300 с на чтение/отправку. + +Web-сервис запускается с `server.forward-headers-strategy: framework`, +поэтому корректно формирует https-редиректы за прокси. + +## 5. Обновление и данные + +```mermaid +sequenceDiagram + autonumber + participant Dev as Разработчик + participant Git as Gitea + participant Srv as Сервер /opt/LMS + participant DC as docker compose + participant PG as postgres + + Dev->>Git: git push origin master + Srv->>Git: git pull + Srv->>DC: ./deploy/deploy.sh up + DC->>DC: docker build (5 стадий) + DC->>PG: старт postgres (volume lms_pgdata) + DC->>DC: старт identity → academic → progress → gateway → web + Note over DC: Flyway применяет миграции
V1..V5 автоматически при старте сервисов +``` + +- Резервные копии: `pg_dump` (том `lms_pgdata`) и `tar` томов `lms_assets`, + `lms_submissions` — команды в [deploy/DEPLOY.md](../deploy/DEPLOY.md). +- Порядок наполнения данными (админ → группы → лабораторная → варианты → + оценки/защита → материалы) описан в [deploy/IMPORT.md](../deploy/IMPORT.md). diff --git a/docs/03-domain-model.md b/docs/03-domain-model.md new file mode 100644 index 0000000..86096c2 --- /dev/null +++ b/docs/03-domain-model.md @@ -0,0 +1,639 @@ +# Модель данных и классов + +Ниже — диаграммы классов по сервисам (JPA-сущности + репозитории + сервисы), +ER-модель БД и модель web-слоя. + +## 1. identity-service + +```mermaid +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 { + <> + ADMIN + STUDENT + } + + Account "1" --> "1" Student : @MapsId account_id + GroupEntity "1" --> "*" Enrollment : group_id + Student "1" --> "*" Enrollment : student_id + Account --> LmsRole : role +``` + +```mermaid +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 + +```mermaid +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 +``` + +```mermaid +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 + } + 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 + + 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 + +```mermaid +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 { + <> + 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 +``` + +```mermaid +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) + +```mermaid +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. + +```mermaid +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)" +``` diff --git a/docs/04-sequences.md b/docs/04-sequences.md new file mode 100644 index 0000000..84fd381 --- /dev/null +++ b/docs/04-sequences.md @@ -0,0 +1,324 @@ +# Диаграммы последовательностей + +## 1. Вход в систему и выдача JWT + +```mermaid +sequenceDiagram + autonumber + actor U as Пользователь + participant W as "web (BFF)" + participant G as gateway + participant I as identity + participant DB as PostgreSQL + + U->>W: POST /login (login, password, _csrf) + W->>G: POST /api/v1/auth/login + Note over G: путь публичный (без токена) + G->>I: POST /api/v1/auth/login + I->>DB: SELECT accounts WHERE login = ? + DB-->>I: Account(password_hash, role, must_change_password) + I->>I: BCrypt.matches(password, hash) + alt пароль верный + I->>I: JwtEncoder: RS256, kid=lms, claims sub/login/role/exp + I-->>G: AuthResponse(token, role, name, mustChangePassword) + G-->>W: 200 AuthResponse + W->>W: session.invalidate(); новая сессия + W->>W: session[token]=JWT, session[user]=SessionUser + alt mustChangePassword + W-->>U: 302 /change-password + else + W-->>U: 302 /admin | /student + end + else пароль неверный + I-->>G: 401 UnauthorizedException + G-->>W: 401 ApiError + W-->>U: страница входа с ошибкой + end +``` + +## 2. Защищённый запрос: проверка JWT + +```mermaid +sequenceDiagram + autonumber + participant W as "web (BFF)" + participant G as gateway + participant S as "сервис identity/academic/progress" + participant ID as identity /jwks.json + + W->>G: GET /api/v1/... (Authorization: Bearer JWT) + G->>G: JwtAuthenticationFilter (order -100) + alt путь публичный + G->>S: запрос без проверки токена + else требуется токен + G->>ID: JWKS (кэшируется) + ID-->>G: открытые RSA-ключи + G->>G: проверка подписи и срока действия + alt токен валиден + G->>S: + X-Account-Id, X-Account-Login, X-Account-Role + else + G-->>W: 401 {"status":401,"detail":"..."} + end + end + S->>S: SecurityConfig: oauth2ResourceServer(jwt), @PreAuthorize + S-->>W: 200 данные / 403 при нехватке прав +``` + +## 3. Первый вход: принудительная смена пароля + +```mermaid +sequenceDiagram + autonumber + actor S as Студент + participant W as "web (BFF)" + participant I as identity + participant DB as PostgreSQL + + S->>W: POST /login (временный пароль) + W->>I: POST /api/v1/auth/login + I-->>W: token, mustChangePassword = true + W->>W: сессия: SessionUser(mustChangePassword=true) + W-->>S: 302 /change-password + S->>W: GET /learn (попытка уйти) + W->>W: MustChangePasswordInterceptor (/**) + W-->>S: 302 /change-password + S->>W: POST /change-password (old, new, new2) + W->>I: POST /api/v1/auth/change-password (Bearer) + I->>DB: UPDATE accounts SET password_hash=?, must_change_password=false, temp_password=NULL + I-->>W: 200 ok + W->>W: SessionUser.copy(mustChangePassword=false) + W-->>S: 302 /student + Note over S,W: до смены пароля доступны только /change-password, /logout и статика +``` + +## 4. Импорт лабораторной (ZIP) + +```mermaid +sequenceDiagram + autonumber + actor A as Администратор + participant W as web + participant G as gateway + participant AC as academic + participant DB as PostgreSQL + + A->>W: POST /admin/labs/import (file=lab1.zip, _csrf) + W->>G: POST /api/v1/labs/import (multipart, Bearer) + G->>AC: POST /api/v1/labs/import + AC->>AC: LabImportParser.parse(zip) + Note over AC: lab.yaml (метаданные, уровни) +
README.md (условие) + variants/*.md + assets/ + AC->>DB: проверка уникальности номера ЛР в семестре + alt номер занят + AC-->>W: 409 Conflict "Лабораторная уже существует" + else + AC->>DB: INSERT labs, lab_levels, variants + AC-->>W: LabImportResult(levelCount, variantCount) + end + W-->>A: редирект на /admin/labs с уведомлением + A->>W: POST /admin/labs/{id}/edit (даты, статус published, уровни, варианты) + W->>AC: PUT /api/v1/labs/{id}, POST/PUT/DELETE уровней и вариантов + AC->>DB: UPDATE labs / INSERT|UPDATE|DELETE lab_levels, variants + W-->>A: «Все изменения сохранены» +``` + +## 5. Раздача вариантов + +```mermaid +sequenceDiagram + autonumber + actor A as Администратор + participant W as web + participant G as gateway + participant P as progress + participant AC as academic + participant ID as identity + participant DB as PostgreSQL + + A->>W: POST /admin/labs/{id}/distribute (groupIds[], force) + W->>G: POST /api/v1/distribute (Bearer) + G->>P: DistributeRequest(labId, groupIds, force) + P->>DB: SELECT student_lab_levels, variant_assignments (labId) + P->>ID: GET /api/v1/groups, GET /api/v1/groups/{id} + ID-->>P: студенты выбранных групп + P->>AC: GET /api/v1/labs/{id} + AC-->>P: уровни и варианты лабораторной + Note over P: Алгоритм:
1) пул = варианты уровня студента (без уровня — первый уровень)
2) очередь студентов случайна и общая для выбранных групп
3) выбирается наименее занятый вариант уровня,
пока есть невыданные — повторов нет
4) ручные закрепления и force не удаляет + P->>DB: INSERT/UPDATE variant_assignments (manual=false) + P-->>W: DistributeResult(assigned, total) + W-->>A: «Распределено: N из M» +``` + +## 6. Сдача решения студентом + +```mermaid +sequenceDiagram + autonumber + actor S as Студент + participant W as web + participant G as gateway + participant P as progress + participant AC as academic + participant FS as файловое хранилище + participant DB as PostgreSQL + + S->>W: POST /student/labs/{id}/submit (files, _csrf) + W->>G: POST /api/v1/me/submissions/{labId} (multipart, Bearer) + G->>P: submit(studentId, labId, files, token) + P->>AC: GET /api/v1/labs/{id} + alt дедлайн прошёл + AC-->>P: deadlineAt < now + P-->>W: 400 "Приём решений закрыт" + W-->>S: ошибка на странице лабы + else приём открыт + P->>DB: SELECT max(seq) по студенту и ЛР + P->>FS: сохранить файлы в submissions/{studentId}/{labId}/{seq} + P->>DB: INSERT submissions + P-->>W: List + W-->>S: 302 обратно на страницу лабы (история сдач) + end +``` + +## 7. Защита лабораторной + +```mermaid +sequenceDiagram + autonumber + actor A as Преподаватель + actor S as Студент + participant W as web + participant P as progress + participant DB as PostgreSQL + + A->>W: «Назначить задание» → POST /admin/defenses/save + W->>P: POST /api/v1/defenses/assign + P->>DB: INSERT/UPDATE defense_sessions (status=waiting, assignment_md) + A->>W: «выдать код» → POST /admin/defenses/{id}/issue-code + P->>DB: UPDATE code = 6-значный, status=waiting + P-->>W: код (flash-уведомление) + W-->>A: код показан преподавателю + + S->>W: POST /student/defenses/start (labId, code) + W->>P: POST /api/v1/me/defenses/start + P->>DB: SELECT ... WHERE code=? AND student_id=? + alt код верный + P->>DB: UPDATE status=started, started_at=now + P-->>W: DefenseDto(secondsLeft) + W-->>S: страница защиты с таймером + else + P-->>W: 400 "Неверный код" + end + + S->>W: POST /student/defenses/{id}/finish + W->>P: POST /api/v1/me/defenses/{id}/finish + P->>DB: UPDATE status=ready, ready_at=now + P-->>W: DefenseDto + W-->>S: «Работа сохранена» + + Note over P,DB: DefenseService.syncTimeouts() переводит просроченные
сессии в ready с ready_reason=timeout + A->>W: «доп. задание: начать заново» → POST /admin/defenses/{id}/reset + P->>DB: UPDATE status=waiting, code=NULL, started_at=NULL + W-->>A: сессия снова ждёт кода +``` + +## 8. Оценка, комментарии и публикация + +```mermaid +sequenceDiagram + autonumber + actor A as Преподаватель + actor S as Студент + participant W as web + participant P as progress + participant DB as PostgreSQL + + A->>W: POST /admin/groups/{gid}/grades (studentId, labId, score, commentsJson) + W->>P: POST /api/v1/grades (GradeSetRequest) + P->>DB: UPSERT lab_grades (student_id, lab_id, score) + P->>DB: DELETE grade_comments по паре, затем INSERT новых + P->>DB: UPSERT comment_templates (use_count++ для автоподсказок) + P-->>W: GradeResponse + Note over DB: Студент оценку пока НЕ видит + + A->>W: «опубликовать оценки» → POST /admin/groups/{gid}/publish + W->>P: POST /api/v1/grades/publish (labId, groupId, published=true) + P->>DB: INSERT/DELETE grade_publications (group_id, lab_id) + P-->>W: 200 + + S->>W: GET /student/labs/{labId} + W->>P: GET /api/v1/me/grades?groupId=... (Bearer) + P->>DB: SELECT lab_grades + grade_comments + grade_publications + P-->>W: только опубликованные оценки группы + W-->>S: оценка и комментарии списками: позитивный / негативный / нейтральный +``` + +## 9. Учебные материалы: публикация и просмотр + +```mermaid +sequenceDiagram + autonumber + actor A as Администратор + actor G as Гость/студент + participant W as web + participant G2 as gateway + participant AC as academic + participant DB as PostgreSQL + + A->>W: GET /learn + W->>G2: GET /api/v1/content/tree/all (Bearer admin) + AC->>DB: SELECT chapters, sections, articles (включая скрытые) + W-->>A: дерево с бейджами «скрыто» и кнопками публикации + + A->>W: POST /admin/content/articles/toggle-published (published=false) + W->>G2: PUT /api/v1/content/articles/{id}/published + AC->>DB: UPDATE articles SET published=false + W-->>A: «Материал скрыт от студентов» + + G->>W: GET /learn + W->>G2: GET /api/v1/content/tree + AC->>DB: SELECT ... WHERE published = true + AC-->>W: только опубликованное + W-->>G: материалы без скрытых глав, разделов и статей + + G->>W: GET /learn/{chapter}/{section}/{article} + W->>G2: GET /api/v1/content/{c}/{s}/{a} + alt статья (или её глава/раздел) скрыта + AC-->>W: 404 NotFoundException + W-->>G: 302 /learn + else опубликована + AC-->>W: ContentArticleView(bodyMd, published) + W->>W: MarkdownRenderer.render (таблицы, код, :::spoiler) + W-->>G: статья с подсветкой кода + end + Note over A,AC: админ открывает скрытую статью через /api/v1/content/all/{c}/{s}/{a} +``` + +## 10. Страница группы у администратора (агрегация данных) + +```mermaid +sequenceDiagram + autonumber + actor A as Администратор + participant W as "web (AdminController.groupDetail)" + participant G as gateway + participant ID as identity + participant AC as academic + participant P as progress + + A->>W: GET /admin/groups/{gid} + W->>G: GET /api/v1/groups/{gid} + G->>ID: GroupService.detail (студенты по ФИО) + W->>G: GET /api/v1/labs + G->>AC: LabService.list (опубликованные ЛР) + loop по каждой лабораторной + W->>G: GET /api/v1/assignments?labId + G->>P: варианты студентов + W->>G: GET /api/v1/submissions?labId + G->>P: последние сдачи + W->>G: GET /api/v1/defenses?labId + G->>P: сессии защиты + end + W->>G: GET /api/v1/grades + /grades/publications?groupId + G->>P: оценки + W->>W: сборка матрицы «студент × лабораторная» + W-->>A: страница группы (оценки, защиты, поиск, фильтры, сортировка) +``` diff --git a/docs/05-states.md b/docs/05-states.md new file mode 100644 index 0000000..e1851f4 --- /dev/null +++ b/docs/05-states.md @@ -0,0 +1,127 @@ +# Диаграммы состояний + +## 1. Сессия защиты (`defense_sessions.status`) + +```mermaid +stateDiagram-v2 + [*] --> waiting : преподаватель назначил задание + waiting --> waiting : выдан/обновлён код + waiting --> started : студент ввёл верный код + started --> ready : студент завершил работу + started --> ready : истекло время (syncTimeouts, readyReason=timeout) + ready --> waiting : «доп. задание: начать заново» (reset) + ready --> [*] : задание принято + note right of waiting + code = NULL — ждёт кода + code != NULL — код выдан + end note +``` + +## 2. Публикация оценок (пара «группа + лабораторная») + +```mermaid +stateDiagram-v2 + [*] --> hidden : оценка выставлена (lab_grades) + hidden --> published : «опубликовать оценки» (grade_publications) + published --> hidden : «скрыть оценки» + note right of hidden + студент не видит ни балл, + ни комментарии + end note + note right of published + студенты группы видят балл и + комментарии, сгруппированные по тону + end note +``` + +## 3. Статус лабораторной (`labs.status`) + +```mermaid +stateDiagram-v2 + [*] --> draft : импорт/создание + draft --> published : публикация + published --> draft : снятие с публикации + note right of draft + студентам не видна; + доступна для настройки + end note + note right of published + видна студентам: условие, вариант, + сдача; оценки — только после публикации + end note +``` + +## 4. Публикация материалов (главы, разделы, статьи) + +```mermaid +stateDiagram-v2 + state "опубликовано" as pub + state "скрыто" as hid + [*] --> pub : импорт/создание (published=true) + pub --> hid : «Скрыть» (published=false) + hid --> pub : «Опубликовать» + note right of hid + Не видно гостям и студентам: + глава целиком скрывает разделы и статьи, + раздел — свои статьи. + Администратор видит их с бейджем «скрыто» + и может открыть для предпросмотра. + end note +``` + +## 5. Жизненный цикл сдачи решения + +```mermaid +stateDiagram-v2 + [*] --> notSubmitted : лабораторная выдана + notSubmitted --> submitted : сдача (seq = 1, файлы на диске) + submitted --> submitted : переотправка (seq + 1, хранится вся история) + submitted --> closed : наступил дедлайн (deadlineAt) + submitted --> closed : выдано индивидуальное задание на защиту + closed --> [*] + note right of closed + Форма отправки скрыта, + доступно только скачивание + ранее отправленных решений + end note + note right of submitted + Скачать можно любую попытку; + преподаватель видит последнюю + end note +``` + +## 6. Вариант студента + +```mermaid +stateDiagram-v2 + [*] --> none : студент в группе, ЛР опубликована + none --> auto : распределение («Раздать») + auto --> manual : «Закрепить» конкретный вариант + auto --> auto : повторное распределение без force (не трогает занятые) + manual --> manual : force не перезаписывает ручные закрепления + auto --> none : force (сброс авто-назначений) + note right of auto + подбирается наименее занятый вариант + своего уровня; пока есть невыданные — + повторов нет + end note +``` + +## 7. Пароль студента + +```mermaid +stateDiagram-v2 + [*] --> temporary : аккаунт создан / «Сбросить пароль» + temporary --> own : смена на /change-password + own --> temporary : «Сбросить пароль» преподавателем + note right of temporary + must_change_password = true, + temp_password хранится; + пока пароль не сменён, доступны только + /change-password, /logout и статика + end note + note right of own + must_change_password = false, + temp_password = NULL + end note +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..12819d3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Документация LMS + +UML- и архитектурные диаграммы проекта. Все диаграммы — в формате **Mermaid**, +поэтому они рендерятся прямо в Gitea/GitHub, в VS Code (расширение Markdown +Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). + +## Содержание + +| Документ | Что описывает | +|---|---| +| [01-architecture.md](01-architecture.md) | Контекст системы, компоненты, модули Gradle, схема аутентификации | +| [02-deployment.md](02-deployment.md) | Диаграмма развёртывания: Docker, nginx, тома, порты, переменные окружения | +| [03-domain-model.md](03-domain-model.md) | Диаграммы классов по сервисам, ER-модель БД, модель web-слоя | +| [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы | +| [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов | + +## Кратко о системе + +LMS — микросервисное приложение на Kotlin/Spring Boot 3 (Java 21) с +server-side-rendered UI на Thymeleaf + htmx: + +- **gateway** (`:8080`) — единая точка входа для REST API: маршрутизация и проверка JWT; +- **identity-service** (`:8081`) — аккаунты, группы, студенты, аутентификация (JWT + JWKS); +- **academic-service** (`:8082`) — семестры, лабораторные (уровни, варианты), учебные материалы; +- **progress-service** (`:8083`) — варианты/уровни студентов, сдачи, оценки и комментарии, защиты; +- **web** (`:8090`) — BFF: сессии, CSRF, страницы админа и студента, markdown-рендер; +- **PostgreSQL 18** — одна БД `lms` со схемами `identity`, `academic`, `progress`. + +Поток запроса: браузер → nginx → web → gateway → сервис → PostgreSQL. +Подробности — в [01-architecture.md](01-architecture.md).