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:
10
README.md
10
README.md
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
Микросервисная система для проведения практических занятий (Kotlin + Spring Boot + PostgreSQL 18).
|
Микросервисная система для проведения практических занятий (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` — общий формат ошибок и обработчик исключений.
|
- `libs/common-http` — общий формат ошибок и обработчик исключений.
|
||||||
|
|||||||
160
docs/01-architecture.md
Normal file
160
docs/01-architecture.md
Normal 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
108
docs/02-deployment.md
Normal 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
639
docs/03-domain-model.md
Normal 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
324
docs/04-sequences.md
Normal 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
127
docs/05-states.md
Normal 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
30
docs/README.md
Normal 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).
|
||||||
Reference in New Issue
Block a user