docs: add UML and architecture documentation (Mermaid)

- 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
This commit is contained in:
2026-09-16 18:44:14 +03:00
parent c379be7fba
commit 6ffc6304e3
7 changed files with 1398 additions and 0 deletions

160
docs/01-architecture.md Normal file
View File

@@ -0,0 +1,160 @@
# Архитектура системы
## 1. Контекст (какие внешние участники и системы)
```mermaid
flowchart LR
user["Преподаватель / студент<br/>(браузер)"]
nginx["nginx<br/>lms.our-place.ru<br/>TLS, reverse proxy"]
web["web BFF<br/>Thymeleaf + htmx<br/>:8090"]
gw["gateway<br/>Spring Cloud Gateway<br/>:8080"]
id["identity-service<br/>:8081"]
ac["academic-service<br/>:8082"]
pr["progress-service<br/>:8083"]
db[("PostgreSQL 18<br/>схемы identity / academic / progress")]
fs[("Файловое хранилище<br/>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<br/>AuthPage, Learn, Student, Admin, ContentAdmin"]
wi["Interceptor'ы:<br/>Csrf, Auth, Admin, MustChangePassword"]
wapi["ApiClient (WebClient)<br/>+ DTO"]
wmd["MarkdownRenderer<br/>(libs:markdown)"]
wc --> wapi
wc --> wmd
wi --> wc
end
subgraph gw["gateway (:8080)"]
routes["Маршруты<br/>identity / academic / progress"]
filter["JwtAuthenticationFilter<br/>order -100"]
filter --> routes
end
subgraph id["identity-service (:8081)"]
idc["AuthController, GroupController, StudentController"]
ids["AuthService, GroupService, Passwords,<br/>StudentImportParser, CredentialsExporter"]
idr["AccountRepository, GroupRepository,<br/>EnrollmentRepository, StudentRepository, SettingRepository"]
idc --> ids --> idr
end
subgraph ac["academic-service (:8082)"]
acc["LabController, ContentPublicController,<br/>ContentAdminController"]
acs["LabService, ContentService,<br/>LabImportParser, AssetStorage"]
acr["LabRepository, LabLevelRepository, VariantRepository,<br/>SemesterRepository, Content*Repository"]
acc --> acs --> acr
end
subgraph pr["progress-service (:8083)"]
prc["ProgressController, DefenseAdminController,<br/>DefenseMyController"]
prs["ProgressService, SubmissionService,<br/>DefenseService, GradePublicationService"]
prr["VariantAssignmentRepository, StudentLabLevelRepository,<br/>LabGradeRepository, GradeCommentRepository,<br/>CommentTemplateRepository, SubmissionRepository,<br/>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: путь публичный<br/>(login/setup/health/jwks)"]
b --> c["identity: AuthService.login"]
c --> d{"BCrypt совпал?"}
d -->|нет| e["401 + ApiError"]
d -->|да| f["JwtEncoder (RSA-2048, kid=lms)<br/>claims: sub, login, role, exp"]
f --> g["AuthResponse{token,...}"]
h["Клиент: запрос к защищённому API<br/>Authorization: Bearer ..."] --> i["gateway JwtAuthenticationFilter"]
i --> j{"Токен валиден по JWKS?"}
j -->|нет| k["401 Unauthorized"]
j -->|да| l["Добавляет X-Account-Id/Login/Role"]
l --> m["Сервис: SecurityConfig<br/>oauth2ResourceServer(jwt)"]
m --> n{"@PreAuthorize<br/>ROLE_ADMIN / isAuthenticated"}
n -->|нет| o["403 Forbidden"]
n -->|да| p["Контроллер → сервис → БД"]
```
- Роли: `ADMIN`, `STUDENT`; конвертер ролей читает claim `role` как `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 /**<br/>GET: выдаёт token в сессию<br/>POST: сверяет _csrf"]
csrf --> must["MustChangePasswordInterceptor /**<br/>(кроме /change-password, /logout, /login, статики)"]
must --> auth["AuthInterceptor<br/>/admin/**, /student/**, /change-password"]
auth --> adm["AdminInterceptor /admin/**"]
adm --> ctrl["Контроллер → ApiClient → gateway"]
```
- `GlobalModelAdvice` кладёт `currentUser` (SessionUser) во все шаблоны.
- Скрытые материалы и полное дерево материалов доступны только администратору.

108
docs/02-deployment.md Normal file
View File

@@ -0,0 +1,108 @@
# Развёртывание
## 1. Диаграмма развёртывания
```mermaid
flowchart TB
internet["Интернет"]
subgraph host["Сервер lms.our-place.ru (Ubuntu, Docker)"]
nginx["nginx (host)<br/>80/443, TLS (certbot)<br/>proxy → 127.0.0.1:8090"]
subgraph net["docker network: lms"]
web["container: web<br/>lms-web:latest<br/>:8090"]
gw["container: gateway<br/>lms-gateway:latest<br/>:8080"]
id["container: identity<br/>lms-identity:latest<br/>:8081"]
ac["container: academic<br/>lms-academic:latest<br/>:8082"]
pr["container: progress<br/>lms-progress:latest<br/>:8083"]
pg["container: postgres<br/>postgres:18<br/>:5432"]
vol1[("volume lms_pgdata")]
vol2[("volume lms_assets<br/>/data/content-assets")]
vol3[("volume lms_submissions<br/>/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["Исходники<br/>services/, libs/, web/, buildSrc/"]
subgraph build["Dockerfile, стадия build (eclipse-temurin:21-jdk)"]
g["./gradlew bootJar -x test<br/>(Gradle-кэш в cache-mount)"]
end
jar["bootJar-артефакты"]
subgraph run["Runtime-стадии (eclipse-temurin:21-jre)"]
r["java -jar app.jar<br/>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 применяет миграции<br/>V1..V5 автоматически при старте сервисов
```
- Резервные копии: `pg_dump` (том `lms_pgdata`) и `tar` томов `lms_assets`,
`lms_submissions` — команды в [deploy/DEPLOY.md](../deploy/DEPLOY.md).
- Порядок наполнения данными (админ → группы → лабораторная → варианты →
оценки/защита → материалы) описан в [deploy/IMPORT.md](../deploy/IMPORT.md).

639
docs/03-domain-model.md Normal file
View File

@@ -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 {
<<enumeration>>
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 {
<<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
```
```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)"
```

324
docs/04-sequences.md Normal file
View File

@@ -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 (метаданные, уровни) +<br/>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: Алгоритм:<br/>1) пул = варианты уровня студента (без уровня — первый уровень)<br/>2) очередь студентов случайна и общая для выбранных групп<br/>3) выбирается наименее занятый вариант уровня,<br/> пока есть невыданные — повторов нет<br/>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<SubmissionDto>
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() переводит просроченные<br/>сессии в 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: страница группы (оценки, защиты, поиск, фильтры, сортировка)
```

127
docs/05-states.md Normal file
View File

@@ -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
```

30
docs/README.md Normal file
View File

@@ -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).