136 Commits

Author SHA1 Message Date
Zuev
a78a93f2f0 #5 8 9 10 12 13 17 - готовы. #15 - тест 2026-07-27 02:23:26 +03:00
dipatrik10
3e8722a26b Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-07-27 00:40:12 +03:00
dipatrik10
7e29c53ce9 комит 2026-07-27 00:33:16 +03:00
Zuev
60c8aa31ac доки 2026-07-27 00:31:05 +03:00
Zuev
92ff87a917 #2 - тест, #5 - готово. Разовое редактирование занятия без изменений правила 2026-07-22 00:15:35 +03:00
Zuev
ee876f1acd баг-фикс завершён 2026-07-19 20:16:12 +03:00
Zuev
bc0e1ab1b4 баг-фикс 30/34 2026-07-19 14:40:43 +03:00
3d798c13e3 Задача Егора 2ч. 2026-07-17 01:27:02 +03:00
dipatrik10
3f685d3e22 Задача Егора 2026-07-16 22:56:43 +03:00
Zuev
85f61436b6 исправление багов 2026-07-13 03:28:18 +03:00
Zuev
39c58440cf промпт для фикса уязвимостей 2026-07-11 03:00:16 +03:00
Zuev
ae0dd4c8b3 баг репорт 2026-07-10 22:20:50 +03:00
Zuev
f7826fb230 резюме девопс 2026-07-04 22:15:05 +03:00
Zuev
96e055e13f баг репорт и графифай 2026-07-04 15:47:12 +03:00
9e6e0038c6 Отображение вкладок в структуре вуза 2026-06-04 23:46:26 +03:00
6a03931190 исправлена 500 ошибка, пустое пространство в модалке 2026-06-04 10:50:12 +03:00
dipatrik10
3d50b6b006 Исправил косяки в модалках решения конфликтов правил 2026-06-03 21:22:24 +03:00
dipatrik10
53298d9d07 Добавил возможность решения конкфликтов при создании правил 2026-06-03 13:55:31 +03:00
776728cf83 Структура вуза, визуал 2026-06-03 12:16:37 +03:00
dipatrik10
08b8607f05 Добавил вкладку заявок 2026-06-02 16:07:30 +03:00
6d9f6b7a42 Изменен фильтр в "группы обучения", изменена механика создания подгрупп 2026-06-02 11:21:02 +03:00
4e1985c28e исправил чекбоксы оборудования в аудиторном фонде 2026-06-02 00:29:23 +03:00
dipatrik10
a966648a7e Сделал обработку на ВЕ конфликтных правил 2026-06-02 00:24:57 +03:00
dipatrik10
f3273f6972 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-06-02 00:02:06 +03:00
dipatrik10
4adce0b89c Добавил запрос на создание препода от кафедры 2026-06-02 00:01:14 +03:00
43d72c5235 Добавить дисциплины календарных графиков 2026-06-01 23:27:37 +03:00
Zuev
4b05d4287c поправил поля ввода и вызово боковой панели конструктора правил 2026-06-01 21:14:30 +03:00
Zuev
5a81a911ff devops 2026-06-01 20:50:18 +03:00
57e55298d5 удалены лишние файлы 2026-06-01 17:00:21 +03:00
3217586657 редизайн кнопок 1st version 2026-06-01 02:55:06 +03:00
dipatrik10
30972db9ee Исправил часть косяков в конструкторе правил 2026-05-31 21:36:23 +03:00
Zuev
f3a9905423 профили 2026-05-31 16:34:05 +03:00
Zuev
7926038bdc обновил дизайн, добавил дашборд 2026-05-31 16:18:27 +03:00
Zuev
e2a3553a09 поправил добавление дисциплин и расписание занятий 2026-05-31 14:42:43 +03:00
e989391c8d Поправил сетку и еще пару багов 2026-05-28 00:52:21 +03:00
5b6ffa5c08 поправил вид сетки и прочие баги 2026-05-28 00:47:51 +03:00
dipatrik10
bbfee068da Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule 2026-05-27 21:26:22 +03:00
dipatrik10
9df67400b8 test 2026-05-27 21:25:33 +03:00
dipatrik10
c19a9b34c7 Добавил визуальную матрицу расписания групп 2026-05-27 21:18:57 +03:00
Zuev
37850b8e06 исправил логику курса групп 2026-05-27 21:04:54 +03:00
Zuev
e9575cf2ce исправил ошибки и проблемы безопасности 2026-05-27 20:29:50 +03:00
Zuev
c7cb5125f4 Merge branch 'origin/dynamic_schedule' into dynamic_schedule 2026-05-27 17:23:27 +03:00
Zuev
d4ace6aab2 переделал токен
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 13s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m48s
2026-05-27 16:48:33 +03:00
Zuev
87cd4cbdc7 переделал токен 2026-05-27 15:22:23 +03:00
52328ae6c0 Merge pull request 'Всё в прод' (#11) from dynamic_schedule into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 4m49s
Reviewed-on: #11
2026-05-27 10:27:09 +00:00
d2ef44471b Merge branch 'main' into dynamic_schedule 2026-05-27 10:26:46 +00:00
dipatrik10
467f78c3d0 Работа с сеткой в графике загруженности 2026-05-27 13:22:59 +03:00
dipatrik10
f08db3f178 Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule 2026-05-25 13:40:30 +03:00
dipatrik10
c506a905a2 Немного исправил сетку графика 2026-05-25 13:40:22 +03:00
Zuev
f49fa95d5a исправил просмотр расписаний 2026-05-20 15:02:43 +03:00
Zuev
b1b85c3d17 поправил просмотр расписания 2026-05-20 01:29:07 +03:00
Zuev
91729cff7d ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания 2026-05-19 23:10:56 +03:00
Zuev
f010ffc467 план ролевого доступа 2026-05-19 15:02:07 +03:00
Zuev
670e0a51ad сделал просмотр загруженности конкретной аудитории 2026-05-15 02:12:58 +03:00
Zuev
02c5ea9118 подправил создание правил расписания 2026-05-08 13:41:25 +03:00
Zuev
e12a5bcccd добавил денение на подгруппы для лабораторных работ 2026-05-06 22:25:53 +03:00
Zuev
b222f3adac написал обработку количества часов лекций лабораторных и практик по отдельности 2026-05-06 21:54:17 +03:00
Zuev
44cfe8ab6e исправил настройку временных слотов 2026-05-02 01:34:21 +03:00
Zuev
3cb311f469 сделал настройку временных слотов 2026-05-01 20:55:49 +03:00
Zuev
bf02efb8b8 доработал отображение учебного графика 2026-05-01 14:02:01 +03:00
Zuev
89c822a073 создал систему календарного учебного графика 2026-04-30 23:40:49 +03:00
Zuev
96e9d8155f Миллион изменений, создание вкладки "Расписание занятий" 2026-04-29 23:35:16 +03:00
Zuev
8f71b9b2b5 начало работы построения динамического расписания и документация 2026-04-29 15:42:05 +03:00
dipatrik10
eb023c07fb Merge remote-tracking branch 'origin/main'
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m4s
# Conflicts:
#	AGENTS.md
#	docs/API.md
#	docs/FRONTEND.md
#	frontend/admin/js/main.js
2026-04-09 21:33:06 +03:00
dipatrik10
d2b9d0c5ea Merge branch 'department_dev' 2026-04-09 21:22:44 +03:00
dipatrik10
813e81be70 Удалил в2 2026-04-09 21:20:11 +03:00
dipatrik10
e92aa74048 Собрал БД в 1 файл 2026-04-09 21:19:35 +03:00
Zuev
48e8d4e631 добавил файл с задачами по денамической генерации занятий 2026-04-07 17:42:58 +03:00
Zuev
c7145de95a поправил документ описывающий концепцияю динамической генерации расписания 2026-04-07 01:10:44 +03:00
Zuev
c7594c4380 добавил архитектурное описание новой системы управления расписанием 2026-04-04 23:56:39 +03:00
ac69a57290 Зеркалирование "Создать занятие" в "Расписание занятий" из "Пользователи", визуальные изменения этих модалок 2026-04-02 00:09:19 +03:00
c82e3feaed Изменил страницу "Кафедра", добавлены изменения из задачи #54 в Vikunja 2026-04-01 23:18:05 +03:00
ProstoDenya01
3cdb8614cb Изменил логику в группах, вместо семестра теперь пишется год начала обучения, чтобы курс и семестр считались на бэке 2026-03-31 16:44:44 +03:00
Zuev
73995f86f8 добавил тестовую страницу загруженности аудиторий 2026-03-31 15:00:38 +03:00
ProstoDenya01
cd6cc6f5f7 Поправил создание кафедрального файла 2026-03-31 13:20:31 +03:00
2be2534a1e Merge remote-tracking branch 'origin/department_dev' into department_dev 2026-03-31 00:49:45 +03:00
b14d937062 Изменил страницу "Кафедра",добавлена модалка с полями для создания записи в Кафедральный файлик 2026-03-31 00:49:37 +03:00
9d06c99d06 Поправил документацию по API 2026-03-30 22:06:16 +03:00
Zuev
522bc97b8c добавил всплывающий текст у свёрнутой боковой панели 2026-03-28 14:28:13 +03:00
Zuev
d0a8148fa0 исправил боковую панель. теперь на десктопе она сворачивается не полностью 2026-03-28 14:20:46 +03:00
ProstoDenya01
0b9d063266 Поправил ответ для пользователей, чтобы приходило название кафедры, а не ID 2026-03-27 19:02:27 +03:00
ProstoDenya01
6f33e23e17 Добавил методы на создание и удаление данных в кафедральном файле 2026-03-27 18:24:08 +03:00
Zuev
bfdcb58c7d изменил дизайн выпадающих списков 2026-03-27 16:08:44 +03:00
Zuev
e015758caf обновил документацию 2026-03-27 15:24:29 +03:00
Zuev
6be8db0cd0 сделал кнопку настроек, вкладку настроек и сворачивание боковой панели 2026-03-27 15:03:52 +03:00
ProstoDenya01
7a2c385257 Реализовал метод на получение данных для расписания по нужным критериям. Обновил БД 2026-03-26 20:08:17 +03:00
f7483e7aeb Изменил страницу "Кафедра", добавлена фильтрация и добавление блоков 2026-03-26 00:37:31 +03:00
Zuev
55da934545 Merge branch 'department_dev' of ssh://gitea.zuev.company:2222/Zuev/magistr into department_dev 2026-03-25 23:53:35 +03:00
Zuev
e71bcee9b5 chore: Configure database healthcheck, backend service dependency on DB health, and disable OpenTelemetry SDK. 2026-03-25 23:53:23 +03:00
7ce0d1e501 Добавлена страница создание кафедры/специальности 2026-03-25 23:52:48 +03:00
Zuev
3861fa05b5 feat: add frontend-design skill with its documentation and license, and update gitignore. 2026-03-25 23:52:48 +03:00
Zuev
599e284ea9 feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md. 2026-03-25 23:52:48 +03:00
Zuev
ec7e615557 docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md. 2026-03-25 23:52:48 +03:00
Zuev
9e7b35aa0b feat: Add OpenTelemetry integration by creating otel.js and importing it into main.js. 2026-03-25 23:52:48 +03:00
ProstoDenya01
4915e6f33b Добавил метод на получение списка данных для расписания 2026-03-25 23:49:10 +03:00
ProstoDenya01
798d61c7ea Добавил возможность создавать, просматривать и удалять данные дисциплин и кафедр. 2026-03-25 22:41:53 +03:00
Zuev
e03a68b7a8 feat: add frontend-design skill with its documentation and license, and update gitignore.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m55s
2026-03-23 02:05:02 +03:00
Zuev
fcd7baac71 feat: Add AutoUpdateDocs agent skill and new logging documentation, updating AGENTS.md.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 10s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m54s
2026-03-22 15:22:10 +03:00
Zuev
491807cd94 docs: Add comprehensive project documentation covering architecture, development, and APIs, and update AGENTS.md.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 17s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m57s
2026-03-22 02:49:13 +03:00
ProstoDenya01
0817961d97 Добавил для пользователей, дисциплин и групп получение списка по конкретной кафедре. 2026-03-21 13:00:30 +03:00
ProstoDenya01
49ca2e17b6 Добавил все новые поля (departmentId, code) для дисциплин. Добавил логирование для SubjectController.java 2026-03-21 11:41:01 +03:00
ProstoDenya01
c07e49ca98 Добавил все новые поля (departmentId, course) для групп. Добавил логирование для GroupController.java 2026-03-21 10:58:27 +03:00
dipatrik10
b89d1c7f72 Добавил все новые поля (fullName, jobTitle, departmentId) для пользователей. Добавил логирование для UserController.java 2026-03-21 00:20:34 +03:00
6774ebb766 Доработано создание пользователя и отображение новых параметров пользователя в таблице 2026-03-20 00:33:27 +03:00
f7fb524bb0 Доработано создание групп и отображение новых параметров групп в таблице 2026-03-20 00:27:01 +03:00
Zuev
81e91e056f feat: Add OpenTelemetry integration by creating otel.js and importing it into main.js.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m57s
2026-03-19 23:47:01 +03:00
d78e675a71 Доработано создание дисциплин и отображение новых параметров дисциплин в таблице+страничка с кафедрами(голая) 2026-03-19 23:42:09 +03:00
Zuev
8cf086d3e9 Remove legacy V2 migration
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 10s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m34s
2026-03-19 04:37:11 +03:00
f39c3d1bbb Merge pull request 'Fix database migration: merge V2 into V1 and remove V2' (#9) from department_dev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 32s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m7s
Reviewed-on: #9
2026-03-19 01:32:07 +00:00
Zuev
dc1c343174 Fix database migration: merge V2 into V1 and remove V2 2026-03-19 04:30:31 +03:00
74fcd07e25 Merge pull request 'department_dev' (#8) from department_dev into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 4m9s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m5s
Reviewed-on: #8
2026-03-19 00:56:35 +00:00
Zuev
8ced8ae669 feat: Integrate OpenTelemetry for distributed tracing in both frontend and backend applications. 2026-03-19 03:55:22 +03:00
dipatrik10
f519650bbb Исправил ошибку в коментах 2026-03-18 20:48:36 +03:00
dipatrik10
7fac9f744d Добавил комментарии для всех колонок таблиц 2026-03-18 20:45:39 +03:00
dipatrik10
18d099460d Поправил создание таблицы 2026-03-18 20:16:34 +03:00
dipatrik10
59b6704be9 Добавил комментарии к БД 2026-03-18 20:05:30 +03:00
ProstoDenya01
220b99594f Обновил таблицы и создал новые для данных расписания. 2026-03-18 15:44:05 +03:00
ProstoDenya01
c10198515c Обновил таблицы и создал новые для данных расписания. 2026-03-18 15:39:11 +03:00
Zuev
a8144acb8b config: Update application properties.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 4m27s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m20s
2026-03-18 02:00:49 +03:00
Zuev
04feb5a3c3 feat: Enhance Dockerfile security with non-root users and correct file permissions, and adjust Gitea workflow action versions.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 17s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m25s
2026-03-17 02:47:57 +03:00
Zuev
d69eab1c12 chore: Update Gitea workflow actions to newer versions, add AGENTS.md, and modify gitignore.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m37s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m10s
2026-03-17 01:30:48 +03:00
ProstoDenya01
f3ea05cd17 Merge remote-tracking branch 'origin/personal-schedule'
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 15s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m38s
2026-03-15 16:00:48 +03:00
Zuev
9f124c52a5 refactor: Integrate Flyway for database migrations and simplify Docker Compose and tenant configurations.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m44s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m19s
2026-03-13 04:35:50 +03:00
Zuev
10c06e726a Update tenant data source configuration.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 28s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m19s
2026-03-13 02:48:03 +03:00
Zuev
9d2de1faaf feat: Configure and route multi-tenant data sources using an interceptor.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Failing after 22s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Has been skipped
2026-03-13 02:36:01 +03:00
Zuev
59caa9d6cc feat: Implement dynamic tenant configuration watching and updating.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 5m59s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 12s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 44s
2026-03-13 02:00:24 +03:00
Zuev
bad1215341 баг
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 38s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 20s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 1m8s
2026-03-13 01:04:26 +03:00
Zuev
ccdc371c3a баг
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 30s
Build and Push Docker Images / build-and-push-frontend (push) Failing after 20s
Build and Push Docker Images / deploy-to-k8s (push) Has been skipped
2026-03-13 00:56:49 +03:00
Zuev
4c2293b620 feat: Implement database initialization using init.sql and update DataInitializer.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 34s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 45s
2026-03-13 00:21:06 +03:00
Zuev
6ea420e529 1
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 30s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 50s
2026-03-13 00:07:54 +03:00
Zuev
75b1ad166e refactor: update tenant data source configuration and routing logic.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 31s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 46s
2026-03-12 23:53:20 +03:00
Zuev
abad3776db feat: Add H2 in-memory database dependency for fallback scenarios.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 3m11s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 10s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m32s
2026-03-12 22:42:57 +03:00
Zuev
13b3a5c481 refactor: Directly configure LocalContainerEntityManagerFactoryBean with HibernateJpaVendorAdapter and rename the primary data source bean.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 12s
Build and Push Docker Images / deploy-to-k8s (push) Has been cancelled
2026-03-12 22:38:00 +03:00
Zuev
3579ef9f1c config: Exclude DataSourceAutoConfiguration and update tenant database connection URL in tenants.json.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 29s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Has been cancelled
2026-03-12 22:31:09 +03:00
Zuev
14cc006f06 feat: Implement multi-tenancy with dynamic data source routing and introduce a database management UI.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 33s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 16s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m31s
2026-03-12 22:15:28 +03:00
9e55472de7 Merge pull request 'Добавил для групп поле численности.' (#7) from personal-schedule into main
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 32s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m32s
Reviewed-on: #7
2026-03-12 14:20:33 +00:00
647 changed files with 176702 additions and 4052 deletions

View File

@@ -0,0 +1,85 @@
---
name: AutoUpdateDocs
description: Автоматическое обновление документации проекта после изменений в коде
---
# Скилл: Автоматическое обновление документации
## Когда активировать
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
- **Общая структура проекта** → обновить `docs/README.md`
## Карта соответствия «файл → документация»
| Изменённый файл/директория | Файл документации |
|----------------------------|-------------------|
| `controller/*Controller.java` | `docs/API.md` |
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
| `model/*.java` | `docs/DATABASE.md` |
| `dto/*.java` | `docs/API.md` |
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` |
## Пошаговая инструкция
### 1. Определить затронутые файлы документации
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
### 2. Прочитать текущую документацию
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
### 3. Внести точечные изменения
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
#### Новый контроллер → `docs/API.md`
Добавить новую секцию с описанием эндпоинтов:
- Метод + URL
- Тело запроса (JSON пример)
- Ответ (JSON пример)
- Валидация
#### Новая миграция → `docs/DATABASE.md`
- Добавить новую таблицу в ER-диаграмму (Mermaid)
- Добавить описание таблицы и колонок
- Добавить запись в таблицу «Текущие миграции»
#### Новый view → `docs/FRONTEND.md`
- Добавить в дерево файлов
- Добавить в таблицу «Разделы админ-панели»
### 4. Обновить AGENTS.md (при необходимости)
Если изменения затрагивают:
- Структуру директорий → обновить дерево в `AGENTS.md`
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
### 5. Сообщить пользователю
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
## Правила
1. **Язык:** Вся документация на русском языке
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения

View File

@@ -0,0 +1,86 @@
---
name: AutoUpdateDocs
description: Автоматическое обновление документации проекта после изменений в коде
---
# Скилл: Автоматическое обновление документации
## Когда активировать
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
- **Общая структура проекта** → обновить `docs/README.md`
## Карта соответствия «файл → документация»
| Изменённый файл/директория | Файл документации |
|----------------------------|-------------------|
| `controller/*Controller.java` | `docs/API.md` |
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
| `model/*.java` | `docs/DATABASE.md` |
| `dto/*.java` | `docs/API.md` |
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
| `frontend/admin/settings/**` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` |
## Пошаговая инструкция
### 1. Определить затронутые файлы документации
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
### 2. Прочитать текущую документацию
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
### 3. Внести точечные изменения
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
#### Новый контроллер → `docs/API.md`
Добавить новую секцию с описанием эндпоинтов:
- Метод + URL
- Тело запроса (JSON пример)
- Ответ (JSON пример)
- Валидация
#### Новая миграция → `docs/DATABASE.md`
- Добавить новую таблицу в ER-диаграмму (Mermaid)
- Добавить описание таблицы и колонок
- Добавить запись в таблицу «Текущие миграции»
#### Новый view → `docs/FRONTEND.md`
- Добавить в дерево файлов
- Добавить в таблицу «Разделы админ-панели»
### 4. Обновить AGENTS.md (при необходимости)
Если изменения затрагивают:
- Структуру директорий → обновить дерево в `AGENTS.md`
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
### 5. Сообщить пользователю
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
## Правила
1. **Язык:** Вся документация на русском языке
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Создание выразительных, готовых к продакшену frontend-интерфейсов с высоким качеством дизайна. Используйте этот навык, когда пользователь просит разработать веб-компоненты, страницы, артефакты, постеры или приложения (например, сайты, лендинги, дашборды, React-компоненты, HTML/CSS верстку или когда нужно стилизовать/улучшить любой веб-интерфейс). Генерирует креативный, отточенный код и UI-дизайн, избегая шаблонной эстетики ИИ.
license: Полные условия в LICENSE.txt
---
Этот навык направляет создание выразительных, готовых к продакшену frontend-интерфейсов, которые избегают шаблонной "ИИ-эстетики". Создавайте реально работающий код с исключительным вниманием к эстетическим деталям и творческим решениям.
Пользователь предоставляет требования к фронтенду: компонент, страницу, приложение или интерфейс для разработки. Требования могут включать контекст о цели, аудитории или технических ограничениях.
## Дизайн-мышление
Перед написанием кода поймите контекст и примите СМЕЛОЕ эстетическое направление:
- **Цель**: Какую проблему решает этот интерфейс? Кто им пользуется?
- **Тон**: Выберите крайность: брутальный минимализм, максималистский хаос, ретро-футуризм, органический/природный, люксовый/утонченный, игривый/игрушечный, редакционный/журнальный, брутализм/грубый, арт-деко/геометрический, мягкий/пастельный, индустриальный/утилитарный и т.д. Вариантов очень много. Используйте их для вдохновения, но создайте дизайн, верный выбранному эстетическому направлению.
- **Ограничения**: Технические требования (фреймворк, производительность, доступность).
- **Отличительная черта**: Что делает это НЕЗАБЫВАЕМЫМ? Какую единственную вещь кто-то запомнит?
**КРИТИЧЕСКИ ВАЖНО**: Выберите четкое концептуальное направление и выполните его с точностью. Смелый максимализм и утонченный минимализм — оба работают, ключ кроется в осознанности намерений, а не в интенсивности.
Затем реализуйте рабочий код (HTML/CSS/JS, React, Vue и т.д.), который:
- Готов к продакшену и функционален
- Визуально поразителен и легко запоминается
- Согласован с четкой эстетической точкой зрения
- Тщательно проработан в каждой детали
## Руководство по эстетике фронтенда
Сфокусируйтесь на:
- **Типографика**: Выбирайте шрифты, которые красивы, уникальны и интересны. Избегайте общих шрифтов, таких как Arial и Inter; вместо этого делайте выбор в пользу выразительных, неожиданных и характерных вариантов, которые повышают уровень эстетики фронтенда. Сочетайте акцидентный шрифт (display) с утонченным текстовым (body).
- **Цвет и тема**: Придерживайтесь согласованной эстетики. Используйте CSS-переменные для консистентности. Доминирующие цвета с резкими акцентами работают намного лучше, чем робкие, равномерно распределенные палитры.
- **Анимация (Motion)**: Используйте анимации для эффектов и микро-взаимодействий. Отдавайте предпочтение CSS-решениям для HTML. Используйте библиотеки анимаций для React, если они доступны. Фокусируйтесь на моментах с высоким влиянием: одна хорошо срежиссированная загрузка страницы с каскадным появлением элементов (animation-delay) создает больше восторга, чем множество разрозненных микро-взаимодействий. Используйте триггеры при скролле (scroll-triggering) и состояния наведения (hover), которые удивляют.
- **Пространственная композиция**: Неожиданные макеты. Асимметрия. Перекрытие. Диагональное направление. Элементы, ломающие сетку. Обильное негативное пространство ИЛИ контролируемая плотность элементов.
- **Фоны и визуальные детали**: Создавайте атмосферу и глубину вместо использования скучных сплошных цветов по умолчанию. Добавляйте контекстуальные эффекты и текстуры, соответствующие общей эстетике. Применяйте творческие формы: градиентные сетки, шумовые текстуры, геометрические паттерны, слоистые прозрачности, драматичные тени, декоративные рамки, кастомные курсоры и эффекты зернистости (grain).
НИКОГДА не используйте шаблонную сгенерированную ИИ эстетику: заезженные семейства шрифтов (Inter, Roboto, Arial, системные шрифты), клишированные цветовые схемы (особенно фиолетовые градиенты на белом фоне), предсказуемые макеты и паттерны компонентов, а также типовой скучный дизайн без характера, не учитывающий контекст.
Интерпретируйте творчески и делайте неожиданные выборы, которые кажутся действительно разработанными под данный контекст. Ни один дизайн не должен быть шаблонным ("под копирку"). Варьируйте между светлыми и темными темами, разными шрифтами, различной эстетикой. НИКОГДА не сходитесь к общим выборам (например, Space Grotesk) в разных генерациях кода.
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.

View File

@@ -0,0 +1 @@
0.9.5

View File

@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory → Obsidian vault
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### Step 3 - Extract entities and relationships
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
Call `spawn_agent` once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt in task-delegation framing:
```
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
After all agents are dispatched, collect results sequentially in memory:
```
result = wait_agent(handle); close_agent(handle) # repeat per handle
```
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. Codex collects in memory, so there are no per-chunk files on disk; the disk-based success checks in Step B3 do not apply — a chunk that returns invalid JSON is the failure signal instead.
Subagent prompt template:
See `references/extraction-spec.md` for the compact subagent prompt (rules, node-ID format, confidence rubric, hyperedge and vision rules, JSON schema). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and have it return the JSON inline.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.

View File

@@ -0,0 +1,56 @@
# graphify reference: add a URL and watch a folder
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
## For /graphify add
Fetch a URL and add it to the corpus, then update the graph.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path
try:
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
print(f'Saved to {out}')
except ValueError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
"
```
Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
Supported URL types (auto-detected):
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
- arXiv → abstract + metadata saved as `.md`
- PDF → downloaded as `.pdf`
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
- Any webpage → converted to markdown via html2text
---
## For --watch
Start a background watcher that monitors a folder and auto-updates the graph when files change.
```bash
$(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3
```
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
Press Ctrl+C to stop.
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.

View File

@@ -0,0 +1,87 @@
# graphify reference: extra exports and benchmark
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--falkordb`, `--falkordb-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
### Step 6b - Wiki (only if --wiki flag)
**Only run this step if `--wiki` was explicitly given in the original command.**
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
```bash
graphify export wiki
```
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
**If `--neo4j`** - generate a Cypher file for manual import:
```bash
graphify export neo4j
```
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
```bash
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
```
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7a - FalkorDB export (only if --falkordb or --falkordb-push flag)
**If `--falkordb`** - generate a Cypher file. The statements are OpenCypher, but FalkorDB's `GRAPH.QUERY` runs one statement at a time (no bulk script import like Neo4j's `cypher-shell`), so prefer `--falkordb-push` to load a graph. Use this only when you want the portable `cypher.txt` artifact:
```bash
graphify export falkordb
```
**If `--falkordb-push <uri>`** - push directly to a running FalkorDB instance. Credentials are optional; ask the user only if the instance requires auth:
```bash
graphify export falkordb --push falkordb://localhost:6379
```
Default URI is `falkordb://localhost:6379` (the scheme is informational - `redis://` or a bare `host:port` work too), auth is optional, and the target graph defaults to `graphify`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7b - SVG export (only if --svg flag)
```bash
graphify export svg
```
### Step 7c - GraphML export (only if --graphml flag)
```bash
graphify export graphml
```
### Step 7d - MCP server (only if --mcp flag)
```bash
$(cat graphify-out/.graphify_python) -m graphify.serve graphify-out/graph.json
```
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
To configure in Claude Desktop, add to `claude_desktop_config.json`. Claude Desktop can't run `$(...)`, and under `uv tool install` the system `python3` can't import graphify — so set `command` to the **absolute interpreter path** printed by `cat graphify-out/.graphify_python`:
```json
{
"mcpServers": {
"graphify": {
"command": "<absolute path from: cat graphify-out/.graphify_python>",
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
}
}
}
```
### Step 8 - Token reduction benchmark (only if total_words > 5000)
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
```bash
graphify benchmark
```
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.

View File

@@ -0,0 +1,31 @@
# graphify reference: extraction subagent prompt (compact)
Load this in Step 3 Part B when the corpus has at least one doc, paper, or image chunk. A pure-code corpus skips Part B and never reads this file. Each semantic subagent receives the prompt below verbatim (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE).
```
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
FILE_LIST
Rules:
- EXTRACTED: relationship explicit in source (import, call, citation)
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
- Semantic similarity: if two concepts solve the same problem or represent the same idea without a structural link (no import, call, or citation), add a `semantically_similar_to` edge marked INFERRED with confidence_score 0.6-0.95. Non-obvious cross-file links only.
- Hyperedges: if 3+ nodes share a concept, flow, or pattern not captured by pairwise edges, add a hyperedge to a top-level `hyperedges` array. Use sparingly. Max 3 per chunk.
- If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file.
- confidence_score is REQUIRED on every edge — never omit it, never use 0.5 as a default. EXTRACTED = 1.0 always. INFERRED: pick exactly ONE of 0.95 (direct structural evidence), 0.85 (strong inference), 0.75 (reasonable inference), 0.65 (weak inference), 0.55 (speculative but plausible) — never 0.5; if none fit, mark the edge AMBIGUOUS. AMBIGUOUS = 0.1-0.3.
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"<FILE_LIST path verbatim>","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"<FILE_LIST path verbatim>","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"<FILE_LIST path verbatim>"}],"input_tokens":0,"output_tokens":0}
source_file RULE: set source_file to the FILE_LIST path for that file VERBATIM (absolute, no shortening to basename, no re-relativizing, no separator change). Keeps full build and --update on one base so build_merge's replace matches instead of duplicating.
```

View File

@@ -0,0 +1,46 @@
# graphify reference: GitHub clone and cross-repo merge
Load this when the user passed one or more `https://github.com/...` URLs, or named several local subfolders to merge into one graph.
### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
**Single repo:**
```bash
LOCAL_PATH=$(graphify clone <github-url> [--branch <branch>])
# Use LOCAL_PATH as the target for all subsequent steps
```
**Multiple repos (cross-repo graph):**
```bash
# Clone each repo, run the full pipeline on each, then merge
graphify clone <url1> # → ~/.graphify/repos/<owner1>/<repo1>
graphify clone <url2> # → ~/.graphify/repos/<owner2>/<repo2>
# Run /graphify on each local path to produce their graph.json files
# Then merge:
graphify merge-graphs \
~/.graphify/repos/<owner1>/<repo1>/graphify-out/graph.json \
~/.graphify/repos/<owner2>/<repo2>/graphify-out/graph.json \
--out graphify-out/cross-repo-graph.json
```
Graphify clones into `~/.graphify/repos/<owner>/<repo>` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
**Multiple local subfolders (monorepo or multi-service layout):**
The skill pipeline writes all intermediate and final outputs to `graphify-out/` in the current working directory. Running the skill on each subfolder separately will clobber the same output dir. Instead, use the CLI directly for each subfolder — it places `graphify-out/` *inside* the scanned path:
```bash
graphify extract ./core/ # → ./core/graphify-out/graph.json
graphify extract ./service/ # → ./service/graphify-out/graph.json
graphify extract ./platform/ # → ./platform/graphify-out/graph.json
# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set
# Then merge at the project root:
graphify merge-graphs \
./core/graphify-out/graph.json \
./service/graphify-out/graph.json \
./platform/graphify-out/graph.json \
--out graphify-out/graph.json
```
Once `graphify-out/graph.json` exists, the fast path above takes over: any codebase question runs `graphify query` directly on the merged graph — no re-extraction, no size gate.

View File

@@ -0,0 +1,33 @@
# graphify reference: commit hook and native CLAUDE.md integration
Load this when the user asked to install the post-commit hook or wire graphify into a project's CLAUDE.md.
## For git commit hook
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
```bash
graphify hook install # install
graphify hook uninstall # remove
graphify hook status # check
```
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
If a post-commit hook already exists, graphify appends to it rather than replacing it.
---
## For native CLAUDE.md integration
Run once per project to make graphify always-on in Claude Code sessions:
```bash
graphify claude install
```
This writes a `## graphify` section to the local `CLAUDE.md` that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual `/graphify` needed in future sessions.
```bash
graphify claude uninstall # remove the section
```

View File

@@ -0,0 +1,311 @@
# graphify reference: query, path, explain
Load this when the user asks a question against an existing graph, or runs `/graphify path` or `/graphify explain`. The core's query stub points here for the full traversal flow. These flows use the `graphify query` CLI when it is available and fall back to an inline NetworkX traversal otherwise.
Two traversal modes - choose based on the question:
| Mode | Flag | Best for |
|------|------|----------|
| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first |
| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path |
First check the graph exists:
```bash
$(cat graphify-out/.graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
raise SystemExit(1)
"
```
If it fails, stop and tell the user to run `/graphify <path>` first.
### Step 0 — Constrained query expansion (REQUIRED before traversal)
graphify's `query` CLI matches nodes via case-folded substring + IDF — there is **no stemming, no synonyms, no cross-language match** inside the binary, and the inline fallback below matches the same way. If the user's question uses different language or different domain vocabulary than the graph's labels (user says "обработчик" / graph says "handler"; user says "authentication" / graph says "Guardian"), the literal matcher returns 0 hits and the answer collapses to noise.
Fix this **without inventing tokens** by expanding the query against the actual graph vocabulary first:
1. Extract the token vocabulary from node labels:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, re
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
vocab = set()
for n in data['nodes']:
for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
for p in parts:
t = p.lower()
if 3 <= len(t) <= 30:
vocab.add(t)
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)))
print(f'vocab: {len(vocab)} tokens')
"
```
2. Read `graphify-out/.vocab.txt`. Then for the user's question, select **up to 12 tokens from this exact list** that semantically match the query intent. Hard constraints:
- You MUST pick only tokens present in the vocabulary file. Do NOT invent tokens.
- If a query concept has no plausible token in the vocab, skip it — do not substitute a near-synonym from training memory.
- If **no** vocab tokens match the query at all, output an empty list and tell the user the corpus has no relevant vocabulary for this question. Do not fabricate a search.
- Translate cross-language: Russian "аутентификация" → look for `auth`, `credential`, `token`, `security` IFF present in vocab.
- Morphology: "handlers" maps to `handler` IFF present; "todos" maps to `todo` IFF present.
3. Print the selection explicitly to the user before running the query, so the expansion is auditable:
```
Query expanded to (from graph vocab, N tokens): [token1, token2, ...]
```
If the list is empty, say so plainly and stop — do not proceed to traversal.
### Step 1 — Traversal
Build the **expanded query string** by joining the selected tokens with spaces. Use this string as `QUESTION` below — NOT the original user question. (The original question is preserved only for `save-result` at the end.)
Prefer the CLI when it is installed:
```bash
graphify query "QUESTION"
# or: graphify query "QUESTION" --dfs --budget 3000
```
If the CLI is unavailable, load `graphify-out/graph.json` and run the traversal inline:
1. Find the 1-3 nodes whose label best matches the expanded tokens.
2. Run the appropriate traversal from each starting node.
3. Read the subgraph - node labels, edge relations, confidence tags, source locations.
4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact.
5. If the graph lacks enough information, say so - do not hallucinate edges.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
question = 'QUESTION'
mode = 'MODE' # 'bfs' or 'dfs'
terms = [t.lower() for t in question.split() if len(t) >= 3] # match the vocab threshold; keeps api/jwt/ios (#1392)
# Find best-matching start nodes
scored = []
for nid, ndata in G.nodes(data=True):
label = ndata.get('label', '').lower()
score = sum(1 for t in terms if t in label)
if score > 0:
scored.append((score, nid))
scored.sort(reverse=True)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
print('No matching nodes found for query terms:', terms)
sys.exit(0)
subgraph_nodes = set()
subgraph_edges = []
if mode == 'dfs':
# DFS: follow one path as deep as possible before backtracking.
# Depth-limited to 6 to avoid traversing the whole graph.
visited = set()
stack = [(n, 0) for n in reversed(start_nodes)]
while stack:
node, depth = stack.pop()
if node in visited or depth > 6:
continue
visited.add(node)
subgraph_nodes.add(node)
for neighbor in G.neighbors(node):
if neighbor not in visited:
stack.append((neighbor, depth + 1))
subgraph_edges.append((node, neighbor))
else:
# BFS: explore all neighbors layer by layer up to depth 3.
frontier = set(start_nodes)
subgraph_nodes = set(start_nodes)
for _ in range(3):
next_frontier = set()
for n in frontier:
for neighbor in G.neighbors(n):
if neighbor not in subgraph_nodes:
next_frontier.add(neighbor)
subgraph_edges.append((n, neighbor))
subgraph_nodes.update(next_frontier)
frontier = next_frontier
# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
token_budget = BUDGET # default 2000
char_budget = token_budget * 4
# Score each node by term overlap for ranked output
def relevance(nid):
label = G.nodes[nid].get('label', '').lower()
return sum(1 for t in terms if t in label)
ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)
lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
for nid in ranked_nodes:
d = G.nodes[nid]
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
for u, v in subgraph_edges:
if u in subgraph_nodes and v in subgraph_nodes:
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
output = '\n'.join(lines)
if len(output) > char_budget:
output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
print(output)
"
```
Replace `QUESTION` with the **expanded** query string, `MODE` with `bfs` or `dfs`, and `BUDGET` with the token budget (default `2000`, or whatever `--budget N` specifies). Then answer based on the subgraph output above, using only what the graph contains.
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
```
Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and `--correction "the right answer"` when correcting):
- `useful` — the cited nodes answered the question well (they become *preferred sources*).
- `dead_end` — the question/path led nowhere; don't re-derive it next time.
- `corrected` — the saved answer was wrong; `--correction` records what was right.
At the **start** of graph work, refresh and read the lessons: run `graphify reflect --if-stale` (cheap, deterministic, no LLM; `--if-stale` makes it a no-op when `LESSONS.md` is already newer than every input, e.g. when the git hook just refreshed it), then read `graphify-out/reflections/LESSONS.md`. It lists **preferred sources** (start there), **known dead ends** (skip them), and prior **corrections**. Running `reflect` yourself keeps the lessons current even without the git hook installed; if the post-commit hook *is* installed, `--if-stale` means your session-start run costs almost nothing.
---
## For /graphify path
Find the shortest path between two named concepts in the graph. Prefer the CLI when installed:
```bash
graphify path "NODE_A" "NODE_B"
```
If the CLI is unavailable, run it inline:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
a_term = 'NODE_A'
b_term = 'NODE_B'
def find_node(term):
term = term.lower()
scored = sorted(
[(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
return scored[0][1] if scored and scored[0][0] > 0 else None
src = find_node(a_term)
tgt = find_node(b_term)
if not src or not tgt:
print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
sys.exit(0)
try:
path = nx.shortest_path(G, src, tgt)
print(f'Shortest path ({len(path)-1} hops):')
for i, nid in enumerate(path):
label = G.nodes[nid].get('label', nid)
if i < len(path) - 1:
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
print(f' {label} --{rel}--> [{conf}]')
else:
print(f' {label}')
except nx.NetworkXNoPath:
print(f'No path found between {a_term!r} and {b_term!r}')
except nx.NodeNotFound as e:
print(f'Node not found: {e}')
"
```
Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.
After writing the explanation, save it back:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
```
---
## For /graphify explain
Give a plain-language explanation of a single node - everything connected to it. Prefer the CLI when installed:
```bash
graphify explain "NODE_NAME"
```
If the CLI is unavailable, run it inline:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
term = 'NODE_NAME'
term_lower = term.lower()
# Find best matching node
scored = sorted(
[(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
if not scored or scored[0][0] == 0:
print(f'No node matching {term!r}')
sys.exit(0)
nid = scored[0][1]
data_n = G.nodes[nid]
print(f'NODE: {data_n.get(\"label\", nid)}')
print(f' source: {data_n.get(\"source_file\",\"unknown\")}')
print(f' type: {data_n.get(\"file_type\",\"unknown\")}')
print(f' degree: {G.degree(nid)}')
print()
print('CONNECTIONS:')
for neighbor in G.neighbors(nid):
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
nlabel = G.nodes[neighbor].get('label', neighbor)
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
src_file = G.nodes[neighbor].get('source_file', '')
print(f' --{rel}--> {nlabel} [{conf}] ({src_file})')
"
```
Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
After writing the explanation, save it back:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
```

View File

@@ -0,0 +1,52 @@
# graphify reference: transcribe video and audio
Load this only when `detect` reported one or more `video` files. A corpus with no video never reads this.
### Step 2.5 - Transcribe video / audio files (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files.
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
**Strategy:** Read the god nodes from `graphify-out/.graphify_detect.json` (or the analysis file if it exists from a previous run). You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
**Step 1 - Write the Whisper prompt yourself.**
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
- Labels: `transformer, attention, encoder, decoder``"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
- Labels: `kubernetes, deployment, pod, helm``"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
**Export** it as `GRAPHIFY_WHISPER_PROMPT` (the exact name the transcriber reads — and it must be `export`ed so the child Python process sees it) for the next command.
**Step 2 - Transcribe:**
```bash
export GRAPHIFY_WHISPER_MODEL=base # or whatever --whisper-model the user passed (must be exported)
export GRAPHIFY_WHISPER_PROMPT="<the one-sentence domain hint you composed in Step 1>"
$(cat graphify-out/.graphify_python) -c "
import json, os, sys
from pathlib import Path
from graphify.transcribe import transcribe_all
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
video_files = detect.get('files', {}).get('video', [])
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
# Write the JSON from Python (NOT a shell '>' redirect): transcribe_all/Whisper
# print progress to stdout, which would otherwise corrupt the JSON file (#1392).
Path('graphify-out/.graphify_transcripts.json').write_text(json.dumps(transcript_paths, ensure_ascii=False), encoding=\"utf-8\")
print(f'Transcribed {len(transcript_paths)} file(s)', file=sys.stderr)
"
```
After transcription:
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
- Add them to the docs list before dispatching semantic subagents in Step 3B
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
- If transcription fails for a file, print a warning and continue with the rest
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, `export GRAPHIFY_WHISPER_MODEL=<name>` (it must be exported, not just assigned) before running the command above.

View File

@@ -0,0 +1,192 @@
# graphify reference: incremental update and cluster-only
Load this only when the user passed `--update` or `--cluster-only`. A first-time full build never reads this file.
## For --update (incremental re-extraction)
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path
result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2, ensure_ascii=False))
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
deleted = list(result.get('deleted_files', []))
if new_total == 0 and not deleted:
print('No files changed since last run. Nothing to update.')
raise SystemExit(0)
if deleted:
print(f'{len(deleted)} deleted file(s) to prune.')
if new_total > 0:
print(f'{new_total} new/changed file(s) to re-extract.')
"
```
Then populate `.graphify_detect.json` so Steps 3A6 (which read it unconditionally) see the right state for an incremental run. `files` carries the changed subset (drives Step 3A AST + Step 3B0 cache check on only what changed); `all_files` carries the full corpus for any step that needs corpus-wide context:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
'files': r.get('new_files', {}),
'all_files': r.get('files', {}),
'total_files': r.get('new_total', 0),
'total_words': r.get('total_words', 0),
'skipped_sensitive': r.get('skipped_sensitive', []),
'needs_graph': True,
}, ensure_ascii=False), encoding=\"utf-8\")
"
```
If new files exist, first check whether all changed files are code files:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"
```
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 48.
If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A3C pipeline as normal.
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
```bash
if [ ! -f graphify-out/.graphify_extract.json ]; then
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
fi
```
Then:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.build import build_merge
from graphify.detect import save_manifest
# Load new extraction and incremental state
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# prune_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
# Do NOT add `changed` here: with root= passed, prune_set relativizes to the same base
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
# now that replace — not the dedup pass — reconciles changed files).
prune = list(deleted) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
# Pass root= so prune_sources (absolute paths from detect_incremental) are
# relativized to match the graph's relative source_file values; without it
# nothing is pruned and stale nodes accumulate on every update (#1361).
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
# False. Without it a --directed --update silently rebuilds undirected and collapses
# reciprocal A<->B edges (#1392).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=prune,
root='INPUT_PATH',
directed=IS_DIRECTED,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
# Explicit source/target last so they win over any stale attrs in d.
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
for u, v, d in G.edges(data=True)
],
# G.graph["hyperedges"] holds hyperedges from both existing graph.json
# and new_extraction (build_merge combines them). Falling back to
# new_extraction only would silently drop prior-run hyperedges (#801).
'hyperedges': list(G.graph.get('hyperedges', [])),
'input_tokens': new_extraction.get('input_tokens', 0),
'output_tokens': new_extraction.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
# Save manifest so next --update diffs against today's state, not the
# prior run's baseline (prevents ghost-node reports on subsequent updates).
# root= matches the build_merge call above so the manifest keys stay relative to
# the scan root — portable across clones/machines, so --update keeps matching
# cached files instead of missing every one after a move (#1417).
save_manifest(incremental['files'], root='INPUT_PATH')
print('[graphify update] Manifest saved.')
"
```
Then run Steps 48 on the merged graph as normal.
After Step 4, show the graph diff:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load old graph (before update) from backup written before merge
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
G_new = build_from_json(new_extract, directed=IS_DIRECTED)
if old_data:
G_old = json_graph.node_link_graph(old_data, edges='links')
diff = graph_diff(G_old, G_new)
print(diff['summary'])
if diff['new_nodes']:
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
if diff['new_edges']:
print('New edges:', len(diff['new_edges']))
"
```
Before the merge step, save the old graph: `cp graphify-out/graph.json graphify-out/.graphify_old.json`
Clean up after: `rm -f graphify-out/.graphify_old.json`
---
## For --cluster-only
Skip Steps 13. Re-run clustering on the existing graph:
```bash
graphify cluster-only .
```
`graphify cluster-only .` is **self-contained**: it re-clusters, names communities, and regenerates `GRAPH_REPORT.md`, `graph.json`, and `graph.html` from the existing graph. **Do not re-run Steps 59** — they read intermediate files (`.graphify_extract.json`, `.graphify_detect.json`, `.graphify_analysis.json`) that a prior build's cleanup (Step 9) already deleted, so they raise `FileNotFoundError` (#1392). When it finishes, present the refreshed `GRAPH_REPORT.md` summary as usual.

21
.codex/config.toml Normal file
View File

@@ -0,0 +1,21 @@
#:schema https://developers.openai.com/codex/config-schema.json
web_search = "live"
developer_instructions = """
Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
"""
[skills]
include_instructions = true
[[skills.config]]
path = "../.agents/skills/auto-update-docs"
enabled = true
[[skills.config]]
path = "../.agents/skills/frontend-design"
enabled = true

View File

@@ -0,0 +1 @@
0.9.5

674
.codex/graphify/SKILL.md Normal file
View File

@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory → Obsidian vault
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### Step 3 - Extract entities and relationships
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
Call `spawn_agent` once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt in task-delegation framing:
```
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
After all agents are dispatched, collect results sequentially in memory:
```
result = wait_agent(handle); close_agent(handle) # repeat per handle
```
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. Codex collects in memory, so there are no per-chunk files on disk; the disk-based success checks in Step B3 do not apply — a chunk that returns invalid JSON is the failure signal instead.
Subagent prompt template:
See `references/extraction-spec.md` for the compact subagent prompt (rules, node-ID format, confidence rubric, hyperedge and vision rules, JSON schema). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and have it return the JSON inline.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.

View File

@@ -0,0 +1,56 @@
# graphify reference: add a URL and watch a folder
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
## For /graphify add
Fetch a URL and add it to the corpus, then update the graph.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path
try:
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
print(f'Saved to {out}')
except ValueError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
"
```
Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
Supported URL types (auto-detected):
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
- arXiv → abstract + metadata saved as `.md`
- PDF → downloaded as `.pdf`
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
- Any webpage → converted to markdown via html2text
---
## For --watch
Start a background watcher that monitors a folder and auto-updates the graph when files change.
```bash
$(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3
```
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
Press Ctrl+C to stop.
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.

View File

@@ -0,0 +1,87 @@
# graphify reference: extra exports and benchmark
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--falkordb`, `--falkordb-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
### Step 6b - Wiki (only if --wiki flag)
**Only run this step if `--wiki` was explicitly given in the original command.**
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
```bash
graphify export wiki
```
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
**If `--neo4j`** - generate a Cypher file for manual import:
```bash
graphify export neo4j
```
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
```bash
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
```
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7a - FalkorDB export (only if --falkordb or --falkordb-push flag)
**If `--falkordb`** - generate a Cypher file. The statements are OpenCypher, but FalkorDB's `GRAPH.QUERY` runs one statement at a time (no bulk script import like Neo4j's `cypher-shell`), so prefer `--falkordb-push` to load a graph. Use this only when you want the portable `cypher.txt` artifact:
```bash
graphify export falkordb
```
**If `--falkordb-push <uri>`** - push directly to a running FalkorDB instance. Credentials are optional; ask the user only if the instance requires auth:
```bash
graphify export falkordb --push falkordb://localhost:6379
```
Default URI is `falkordb://localhost:6379` (the scheme is informational - `redis://` or a bare `host:port` work too), auth is optional, and the target graph defaults to `graphify`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7b - SVG export (only if --svg flag)
```bash
graphify export svg
```
### Step 7c - GraphML export (only if --graphml flag)
```bash
graphify export graphml
```
### Step 7d - MCP server (only if --mcp flag)
```bash
$(cat graphify-out/.graphify_python) -m graphify.serve graphify-out/graph.json
```
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
To configure in Claude Desktop, add to `claude_desktop_config.json`. Claude Desktop can't run `$(...)`, and under `uv tool install` the system `python3` can't import graphify — so set `command` to the **absolute interpreter path** printed by `cat graphify-out/.graphify_python`:
```json
{
"mcpServers": {
"graphify": {
"command": "<absolute path from: cat graphify-out/.graphify_python>",
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
}
}
}
```
### Step 8 - Token reduction benchmark (only if total_words > 5000)
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
```bash
graphify benchmark
```
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.

View File

@@ -0,0 +1,31 @@
# graphify reference: extraction subagent prompt (compact)
Load this in Step 3 Part B when the corpus has at least one doc, paper, or image chunk. A pure-code corpus skips Part B and never reads this file. Each semantic subagent receives the prompt below verbatim (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE).
```
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
FILE_LIST
Rules:
- EXTRACTED: relationship explicit in source (import, call, citation)
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
- Semantic similarity: if two concepts solve the same problem or represent the same idea without a structural link (no import, call, or citation), add a `semantically_similar_to` edge marked INFERRED with confidence_score 0.6-0.95. Non-obvious cross-file links only.
- Hyperedges: if 3+ nodes share a concept, flow, or pattern not captured by pairwise edges, add a hyperedge to a top-level `hyperedges` array. Use sparingly. Max 3 per chunk.
- If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file.
- confidence_score is REQUIRED on every edge — never omit it, never use 0.5 as a default. EXTRACTED = 1.0 always. INFERRED: pick exactly ONE of 0.95 (direct structural evidence), 0.85 (strong inference), 0.75 (reasonable inference), 0.65 (weak inference), 0.55 (speculative but plausible) — never 0.5; if none fit, mark the edge AMBIGUOUS. AMBIGUOUS = 0.1-0.3.
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"<FILE_LIST path verbatim>","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"<FILE_LIST path verbatim>","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"<FILE_LIST path verbatim>"}],"input_tokens":0,"output_tokens":0}
source_file RULE: set source_file to the FILE_LIST path for that file VERBATIM (absolute, no shortening to basename, no re-relativizing, no separator change). Keeps full build and --update on one base so build_merge's replace matches instead of duplicating.
```

View File

@@ -0,0 +1,46 @@
# graphify reference: GitHub clone and cross-repo merge
Load this when the user passed one or more `https://github.com/...` URLs, or named several local subfolders to merge into one graph.
### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
**Single repo:**
```bash
LOCAL_PATH=$(graphify clone <github-url> [--branch <branch>])
# Use LOCAL_PATH as the target for all subsequent steps
```
**Multiple repos (cross-repo graph):**
```bash
# Clone each repo, run the full pipeline on each, then merge
graphify clone <url1> # → ~/.graphify/repos/<owner1>/<repo1>
graphify clone <url2> # → ~/.graphify/repos/<owner2>/<repo2>
# Run /graphify on each local path to produce their graph.json files
# Then merge:
graphify merge-graphs \
~/.graphify/repos/<owner1>/<repo1>/graphify-out/graph.json \
~/.graphify/repos/<owner2>/<repo2>/graphify-out/graph.json \
--out graphify-out/cross-repo-graph.json
```
Graphify clones into `~/.graphify/repos/<owner>/<repo>` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
**Multiple local subfolders (monorepo or multi-service layout):**
The skill pipeline writes all intermediate and final outputs to `graphify-out/` in the current working directory. Running the skill on each subfolder separately will clobber the same output dir. Instead, use the CLI directly for each subfolder — it places `graphify-out/` *inside* the scanned path:
```bash
graphify extract ./core/ # → ./core/graphify-out/graph.json
graphify extract ./service/ # → ./service/graphify-out/graph.json
graphify extract ./platform/ # → ./platform/graphify-out/graph.json
# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set
# Then merge at the project root:
graphify merge-graphs \
./core/graphify-out/graph.json \
./service/graphify-out/graph.json \
./platform/graphify-out/graph.json \
--out graphify-out/graph.json
```
Once `graphify-out/graph.json` exists, the fast path above takes over: any codebase question runs `graphify query` directly on the merged graph — no re-extraction, no size gate.

View File

@@ -0,0 +1,33 @@
# graphify reference: commit hook and native CLAUDE.md integration
Load this when the user asked to install the post-commit hook or wire graphify into a project's CLAUDE.md.
## For git commit hook
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
```bash
graphify hook install # install
graphify hook uninstall # remove
graphify hook status # check
```
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
If a post-commit hook already exists, graphify appends to it rather than replacing it.
---
## For native CLAUDE.md integration
Run once per project to make graphify always-on in Claude Code sessions:
```bash
graphify claude install
```
This writes a `## graphify` section to the local `CLAUDE.md` that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual `/graphify` needed in future sessions.
```bash
graphify claude uninstall # remove the section
```

View File

@@ -0,0 +1,311 @@
# graphify reference: query, path, explain
Load this when the user asks a question against an existing graph, or runs `/graphify path` or `/graphify explain`. The core's query stub points here for the full traversal flow. These flows use the `graphify query` CLI when it is available and fall back to an inline NetworkX traversal otherwise.
Two traversal modes - choose based on the question:
| Mode | Flag | Best for |
|------|------|----------|
| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first |
| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path |
First check the graph exists:
```bash
$(cat graphify-out/.graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
raise SystemExit(1)
"
```
If it fails, stop and tell the user to run `/graphify <path>` first.
### Step 0 — Constrained query expansion (REQUIRED before traversal)
graphify's `query` CLI matches nodes via case-folded substring + IDF — there is **no stemming, no synonyms, no cross-language match** inside the binary, and the inline fallback below matches the same way. If the user's question uses different language or different domain vocabulary than the graph's labels (user says "обработчик" / graph says "handler"; user says "authentication" / graph says "Guardian"), the literal matcher returns 0 hits and the answer collapses to noise.
Fix this **without inventing tokens** by expanding the query against the actual graph vocabulary first:
1. Extract the token vocabulary from node labels:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, re
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
vocab = set()
for n in data['nodes']:
for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
for p in parts:
t = p.lower()
if 3 <= len(t) <= 30:
vocab.add(t)
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)))
print(f'vocab: {len(vocab)} tokens')
"
```
2. Read `graphify-out/.vocab.txt`. Then for the user's question, select **up to 12 tokens from this exact list** that semantically match the query intent. Hard constraints:
- You MUST pick only tokens present in the vocabulary file. Do NOT invent tokens.
- If a query concept has no plausible token in the vocab, skip it — do not substitute a near-synonym from training memory.
- If **no** vocab tokens match the query at all, output an empty list and tell the user the corpus has no relevant vocabulary for this question. Do not fabricate a search.
- Translate cross-language: Russian "аутентификация" → look for `auth`, `credential`, `token`, `security` IFF present in vocab.
- Morphology: "handlers" maps to `handler` IFF present; "todos" maps to `todo` IFF present.
3. Print the selection explicitly to the user before running the query, so the expansion is auditable:
```
Query expanded to (from graph vocab, N tokens): [token1, token2, ...]
```
If the list is empty, say so plainly and stop — do not proceed to traversal.
### Step 1 — Traversal
Build the **expanded query string** by joining the selected tokens with spaces. Use this string as `QUESTION` below — NOT the original user question. (The original question is preserved only for `save-result` at the end.)
Prefer the CLI when it is installed:
```bash
graphify query "QUESTION"
# or: graphify query "QUESTION" --dfs --budget 3000
```
If the CLI is unavailable, load `graphify-out/graph.json` and run the traversal inline:
1. Find the 1-3 nodes whose label best matches the expanded tokens.
2. Run the appropriate traversal from each starting node.
3. Read the subgraph - node labels, edge relations, confidence tags, source locations.
4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact.
5. If the graph lacks enough information, say so - do not hallucinate edges.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
question = 'QUESTION'
mode = 'MODE' # 'bfs' or 'dfs'
terms = [t.lower() for t in question.split() if len(t) >= 3] # match the vocab threshold; keeps api/jwt/ios (#1392)
# Find best-matching start nodes
scored = []
for nid, ndata in G.nodes(data=True):
label = ndata.get('label', '').lower()
score = sum(1 for t in terms if t in label)
if score > 0:
scored.append((score, nid))
scored.sort(reverse=True)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
print('No matching nodes found for query terms:', terms)
sys.exit(0)
subgraph_nodes = set()
subgraph_edges = []
if mode == 'dfs':
# DFS: follow one path as deep as possible before backtracking.
# Depth-limited to 6 to avoid traversing the whole graph.
visited = set()
stack = [(n, 0) for n in reversed(start_nodes)]
while stack:
node, depth = stack.pop()
if node in visited or depth > 6:
continue
visited.add(node)
subgraph_nodes.add(node)
for neighbor in G.neighbors(node):
if neighbor not in visited:
stack.append((neighbor, depth + 1))
subgraph_edges.append((node, neighbor))
else:
# BFS: explore all neighbors layer by layer up to depth 3.
frontier = set(start_nodes)
subgraph_nodes = set(start_nodes)
for _ in range(3):
next_frontier = set()
for n in frontier:
for neighbor in G.neighbors(n):
if neighbor not in subgraph_nodes:
next_frontier.add(neighbor)
subgraph_edges.append((n, neighbor))
subgraph_nodes.update(next_frontier)
frontier = next_frontier
# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
token_budget = BUDGET # default 2000
char_budget = token_budget * 4
# Score each node by term overlap for ranked output
def relevance(nid):
label = G.nodes[nid].get('label', '').lower()
return sum(1 for t in terms if t in label)
ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)
lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
for nid in ranked_nodes:
d = G.nodes[nid]
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
for u, v in subgraph_edges:
if u in subgraph_nodes and v in subgraph_nodes:
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
output = '\n'.join(lines)
if len(output) > char_budget:
output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
print(output)
"
```
Replace `QUESTION` with the **expanded** query string, `MODE` with `bfs` or `dfs`, and `BUDGET` with the token budget (default `2000`, or whatever `--budget N` specifies). Then answer based on the subgraph output above, using only what the graph contains.
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
```
Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
**Work memory (self-improving loop).** Add an `--outcome` so future sessions learn from this one — append `--outcome useful|dead_end|corrected` to the `save-result` command (and `--correction "the right answer"` when correcting):
- `useful` — the cited nodes answered the question well (they become *preferred sources*).
- `dead_end` — the question/path led nowhere; don't re-derive it next time.
- `corrected` — the saved answer was wrong; `--correction` records what was right.
At the **start** of graph work, refresh and read the lessons: run `graphify reflect --if-stale` (cheap, deterministic, no LLM; `--if-stale` makes it a no-op when `LESSONS.md` is already newer than every input, e.g. when the git hook just refreshed it), then read `graphify-out/reflections/LESSONS.md`. It lists **preferred sources** (start there), **known dead ends** (skip them), and prior **corrections**. Running `reflect` yourself keeps the lessons current even without the git hook installed; if the post-commit hook *is* installed, `--if-stale` means your session-start run costs almost nothing.
---
## For /graphify path
Find the shortest path between two named concepts in the graph. Prefer the CLI when installed:
```bash
graphify path "NODE_A" "NODE_B"
```
If the CLI is unavailable, run it inline:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
a_term = 'NODE_A'
b_term = 'NODE_B'
def find_node(term):
term = term.lower()
scored = sorted(
[(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
return scored[0][1] if scored and scored[0][0] > 0 else None
src = find_node(a_term)
tgt = find_node(b_term)
if not src or not tgt:
print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
sys.exit(0)
try:
path = nx.shortest_path(G, src, tgt)
print(f'Shortest path ({len(path)-1} hops):')
for i, nid in enumerate(path):
label = G.nodes[nid].get('label', nid)
if i < len(path) - 1:
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
print(f' {label} --{rel}--> [{conf}]')
else:
print(f' {label}')
except nx.NetworkXNoPath:
print(f'No path found between {a_term!r} and {b_term!r}')
except nx.NodeNotFound as e:
print(f'Node not found: {e}')
"
```
Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.
After writing the explanation, save it back:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
```
---
## For /graphify explain
Give a plain-language explanation of a single node - everything connected to it. Prefer the CLI when installed:
```bash
graphify explain "NODE_NAME"
```
If the CLI is unavailable, run it inline:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
term = 'NODE_NAME'
term_lower = term.lower()
# Find best matching node
scored = sorted(
[(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
if not scored or scored[0][0] == 0:
print(f'No node matching {term!r}')
sys.exit(0)
nid = scored[0][1]
data_n = G.nodes[nid]
print(f'NODE: {data_n.get(\"label\", nid)}')
print(f' source: {data_n.get(\"source_file\",\"unknown\")}')
print(f' type: {data_n.get(\"file_type\",\"unknown\")}')
print(f' degree: {G.degree(nid)}')
print()
print('CONNECTIONS:')
for neighbor in G.neighbors(nid):
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
nlabel = G.nodes[neighbor].get('label', neighbor)
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
src_file = G.nodes[neighbor].get('source_file', '')
print(f' --{rel}--> {nlabel} [{conf}] ({src_file})')
"
```
Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
After writing the explanation, save it back:
```bash
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
```

View File

@@ -0,0 +1,52 @@
# graphify reference: transcribe video and audio
Load this only when `detect` reported one or more `video` files. A corpus with no video never reads this.
### Step 2.5 - Transcribe video / audio files (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files.
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
**Strategy:** Read the god nodes from `graphify-out/.graphify_detect.json` (or the analysis file if it exists from a previous run). You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
**Step 1 - Write the Whisper prompt yourself.**
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
- Labels: `transformer, attention, encoder, decoder``"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
- Labels: `kubernetes, deployment, pod, helm``"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
**Export** it as `GRAPHIFY_WHISPER_PROMPT` (the exact name the transcriber reads — and it must be `export`ed so the child Python process sees it) for the next command.
**Step 2 - Transcribe:**
```bash
export GRAPHIFY_WHISPER_MODEL=base # or whatever --whisper-model the user passed (must be exported)
export GRAPHIFY_WHISPER_PROMPT="<the one-sentence domain hint you composed in Step 1>"
$(cat graphify-out/.graphify_python) -c "
import json, os, sys
from pathlib import Path
from graphify.transcribe import transcribe_all
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
video_files = detect.get('files', {}).get('video', [])
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
# Write the JSON from Python (NOT a shell '>' redirect): transcribe_all/Whisper
# print progress to stdout, which would otherwise corrupt the JSON file (#1392).
Path('graphify-out/.graphify_transcripts.json').write_text(json.dumps(transcript_paths, ensure_ascii=False), encoding=\"utf-8\")
print(f'Transcribed {len(transcript_paths)} file(s)', file=sys.stderr)
"
```
After transcription:
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
- Add them to the docs list before dispatching semantic subagents in Step 3B
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
- If transcription fails for a file, print a warning and continue with the rest
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, `export GRAPHIFY_WHISPER_MODEL=<name>` (it must be exported, not just assigned) before running the command above.

View File

@@ -0,0 +1,192 @@
# graphify reference: incremental update and cluster-only
Load this only when the user passed `--update` or `--cluster-only`. A first-time full build never reads this file.
## For --update (incremental re-extraction)
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path
result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2, ensure_ascii=False))
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
deleted = list(result.get('deleted_files', []))
if new_total == 0 and not deleted:
print('No files changed since last run. Nothing to update.')
raise SystemExit(0)
if deleted:
print(f'{len(deleted)} deleted file(s) to prune.')
if new_total > 0:
print(f'{new_total} new/changed file(s) to re-extract.')
"
```
Then populate `.graphify_detect.json` so Steps 3A6 (which read it unconditionally) see the right state for an incremental run. `files` carries the changed subset (drives Step 3A AST + Step 3B0 cache check on only what changed); `all_files` carries the full corpus for any step that needs corpus-wide context:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
'files': r.get('new_files', {}),
'all_files': r.get('files', {}),
'total_files': r.get('new_total', 0),
'total_words': r.get('total_words', 0),
'skipped_sensitive': r.get('skipped_sensitive', []),
'needs_graph': True,
}, ensure_ascii=False), encoding=\"utf-8\")
"
```
If new files exist, first check whether all changed files are code files:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"
```
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 48.
If `code_only` is False (any changed file is a doc/paper/image/video): **first, if any changed file is in `new_files['video']`, run `references/transcribe.md` (Step 2.5) on those files, then rewrite `.graphify_detect.json` to move the resulting transcript paths into `files['document']` and drop `files['video']`** — otherwise raw `.mp4/.mp3` paths are fed to semantic subagents as unreadable media (#1392). Then run the full Steps 3A3C pipeline as normal.
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
```bash
if [ ! -f graphify-out/.graphify_extract.json ]; then
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
fi
```
Then:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.build import build_merge
from graphify.detect import save_manifest
# Load new extraction and incremental state
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
# prune_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
# Do NOT add `changed` here: with root= passed, prune_set relativizes to the same base
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
# now that replace — not the dedup pass — reconciles changed files).
prune = list(deleted) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
# Pass root= so prune_sources (absolute paths from detect_incremental) are
# relativized to match the graph's relative source_file values; without it
# nothing is pruned and stale nodes accumulate on every update (#1361).
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
# False. Without it a --directed --update silently rebuilds undirected and collapses
# reciprocal A<->B edges (#1392).
G = build_merge(
[new_extraction],
graph_path='graphify-out/graph.json',
prune_sources=prune,
root='INPUT_PATH',
directed=IS_DIRECTED,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
# Explicit source/target last so they win over any stale attrs in d.
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
for u, v, d in G.edges(data=True)
],
# G.graph["hyperedges"] holds hyperedges from both existing graph.json
# and new_extraction (build_merge combines them). Falling back to
# new_extraction only would silently drop prior-run hyperedges (#801).
'hyperedges': list(G.graph.get('hyperedges', [])),
'input_tokens': new_extraction.get('input_tokens', 0),
'output_tokens': new_extraction.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
# Save manifest so next --update diffs against today's state, not the
# prior run's baseline (prevents ghost-node reports on subsequent updates).
# root= matches the build_merge call above so the manifest keys stay relative to
# the scan root — portable across clones/machines, so --update keeps matching
# cached files instead of missing every one after a move (#1417).
save_manifest(incremental['files'], root='INPUT_PATH')
print('[graphify update] Manifest saved.')
"
```
Then run Steps 48 on the merged graph as normal.
After Step 4, show the graph diff:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load old graph (before update) from backup written before merge
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
G_new = build_from_json(new_extract, directed=IS_DIRECTED)
if old_data:
G_old = json_graph.node_link_graph(old_data, edges='links')
diff = graph_diff(G_old, G_new)
print(diff['summary'])
if diff['new_nodes']:
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
if diff['new_edges']:
print('New edges:', len(diff['new_edges']))
"
```
Before the merge step, save the old graph: `cp graphify-out/graph.json graphify-out/.graphify_old.json`
Clean up after: `rm -f graphify-out/.graphify_old.json`
---
## For --cluster-only
Skip Steps 13. Re-run clustering on the existing graph:
```bash
graphify cluster-only .
```
`graphify cluster-only .` is **self-contained**: it re-clusters, names communities, and regenerates `GRAPH_REPORT.md`, `graph.json`, and `graph.html` from the existing graph. **Do not re-run Steps 59** — they read intermediate files (`.graphify_extract.json`, `.graphify_detect.json`, `.graphify_analysis.json`) that a prior build's cleanup (Step 9) already deleted, so they raise `FileNotFoundError` (#1392). When it finishes, present the refreshed `GRAPH_REPORT.md` summary as usual.

View File

@@ -1,4 +1,4 @@
name: Build and Push Docker Images
name: Проверка, сборка и развёртывание Magistr
on:
push:
@@ -7,91 +7,201 @@ on:
tags:
- 'v*'
concurrency:
group: magistr-production
cancel-in-progress: false
env:
REGISTRY: gitea.zuev.company # Замените на реальный домен вашего Gitea
REGISTRY: gitea.zuev.company
BACKEND_IMAGE: zuev/magistr-backend
FRONTEND_IMAGE: zuev/magistr-frontend
MAVEN_IMAGE: maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc
NODE_IMAGE: node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e
TRIVY_IMAGE: aquasec/trivy:0.63.0@sha256:6fb0646988fcd2fdf7bf123f7174945ebc2c9c72d1fa1567c8d7daeeb70f8037
jobs:
checks:
name: Обязательные проверки
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Получить исходный код
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Backend unit и component tests
run: >-
docker run --rm
-v "${{ github.workspace }}/backend:/workspace"
-w /workspace
"${{ env.MAVEN_IMAGE }}"
mvn -Dapi.version=1.44 '-Dtest=!*IntegrationTest' test
- name: Frontend static и unit tests
run: >-
docker run --rm
-v "${{ github.workspace }}/frontend:/workspace"
-w /workspace
"${{ env.NODE_IMAGE }}"
sh -c "npm ci && npm run check"
- name: Проверить Docker Compose
env:
POSTGRES_PASSWORD: ci-config-only
JWT_SECRET: ci-only-secret-not-for-runtime-0123456789abcdef0123456789abcdef
run: docker compose config --quiet
- name: Проверить сценарий immutable rollout и rollback
run: bash scripts/test-deploy-images.sh
- name: Проверить закрепление артефактов и supply-chain gates
run: bash scripts/test-artifact-pinning.sh
build-and-push-backend:
name: Собрать backend image
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Получить исходный код
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }} # Нужно создать секрет ZUEV_TOKEN в настройках репозитория (Personal Access Token)
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
- name: Войти в Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Сформировать immutable tags и labels
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}
flavor: latest=false
tags: |
type=sha,format=long,prefix=sha-
type=ref,event=tag
- name: Build and push Docker image
uses: docker/build-push-action@v6
- name: Собрать и опубликовать backend image
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./frontend
context: ./backend
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
deploy-to-k8s:
build-and-push-frontend:
name: Собрать frontend image
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Получить исходный код
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Войти в Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }}
- name: Сформировать immutable tags и labels
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}
flavor: latest=false
tags: |
type=sha,format=long,prefix=sha-
type=ref,event=tag
- name: Собрать и опубликовать frontend image
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./frontend
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
security-scan:
name: Проверить опубликованные образы
needs: [build-and-push-backend, build-and-push-frontend]
runs-on: ubuntu-latest
timeout-minutes: 20
env:
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
steps:
- name: Create kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Войти в Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.ZUEV_TOKEN }}
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Проверить backend на HIGH и CRITICAL уязвимости
run: >-
docker run --rm
-v "$HOME/.docker:/root/.docker:ro"
"${{ env.TRIVY_IMAGE }}"
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
"$BACKEND_REF"
- name: Trigger Kubernetes Rollout
run: |
# Перезапускаем поды, чтобы они скачали свежий :main образ
kubectl rollout restart deployment backend frontend -n magistr
# Ждём успешного обновления (5 минут на backend из-за Spring Boot)
kubectl rollout status deployment/frontend -n magistr --timeout=120s
kubectl rollout status deployment/backend -n magistr --timeout=300s
- name: Проверить frontend на HIGH и CRITICAL уязвимости
run: >-
docker run --rm
-v "$HOME/.docker:/root/.docker:ro"
"${{ env.TRIVY_IMAGE }}"
image --scanners vuln --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
"$FRONTEND_REF"
deploy-to-k8s:
name: Развернуть проверенные digests
needs: [build-and-push-backend, build-and-push-frontend, security-scan]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
env:
BACKEND_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}@${{ needs.build-and-push-backend.outputs.digest }}
FRONTEND_REF: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}@${{ needs.build-and-push-frontend.outputs.digest }}
KUBECTL_VERSION: v1.33.12
KUBECTL_SHA256: fe80ae4133b44fa2077db4af144e80765eb1b3b2eede55fbff6933c4374d8c6e
steps:
- name: Получить deploy-скрипт
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Создать kubeconfig
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }}
run: |
install -d -m 700 "$HOME/.kube"
printf '%s' "$KUBECONFIG_DATA" | base64 --decode > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
- name: Установить проверенный kubectl
run: |
curl --fail --show-error --silent --location --retry 3 \
--output kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
echo "${KUBECTL_SHA256} kubectl" | sha256sum --check --strict
sudo install -m 0755 kubectl /usr/local/bin/kubectl
- name: Применить digests и проверить rollout
run: bash scripts/deploy-images.sh "$BACKEND_REF" "$FRONTEND_REF"

5
.gitignore vendored
View File

@@ -1,5 +1,6 @@
db/data/
.env
backend/tenants.json
# Игнорируем временные файлы сборки (на будущее)
backend/target/
@@ -7,9 +8,7 @@ backend/build/
frontend/node_modules/
frontend/dist/
.agents
.idea/
.vscode/
*.DS_Store
AGENTS.md
GEMINI.md
skills-lock.json

91
AGENTS.md Executable file
View File

@@ -0,0 +1,91 @@
# AGENTS.md - Руководство для агентных помощников
## Обзор проекта
Проект представляет собой систему управления университетским расписанием.
- **Backend**: Java 17, Spring Boot 3.2.5 (Мультитенантная архитектура: отдельная БД для каждого клиента)
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
- **Локальный URL**: localhost:80
- **Продакшн URL**: https://magistr.zuev.company
---
## Структура директорий
```
magistr/
├── backend/ # Java Spring Boot приложение
│ └── src/main/java/com/magistr/app/
│ ├── controller/ # REST контроллеры
│ ├── model/ # JPA сущности
│ ├── dto/ # Data Transfer Objects
│ ├── repository/ # Spring Data JPA репозитории
│ ├── config/ # Конфигурация приложения
│ ├── utils/ # Утилиты
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта
├── compose.yaml # Docker Compose конфигурация
└── .env # Переменные окружения
```
**Внешние зависимости (родительская директория)**
На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
---
## Быстрый справочник команд
```bash
# Сборка и запуск
docker compose up -d --build
# Полный сброс БД
docker compose down -v && docker compose up -d
# Логи конкретного сервиса
docker compose logs -f backend
```
Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md).
---
## Критические правила для агентов
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования
- **Все ответы и комментарии на русском языке**
- Сообщения об ошибках и логи на русском
- Пользовательский интерфейс на русском
---
## Подробная документация
Полная документация проекта находится в папке `docs/`:
| Документ | Содержание |
|----------|-----------|
| [`docs/README.md`](docs/README.md) | Обзор проекта, стек технологий, быстрый старт |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Архитектура системы, мультитенантность, аутентификация |
| [`docs/BUSINESS_LOGIC.md`](docs/BUSINESS_LOGIC.md) | Бизнес-логика, ролевая модель, правила расписания |
| [`docs/DATABASE.md`](docs/DATABASE.md) | Схема БД (ER-диаграмма), описание всех таблиц, Flyway |
| [`docs/API.md`](docs/API.md) | REST API эндпоинты с примерами запросов и ответов |
| [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта |
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность |
| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz |
| [`docs/UI_COMPONENTS.md`](docs/UI_COMPONENTS.md) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |

1067
BUG_FIX_PROGRESS.md Normal file

File diff suppressed because it is too large Load Diff

193
BUG_FIX_PROMPT.md Normal file
View File

@@ -0,0 +1,193 @@
# Промпт для исправления ошибок из `BUG_REPORT.md`
Ты работаешь как ведущий full-stack инженер, специалист по безопасности и DevOps в репозитории системы университетского расписания Magistr.
Твоя задача — не просто составить план, а последовательно реализовать, протестировать и документировать исправления всех проблем из корневого файла `BUG_REPORT.md`, **кроме проблемы № 2**.
## Обязательная область работ
Исправь проблемы **№ 1 и № 334**. Проблема **№ 2 полностью исключена из задачи**:
`[1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34]`
Это ровно 33 проблемы: одна критическая № 1, десять высокого приоритета № 312, девятнадцать среднего приоритета № 1331 и три низкого приоритета № 3234.
- не исправляй её;
- не изменяй ради неё демонстрационные данные или учётные записи;
- не добавляй миграцию для отключения demo-аккаунтов;
- не меняй существующие Flyway-миграции, включая `V1__init.sql` и `V2__subgroups_active_unique_name.sql`;
- не включай проблему № 2 в итоговый список выполненных исправлений.
Если изменение для другого пункта пересекается с проблемой № 2, реализуй только часть, необходимую для другого пункта, и не затрагивай seed/demo-данные.
## Проектный контекст и обязательные правила
1. Сначала полностью прочитай:
- корневой `AGENTS.md` — это главный проектный регламент;
- `BUG_REPORT.md`;
- относящиеся к изменениям документы из `docs/`;
- фактический код и тесты в затронутых подсистемах;
- production-манифесты в `../k8s/`, которые входят в область проекта согласно `AGENTS.md`.
2. При конфликте любых инструкций проекта с `AGENTS.md` следуй `AGENTS.md`.
3. Используй проектные навыки из `.agents/skills`, когда задача соответствует их описанию. Для навигации по проекту сначала используй существующий `graphify-out/graph.json`, если он актуален. После изменений в коде примени `AutoUpdateDocs`.
4. Соблюдай стек проекта: Java 17, Spring Boot 3.2.5, PostgreSQL/Flyway, Vanilla JavaScript, HTML и CSS без frontend-фреймворков.
5. Все ответы, комментарии, UI-тексты, пользовательские ошибки и проектные логи должны быть на русском языке. Технические идентификаторы допустимы в структурированных полях логов.
6. Сохраняй мультитенантную модель: отдельная PostgreSQL БД для каждого tenant. Проверяй, что миграции и фоновые задачи корректно работают для всех tenant-БД.
7. Не изменяй существующие Flyway-миграции. Любые новые ограничения схемы оформляй новыми последовательно пронумерованными миграциями, начиная со следующей свободной версии. Сначала проверь фактическую максимальную версию в `backend/src/main/resources/db/migration/`.
8. Сохраняй уже имеющиеся пользовательские изменения в рабочем дереве. Не откатывай и не перезаписывай несвязанные правки.
9. Не выполняй `commit`, `push`, deployment, ротацию production-секретов, переписывание истории Git или иные внешние необратимые действия без отдельного разрешения. Репозиторную часть исправлений реализуй полностью, а необходимые production-действия оформи отдельным точным runbook.
10. Не придумывай и не коммить реальные секреты. Не выводи секреты, пароли, токены, cookie или строки подключения в ответах, тестах и логах.
11. Не скрывай ошибки пустыми `catch`, не ослабляй проверки безопасности ради прохождения тестов и не отмечай проблему исправленной только за счёт комментария или документации.
12. Если замечание из отчёта уже исправлено в текущем коде, проверь это по реализации, добавь или уточни регрессионный тест и укажи доказательство. Не делай лишнюю повторную правку.
13. Не редактируй `BUG_REPORT.md`: он остаётся исходным реестром требований и основанием для итоговой проверки.
14. Учитывай, что `../k8s/` находится вне Git-корня `magistr`: отдельно перечисляй и проверяй каждый изменённый там файл. `git diff` текущего репозитория не подтверждает состояние `../k8s/`.
## Режим выполнения
Задача большая, поэтому веди явный чек-лист по номерам проблем: `1, 3, 4, …, 34`. Для каждого пункта зафиксируй:
- подтверждённую первопричину;
- изменяемые файлы и выбранное решение;
- автоматические тесты или иную воспроизводимую проверку;
- статус: `не начато`, `в работе`, `исправлено и проверено` либо `требуется внешнее действие`.
Раздели работу на независимые потоки и используй субагентов, если платформа это поддерживает, но координируй общие файлы, номера Flyway-миграций, API-контракты и role matrix централизованно. Не останавливайся после анализа или первой группы исправлений. Продолжай, пока все пункты из области работ не будут реализованы и проверены либо пока не останется объективный блокер, требующий внешних полномочий.
Перед редактированием:
1. Выполни `git status --short` и сохрани понимание исходных пользовательских изменений.
2. Зафиксируй список и контрольные суммы всех существующих Flyway-миграций, чтобы доказать их неизменность в финале.
3. Проверь каждое утверждение отчёта по актуальному коду.
4. Составь краткий порядок правок с учётом зависимостей.
5. Запусти доступный базовый набор тестов, чтобы отличать исходные сбои от внесённых регрессий.
## Требуемый результат по каждому пункту
### Безопасность и аутентификация
- **№ 1 — секреты в production-конфигурации.** Удали фиксированные, дефолтные и placeholder-секреты из отслеживаемой конфигурации; перенеси чувствительные значения из ConfigMap в подходящий механизм Secret/External Secrets/SOPS/Sealed Secrets либо подготовь безопасные ссылки на секреты без самих значений. На production-профиле приложение должно завершать запуск при отсутствующем, дефолтном или известном placeholder `JWT_SECRET`, а также при небезопасном refresh-cookie. Не переписывай историю Git и не ротируй реальные секреты самостоятельно — подготовь отдельный runbook ротации и очистки истории. При secret scan не трактуй неизменяемые demo-значения из `V1__init.sql`, относящиеся к исключённой проблеме № 2, как разрешение менять V1 или решать пункт № 2.
- **№ 3 — конкурентная ротация refresh-токена.** Обеспечь атомарную single-use семантику через блокировку строки или условный `UPDATE`; только один из параллельных запросов может создать следующий токен. Добавь конкурентный интеграционный тест с реальным PostgreSQL/Testcontainers.
- **№ 4 — утечка `AuthContext`.** Очищай контекст в начале обработки и на всех отказах, а пользователя устанавливай только после успешной аутентификации и авторизации. Добавь тест последовательных запросов на одном servlet-потоке.
- **№ 12 — access JWT и удалённый JavaScript в одном origin.** Убери исполнение неприкреплённых удалённых модулей, зафиксируй зависимости lockfile и раздавай их локально. Убери долговременное хранение access token в `localStorage` и `sessionStorage`: выбери и последовательно реализуй безопасную схему с access token в памяти и HttpOnly refresh-cookie либо полноценной защищённой cookie-сессией. Добавь строгий CSP без `unsafe-inline`/`unsafe-eval`, устрани несовместимые inline-скрипты и проверь все сценарии входа, refresh, logout и безопасного восстановления сессии после перезагрузки страницы.
- **№ 14 — отключённая TLS-проверка Kubernetes API.** Удали доверие любому сертификату. Используй service-account CA `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, стандартную проверку цепочки и hostname; ошибки должны быть явными и безопасными.
- **№ 24 — бесконечный рост refresh-токенов.** Добавь tenant-aware cleanup с настраиваемым сроком хранения отозванных/истёкших записей, безопасным расписанием и тестами. Очистка должна сохранять активные и свежие audit-записи, быть идемпотентной и безопасной при двух pod.
- **№ 25 — утечка DB/JDBC-ошибок.** Централизованно преобразуй `DataIntegrityViolationException` и известные constraints в корректные `400`/`409` с русскими сообщениями. Внутренние тексты исключений не должны попадать клиенту.
- **№ 28 — отсутствие ограничения входа.** Реализуй общий для нескольких backend-pod rate limit по tenant + IP + нормализованному username, прогрессивную задержку или временную блокировку и аудит неудачных попыток без логирования пароля. Учти корректное определение клиентского IP только за доверенным proxy, не допускай user enumeration, возвращай `429 Too Many Requests` и корректный `Retry-After`. Не выдавай локальный in-memory limiter одного pod за production-решение.
- **№ 33 — DOM XSS и видимые пароли.** Замени небезопасные вставки ошибок через `innerHTML` на `textContent` или проверенное экранирование. Поля пароля должны иметь `type="password"` и корректные `autocomplete`.
- **№ 34 — языковой регламент.** Приведи затронутые UI-тексты, ошибки и production-логи к русскому языку, не отдавая пользователю сырой JDBC/HTTP exception text.
### Расписание, календарь и бизнес-инварианты
- **№ 5 — невалидные schedule overrides.** До сохранения построй фактическую исходную пару на дату, проверь обязательные поля для каждого action, существование пары и конфликты преподавателя, аудитории и всех групп в результирующем расписании. Конфликт должен давать `409 Conflict` с русским сообщением.
- **№ 6 — замена преподавателя не видна в его расписании.** Учитывай overrides с `newTeacher` за период, достраивай относящиеся базовые пары и выполняй окончательную фильтрацию после применения overrides. Покрой исходного и нового преподавателя тестами.
- **№ 7 — неполная валидация правил.** Проверяй конфликты и дубли внутри нового правила, роль `TEACHER`, допустимый enum формата и чётное положительное количество академических часов. Где уместно, продублируй инварианты новой Flyway-миграцией.
- **№ 8 — частичный commit календарной сетки.** Сначала валидируй весь payload и уникальность ключей, затем атомарно заменяй сетку в транзакции. После любого `400` прежние данные должны остаться без изменений.
- **№ 11 — ложный зелёный статус дашборда.** Рассчитывай воскресенье как отдельную дату `понедельник + 6 дней`, не используй UTC для date-only значений и разделяй состояния «конфликтов нет», «частичная ошибка проверки» и «проверка не выполнена». Для `2026-07-02` диапазон должен быть `2026-06-29…2026-07-05`; проверь также переход года.
- **№ 15 — некорректные временные слоты.** Требуй `startTime < endTime`, запрети пересечения интервалов внутри scope, но разреши соседние интервалы с общей границей. Не разрешай перевод используемого базового слота из `DEFAULT` в `MANUAL`, а продолжительность рассчитывай на backend по `startTime/endTime`. Защити инвариант и от конкурентных записей.
- **№ 16 — пересечения учебных лет и семестров.** Централизуй валидацию диапазонов для create/update: запрети пересечения учебных лет, пересечения семестров, выход семестра за границы его года и дубликаты title/type. Добавь DB-ограничения новой миграцией там, где PostgreSQL может надёжно обеспечить инвариант.
- **№ 17 — устаревшие назначения календарей.** При изменении ключевых измерений календаря или группы либо безопасно отклоняй несовместимое изменение с `409 Conflict` без изменения данных, либо в одной транзакции перевалидируй назначения, grid и subjects. Уменьшение `courseCount` не должно оставлять строки или дисциплины старших курсов.
- **№ 18 — исторические кафедры преподавателя.** Для бизнес-решений и исторических отчётов определяй кафедру через `teacher_department_assignments` на целевую дату. Будущий перевод не должен менять текущую принадлежность раньше `validFrom`, архивный преподаватель должен корректно отображаться в историческом периоде, дополнительные активные назначения должны работать единообразно.
- **№ 19 — доступ к workload чужой кафедры.** Для роли `DEPARTMENT` принудительно ограничивай scope кафедрой из `AuthContext`; глобальный просмотр разрешай только явно уполномоченным ролям.
- **№ 20 — лимит 50 групп в агрегатах.** Не используй ограничение интерактивного широкого поиска в workload/free-classrooms. Реализуй специализированные агрегирующие запросы или контролируемую batch-обработку и тесты как минимум с 51 и 100 активными группами.
- **№ 21 — импорт дисциплины другой кафедры.** Не переназначай чужую запись по совпавшему глобальному имени. Зафиксируй бизнес-правило владения и уникальности; безопасный вариант по умолчанию — `409 Conflict` при совпадении с чужой кафедрой и идемпотентный повторный импорт своей записи. При необходимости введи `(department_id, lower(name))` новой миграцией и обработай существующие данные безопасно.
- **№ 22 — несовпадающие права учебного отдела.** Создай единый явно проверяемый role matrix для backend и frontend. Либо выдай `EDUCATION_OFFICE` необходимые права, либо скрой недоступные действия; штатные экраны не должны завершать инициализацию с `403`.
- **№ 23 — N+1 при генерации расписания.** Батчем загружай semesters, assignments и calendar days на диапазон, переиспользуй найденные данные и не выполняй запрос на каждую дату/группу/правило. Добавь измеримый query-count тест на диапазоне 120 дней и десятках групп, доказывающий отсутствие роста порядка `дни × группы × правила` при неизменном результате.
- **№ 29 — нефиксированный часовой пояс.** Внедри `Clock` и явно настроенный `ZoneId` `Europe/Moscow` для бизнес-дат, используй `Instant`/UTC для абсолютных timestamp, зафиксируй timezone контейнеров и формируй frontend date-only строки из локальных компонентов без `toISOString()`.
- **№ 31 — некорректный размер группы.** Используй единый валидатор create/update: `groupSize > 0`, `yearStartStudy > 0`, а также запрет уменьшения ниже суммы активных подгрупп, включая конкурентный сценарий. Добавь новые CHECK constraints отдельной миграцией.
- **№ 32 — диапазон в 121 дату.** Проверяй включительную длину диапазона; максимум должен составлять ровно 120 календарных дат. Покрой граничные случаи 120 и 121 дата.
### Tenant, инфраструктура и поставка
- **№ 9 — небезопасная замена tenant DataSource.** Создавай временный pool, явно проверяй соединение и Flyway, затем атомарно подменяй рабочий DataSource. Ошибка миграции или сохранения конфигурации должна прерывать операцию и запускать compensating rollback без потери старого подключения.
- **№ 10 — lost update ConfigMap и неинформативные probes.** Реализуй optimistic locking по `resourceVersion` с ограниченным retry/backoff либо эквивалентный единый coordinator; параллельные изменения двух pod не должны теряться. Readiness должна учитывать доступность обязательных tenant-БД и успешность миграций, а liveness — только жизнеспособность процесса, чтобы отказ БД не создавал restart storm.
- **№ 13 — неполная синхронизация watcher.** Сравнивай весь нормализованный `TenantConfig`, а hash обновляй только после полного успеха. Неудачная синхронизация должна повторяться с ограниченным backoff и наблюдаемым русскоязычным логом.
- **№ 26 — нерабочий чистый локальный запуск.** Сделай `docker compose up -d --build` на чистой машине, без заранее созданной внешней сети, достаточным для доступа к приложению по `http://localhost:80` и проксирования `/api` в backend. Согласуй JDBC URL и `POSTGRES_DB`, передай JWT-настройки, добавь именованный volume и обнови quick start.
- **№ 27 — deployment без тестов и неверный tag rollout.** Добавь обязательные backend/frontend/static/config проверки до build/push/deploy, concurrency lock и проверяемый rollback. Для релизного tag разворачивай конкретный tag или digest, а не перезапускай mutable `:main`.
- **№ 30 — mutable и непроверяемые артефакты.** Зафиксируй версии и digest базовых/сторонних образов и инструментов, проверяй SHA256/signature скачиваемых бинарников, формируй SBOM и добавь сканирование образов. В production не должно остаться `latest` и `:main`; обновление версии должно быть явным и воспроизводимым.
## Архитектурные требования к реализации
- Транзакционные границы должны находиться на вызываемых Spring proxy-методах; не рассчитывай на `@Transactional` при self-invocation или пойманном внутри исключении.
- Для конкурентных сценариев используй гарантии PostgreSQL и проверяй их интеграционными тестами, а не только синхронизацией внутри одного JVM-процесса.
- Все операции с tenant-конфигурацией должны быть идемпотентными и безопасными при двух pod.
- Не смешивай date-only и timestamp. Date-only передавай как `YYYY-MM-DD` без UTC-конвертации.
- Сохраняй обратную совместимость API там, где она не противоречит безопасности. Если контракт необходимо изменить, синхронно обнови backend, frontend, тесты и `docs/API.md`.
- Не дублируй сложные бизнес-правила по контроллерам. Выноси общую валидацию/логику в переиспользуемые компоненты в соответствии с текущей архитектурой проекта.
- Избегай массовых косметических рефакторингов, не относящихся к отчёту. Каждое существенное изменение должно сопоставляться с одним или несколькими номерами проблем.
- Не выполняй `docker compose down -v`, массовое удаление данных и другие необратимые операции. Интеграционные тесты должны использовать изолированные временные ресурсы.
## Обязательные тесты и проверки
Добавь регрессионные тесты для каждого исправленного поведения. В первую очередь обязательно покрой:
1. PostgreSQL/Testcontainers и Flyway: чистая схема и upgrade существующей схемы — **без проверки и изменения поведения из проблемы № 2**.
2. Два параллельных refresh-запроса с одним токеном: успех только одного.
3. Последовательность `403`, затем публичный/защищённый запрос на одном потоке без утечки `AuthContext`.
4. Override с конфликтом и расписание преподавателя, назначенного через `newTeacher`.
5. Внутренние конфликты нового правила, неверная роль, нечётные часы и недопустимый формат.
6. Атомарность `saveGrid`: после ошибочного payload старая сетка полностью сохранена.
7. Изменение tenant URL/credentials, неудачная миграция, неудачный ConfigMap update и параллельные PATCH от двух pod.
8. Более 50 активных групп для workload/free-classrooms.
9. Будущий перевод преподавателя и исторические отчёты.
10. E2E или эквивалентный интеграционный сценарий `EDUCATION_OFFICE` для календарных графиков и форм обучения.
11. Frontend-даты в первые дни месяца и в интервале 00:0003:00 `Europe/Moscow`.
12. Граничные диапазоны 120/121 дата, XSS payload в ошибке, rate limit и cleanup refresh-токенов.
После реализации выполни доступные проверки и зафиксируй фактический результат каждой команды:
```bash
mvn -f backend/pom.xml test
find frontend -type f -name '*.js' -print0 | xargs -0 -n1 node --check
docker compose config --quiet
docker compose build backend frontend
kubectl kustomize ../k8s >/dev/null
bash -n ../k8s/deploy.sh
git diff --check
git diff --stat
git status --short
```
Команду для `../k8s/deploy.sh` выполняй только если файл существует. Если локального Maven, Node, Docker или kubectl нет либо сборке нужен недоступный network/daemon, используй уже принятый в проекте контейнерный эквивалент либо честно укажи, какую проверку невозможно запустить и почему. Не называй непроверенный результат успешным. Успешный Docker build не заменяет `mvn test`.
Дополнительно проверь:
- отсутствие секретов и placeholder credentials в отслеживаемых production-файлах;
- отсутствие изменений существующих Flyway-миграций;
- согласованность новых миграций с JPA-моделями и upgrade-путём всех tenant-БД;
- отсутствие сырых exception messages в HTTP-ответах;
- соответствие backend/frontend role matrix;
- отсутствие `localStorage`/`sessionStorage` для access JWT, удалённых исполняемых модулей и исполняемого inline JavaScript, несовместимого со строгим CSP;
- воспроизводимость Docker/CI/Kubernetes-конфигурации;
- отсутствие английских пользовательских сообщений и production-логов в изменённых областях.
- отдельный список и содержательную проверку всех изменений в `../k8s/`, поскольку они не видны в Git diff проекта.
## Документация
После кода и тестов обнови только относящуюся к изменениям документацию, в том числе при необходимости:
- `docs/API.md`;
- `docs/DATABASE.md`;
- `docs/ARCHITECTURE.md`;
- `docs/BUSINESS_LOGIC.md`;
- `docs/FRONTEND.md`;
- `docs/INFRASTRUCTURE.md`;
- `docs/LOGGING.md`;
- `docs/README.md` и корневой quick start.
Документация должна описывать фактическое поведение после изменений. Не документируй проблему № 2 как исправленную и не меняй инструкции по demo-аккаунтам в рамках этой задачи.
## Формат итогового ответа
Ответ дай на русском языке и начни с результата. Включи:
1. Таблицу по всем пунктам `1, 334`: номер, краткое исправление, основные файлы, тест/проверка, статус.
2. Список созданных Flyway-миграций и подтверждение, что существующие миграции не изменены.
3. Фактически выполненные команды и их результаты.
4. Изменения API, конфигурации, role matrix и deployment-процесса.
5. Отдельный раздел «Требуемые действия оператора» для ротации секретов, очистки истории, настройки secret manager, production rollout и других действий, которым нужен внешний доступ. Не утверждай, что они выполнены.
6. Оставшиеся риски или проверки, которые невозможно выполнить локально.
7. Подтверждение, что проблема № 2 не затрагивалась.
Перед финальным ответом ещё раз прочитай `BUG_REPORT.md` и сравни чек-лист с точным множеством `{1} {3..34}`. В итоговой таблице должно быть ровно 33 строки без пропущенных или повторяющихся ID. У каждого пункта должны быть реализация либо доказательство уже существующего исправления, регрессионная проверка и честный статус.
Критерий завершения: каждый пункт № 1 и № 334 имеет реализацию и регрессионную проверку либо явно отделённое внешнее действие; все доступные тесты проходят; документация соответствует коду; проблема № 2 и существующие Flyway-миграции не изменены.

614
BUG_REPORT.md Normal file
View File

@@ -0,0 +1,614 @@
# Отчёт по ошибкам и рискам проекта Magistr
Дата проверки: 2026-07-10
## Объём и способ проверки
Проверены:
- 166 Java-файлов backend, JPA-модели, репозитории, контроллеры, сервисы и конфигурация мультитенантности;
- обе Flyway-миграции без изменения существующих файлов;
- 23 JavaScript-файла, встроенный JavaScript страниц преподавателя/студента, HTML/CSS и SPA-маршрутизация;
- `compose.yaml`, Dockerfile, Gitea Actions;
- production-манифесты `../k8s/`, которые AGENTS.md относит к внешним зависимостям проекта;
- проектная документация и существующий граф связей `graphify-out/graph.json`.
Выполненные проверки:
- `mvn test` в Maven-контейнере: **30 тестов, 0 failures, 0 errors, BUILD SUCCESS**;
- `node --check` для всех внешних JS-файлов и извлечённых inline-скриптов: ошибок синтаксиса нет;
- `docker compose config --quiet`: Compose синтаксически корректен;
- `kubectl kustomize ../k8s`: Kustomize-манифесты собираются;
- сопоставление JPA-таблиц с Flyway DDL: отсутствующих таблиц для сущностей не найдено;
- `git diff --check`: проблем с пробелами и маркерами конфликтов нет.
Ограничения проверки: не выполнялись E2E-тесты в браузере, нагрузочное тестирование, тестирование с реальным PostgreSQL и одновременной работой двух backend-pod. Конкурентные дефекты ниже подтверждены по коду и воспроизводимому сценарию, но не запускались против production-кластера.
## Сводка
| Приоритет | Количество |
|---|---:|
| Критический | 2 |
| Высокий | 10 |
| Средний | 19 |
| Низкий | 3 |
| **Всего** | **34** |
## Критические проблемы
### 1. Production-манифесты содержат известные JWT/DB-секреты и хранят пароли в ConfigMap
**Файлы:**
- `backend/src/main/resources/application.properties:18-22`
- `backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java:13-17`
- `../k8s/config.yaml:15-26`
- `../k8s/backend.yaml:1-23`
- `../k8s/otel-collector.yaml:1-25`
**Проблема:** приложение имеет известный JWT-секрет по умолчанию и небезопасный дефолт `refresh-cookie-secure=false`. Production-манифест `config.yaml` передаёт другой, но также заранее известный placeholder-секрет, который удовлетворяет проверке длины. Пароли PostgreSQL записаны открытым текстом в `Secret.stringData`, tenant ConfigMap и ConfigMap коллектора OpenTelemetry. Kubernetes Secret в YAML не шифрует значение в исходном файле, а ConfigMap вообще не предназначен для секретов.
**Риск:** подделка access JWT, компрометация всех tenant-БД и метрик, повторное использование опубликованных credentials. Если эти значения когда-либо применялись, их нужно считать скомпрометированными.
**Как исправить:**
- Немедленно ротировать JWT- и DB-секреты во всех окружениях.
- Удалить реальные/фиксированные значения из файлов и истории репозиториев.
- Использовать External Secrets, SOPS/Sealed Secrets или секреты CI/CD; tenant credentials и credentials OTel хранить в Secret, а не ConfigMap.
- На production-профиле завершать запуск при пустом, дефолтном или известном placeholder `JWT_SECRET` и при `JWT_REFRESH_COOKIE_SECURE != true`.
---
### 2. Flyway создаёт во всех новых tenant-БД предсказуемые учётные записи и демонстрационные данные
**Файлы:**
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
- `docs/README.md:45-54`
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
**Как исправить:**
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
- Разнести demo/dev seed и обязательную production-схему.
- Ротировать пароли уже созданных tenant-БД.
## Высокий приоритет
### 3. Race condition при ротации refresh-токена
**Файлы:**
- `backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java:50-91`
- `backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java:10`
**Проблема:** `rotate()` читает токен, проверяет его активность, а затем отзывает старый и создаёт новый без блокировки строки или условного атомарного UPDATE. Два параллельных запроса с одним cookie могут оба пройти проверку и создать две новые сессии.
**Риск:** повторное использование refresh-токена, раздвоение цепочки ротации и обход ожидаемой семантики single-use.
**Как исправить:** использовать `PESSIMISTIC_WRITE` либо атомарный `UPDATE ... WHERE revoked_at IS NULL AND expires_at > now()` и создавать следующий токен только при обновлении одной строки. Добавить конкурентный интеграционный тест с PostgreSQL.
---
### 4. `AuthContext` остаётся в servlet-потоке после отказа по роли
**Файл:** `backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java:45-49,65-68`
**Проблема:** пользователь записывается в ThreadLocal до проверки `@RequireRoles`. Если interceptor возвращает `false`, его собственный `afterCompletion()` не вызывается, поэтому контекст не очищается.
**Риск:** следующий запрос на том же потоке может увидеть данные предыдущего пользователя, особенно если он проходит через публичный auth endpoint или останавливается в более раннем interceptor.
**Как исправить:** очищать контекст в начале `preHandle()` и перед каждым `return false`; ещё лучше — устанавливать пользователя только после успешной проверки роли. Добавить MockMvc-тест последовательных запросов на одном executor-потоке.
---
### 5. Точечные изменения расписания создаются без проверки конфликтов и факта существования пары
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java:71-126`
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:67-141`
**Проблема:** для `MOVE`/`REPLACE` проверяется только существование сущностей. Не проверяется занятость преподавателя, аудитории и групп в новой паре. Также можно сохранить override на дату, когда базовый слот вообще не генерируется, либо сохранить фактически пустой `REPLACE`.
**Риск:** двойное назначение ресурсов; накопление неработающих изменений, которые API считает успешными.
**Как исправить:** перед сохранением построить исходную пару на указанную дату, проверить action-specific поля и пересечения уже применённого расписания. При конфликте возвращать `409 Conflict`.
---
### 6. Расписание преподавателя не показывает пары, назначенные ему через override
**Файлы:**
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:47-63`
- `backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java:36-58`
- `backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java:14-26`
**Проблема:** запрос только по `teacherId` сначала выбирает базовые правила этого преподавателя. Если override заменяет преподавателя A на B, правило A не попадает в исходную выборку B, поэтому последующее `applyOverrides()` уже не может добавить пару.
**Риск:** преподаватель B не видит назначенную ему замену.
**Как исправить:** учитывать overrides с `newTeacher.id = teacherId` за период и достраивать соответствующие базовые пары до финальной фильтрации.
---
### 7. Валидатор правил расписания пропускает несколько нарушений инвариантов
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java:220-228,440-481,524-565`
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:252-293`
- `backend/src/main/resources/db/migration/V1__init.sql:707-726`
**Проблемы:**
- конфликт ищется только с уже сохранёнными правилами; два конфликтующих/дублирующих слота внутри одного нового правила не сравниваются между собой;
- пользователь слота проверяется на архивность, но не на роль `TEACHER`;
- `lessonFormat` проверяется только на непустоту, хотя DB допускает лишь «Очно»/«Онлайн»;
- нечётное число академических часов принимается, хотя генератор списывает по два часа и может выдать больше часов, чем задано.
**Риск:** дубли в один момент времени, назначение студента/администратора преподавателем, HTTP 500 на DB CHECK и неверный расход часов.
**Как исправить:** валидировать пары новых слотов между собой, роль пользователя, enum формата и кратность часов двум; продублировать ключевые ограничения новой Flyway-миграцией там, где это возможно.
---
### 8. Ошибка в строке календарной сетки возвращает 400, но удаляет старую сетку и коммитит часть новой
**Файл:** `backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java:125-146`
**Проблема:** `saveGrid()` работает в транзакции, сначала удаляет всю старую сетку, затем валидирует и сохраняет строки по одной. `IllegalArgumentException` ловится внутри transactional-метода и не выходит за границу proxy, поэтому транзакция считается успешной.
**Воспроизведение:** передать список, где первая строка корректна, а вторая содержит неверный день/дату. API вернёт 400, но старая сетка будет удалена, а первая новая строка сохранится.
**Риск:** тихая потеря календарного учебного графика при ошибке пользователя.
**Как исправить:** сначала провалидировать весь список и уникальность ключей, затем одним шагом заменять данные; либо не перехватывать исключение внутри транзакции/помечать транзакцию rollback-only.
---
### 9. Обновление tenant может удалить рабочее подключение и успешно сохранить нерабочее
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:104-124,175-182`
- `backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java:135-148`
- `backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java:133-160`
**Проблема:** существующий DataSource сначала удаляется и закрывается. Новый Hikari pool создаётся с `initializationFailTimeout=-1`. Ошибка Flyway проглатывается, а результат записи ConfigMap игнорируется. API способен вернуть успех после неудачной миграции или неудачной персистенции.
**Риск:** одна опечатка в URL/пароле отключает рабочий tenant; разные pod получают разную конфигурацию.
**Как исправить:** создать временный pool, проверить соединение и миграции, затем атомарно заменить старый. Ошибки миграции и ConfigMap должны прерывать операцию; нужен compensating rollback.
---
### 10. Два backend-pod могут потерять изменения tenant-конфигурации, а probes не видят отказ БД
**Файлы:**
- `../k8s/backend.yaml:31,86-97`
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:177-182`
- `backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java:62-92`
**Проблема:** deployment имеет две реплики. Каждая PATCH-операция перезаписывает весь `tenants.json` из локальной in-memory карты без resourceVersion/compare-and-swap. Параллельное добавление A на pod-1 и B на pod-2 приводит к last-write-wins и потере A или B. TCP readiness/liveness считает pod здоровым, даже если все tenant-БД недоступны и Flyway завершился ошибкой.
**Риск:** потерянные конфигурационные изменения и маршрутизация трафика на фактически неработающий pod.
**Как исправить:** вынести операции в единый coordinator/CRD/БД либо применять optimistic locking к ConfigMap с повтором; добавить health endpoint, проверяющий миграции и доступность обязательных tenant.
---
### 11. Дашборд может показать зелёное «конфликтов нет», когда проверка фактически не выполнена
**Файл:** `frontend/admin/js/views/dashboard.js:148-168,274-287`
**Проблемы:**
- диапазон недели вычисляется двойным мутированием одного `Date`; в первые дни месяца конец диапазона попадает в начало предыдущего месяца и оказывается раньше начала;
- каждый запрос кафедры имеет `catch(() => [])`, поэтому сетевые/серверные ошибки превращаются в пустое расписание;
- после этого пустой результат отображается как «Конфликты расписания не обнаружены».
**Проверенный пример:** для 2026-07-02 код строит диапазон примерно 2026-06-29 … 2026-06-05 вместо 2026-06-29 … 2026-07-05.
**Риск:** ложное подтверждение безопасности расписания именно в панели контроля.
**Как исправить:** вычислять воскресенье как `monday + 6 дней` на отдельном объекте, форматировать локальную дату без UTC и различать состояния «нет конфликтов»/«проверка частично или полностью не выполнена».
---
### 12. Access JWT и удалённый JavaScript выполняются в одном origin
**Файлы:**
- `frontend/admin/js/api.js:5-7,155-163`
- `frontend/script.js:5-28`
- `frontend/admin/js/otel.js:1-8`
**Проблема:** JWT хранится в `localStorage`. Страница входа динамически исполняет неприкреплённые по версии модули с `esm.sh`, а admin — удалённые модули без SRI. Такой код имеет доступ к DOM, введённому паролю и `localStorage`.
**Риск:** компрометация CDN/пакета или XSS сразу даёт учётные данные и Bearer-токен.
**Как исправить:** собирать и раздавать telemetry dependencies локально с lockfile; держать access token в памяти или перейти на защищённую cookie-сессию; добавить строгий CSP и убрать inline-скрипты.
## Средний приоритет
### 13. Watcher не применяет изменения существующего tenant и не повторяет неудачную синхронизацию
**Файл:** `backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java:60-71,102-125`
**Проблема:** сравниваются только множества доменов — смена URL, логина или пароля существующего домена игнорируется. Кроме того, новый hash записывается до JSON parsing/sync; если синхронизация падает, следующий poll считает файл уже обработанным и не повторяет операцию.
**Риск:** pod продолжает использовать старые credentials либо навсегда остаётся в частично синхронизированном состоянии до следующего изменения файла/рестарта.
**Как исправить:** сравнивать весь нормализованный `TenantConfig`, записывать hash только после полного успеха и сохранять retry/backoff-состояние.
---
### 14. Полностью отключена проверка TLS Kubernetes API
**Файл:** `backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java:74-76,104-119`
**Проблема:** trust manager принимает любой сертификат. Kubernetes CA уже доступен в serviceaccount volume.
**Риск:** MITM к API server, утечка serviceaccount token и подмена tenant ConfigMap.
**Как исправить:** загружать `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использовать стандартную проверку цепочки и hostname.
---
### 15. Редактор временных слотов позволяет создать пересекающиеся интервалы и сломать базовые правила
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java:152-224`
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:418-435`
**Проблемы:**
- проверяется только уникальность номера пары, но не пересечение временных интервалов внутри сетки;
- update разрешает перенести существующий базовый слот в MANUAL scope, хотя правила обязаны ссылаться на DEFAULT;
- `durationMinutes` может не соответствовать разнице `startTime/endTime`.
**Риск:** два занятия реально идут одновременно, но конфликтный анализ считает их разными слотами; существующие правила исчезают из ожидаемой базовой сетки.
**Как исправить:** запретить пересечение интервалов, фиксировать scope используемого базового слота и вычислять duration на backend.
---
### 16. Учебные годы и семестры допускают пересечения и выход семестра за границы года
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java:46-129,188-237`
- `backend/src/main/java/com/magistr/app/service/AcademicDateService.java:32-34`
- `backend/src/main/java/com/magistr/app/repository/SemesterRepository.java:13-17`
**Проблема:** проверяется только порядок двух дат. Можно пересечь учебные годы, пересечь осенний и весенний семестры, вынести семестр за границы года. При update также не проверяется дубликат title/type до DB constraint.
**Риск:** `findFirst...` выбирает произвольный семестр для даты; номер недели и чётность становятся недетерминированными, а часть update-запросов заканчивается HTTP 500.
**Как исправить:** централизовать календарную валидацию, запретить пересечения и добавить DB-level exclusion/range constraints новой миграцией.
---
### 17. Изменение календаря или группы нарушает уже сохранённые назначения
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java:85-98,178-214`
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:173-212,322-359`
**Проблема:** после назначения графика группе можно изменить у графика учебный год, специальность, профиль, форму или количество курсов; можно также изменить эти поля у группы. Существующее `student_group_calendar_assignments` не перепроверяется и не удаляется. При уменьшении courseCount остаются строки сетки старших курсов.
**Риск:** расписание строится по графику, который больше не соответствует группе/году, хотя API проверял соответствие при первоначальном назначении.
**Как исправить:** запрещать изменение ключевых измерений у используемого графика либо транзакционно перевалидировать назначения, grid и subjects.
---
### 18. Историческая модель кафедр преподавателя используется непоследовательно
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/UserController.java:218-259,310-324`
- `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:51-60,85-100`
- `backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java:102-115`
**Проблемы:**
- будущий перевод немедленно меняет `users.department_id`, и преподаватель до `validFrom` появляется одновременно в старой и новой кафедре;
- исторический список фильтрует `User::isActiveRecord`, поэтому архивный преподаватель исчезает даже для даты, когда он работал;
- workload прошлых периодов относится к текущему `users.department_id`;
- дополнительная активная кафедральная связь учитывается в списке преподавателей, но `TeacherSubjectController` разрешает связь только по legacy `users.department_id`.
**Риск:** неверные исторические отчёты и неработоспособность дополнительного назначения преподавателя.
**Как исправить:** считать кафедру через `teacher_department_assignments` на дату; legacy-поле обновлять только в дату вступления перевода в силу либо не использовать для бизнес-решений.
---
### 19. Роль `DEPARTMENT` может запрашивать workload чужой кафедры
**Файл:** `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:43-135`
**Проблема:** endpoints принимают произвольный `departmentId` или отсутствие фильтра и не сопоставляют его с `AuthContext.departmentId`.
**Риск:** межкафедральное раскрытие отчётов и обход ограничений, которые уже применяются в `DepartmentWorkspaceController` и части `UserController`.
**Как исправить:** для `DEPARTMENT` принудительно использовать кафедру из токена; явно документировать endpoints, где глобальный просмотр действительно разрешён.
---
### 20. Глобальные workload/free-classrooms ломаются при количестве активных групп больше 50
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/WorkloadController.java:43-135`
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:160-180`
**Проблема:** агрегирующие endpoints переиспользуют общий поиск с ограничением 50 групп. `free-classrooms` вообще не принимает scope, поэтому в крупном tenant неизбежно получает `IllegalArgumentException` вместо списка свободных аудиторий.
**Риск:** штатная функция перестаёт работать при росте университета.
**Как исправить:** сделать специализированные агрегирующие запросы/батч-генерацию, а не использовать ограничение интерактивного широкого поиска.
---
### 21. Импорт кафедры может переназначить дисциплину другой кафедры
**Файл:** `backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java:80-90`
**Проблема:** дисциплина ищется по глобальному имени, после чего `departmentId` безусловно меняется на текущую кафедру.
**Риск:** потеря принадлежности и изменение расписаний/календарей другой кафедры.
**Как исправить:** не менять чужую запись; решить бизнес-правило уникальности и при необходимости перейти на `(department_id, lower(name))` новой миграцией.
---
### 22. Права frontend и backend расходятся для учебного отдела
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/SpecialityController.java:23-24,38-58,196-213`
- `backend/src/main/java/com/magistr/app/controller/EducationFormController.java:17-18,33-59`
- `frontend/admin/js/views/academic-calendar.js:106-118,207-218`
- `frontend/admin/settings/js/main.js:47-52`
**Проблемы:**
- `EDUCATION_OFFICE` видит вкладку календарных графиков, но её обязательные GET `/api/specialties` и profiles разрешены только `ADMIN`; инициализация попадает в 403;
- settings показывает учебному отделу CRUD форм обучения, но POST/DELETE backend разрешены только `ADMIN`.
**Риск:** видимые штатные экраны частично или полностью не работают.
**Как исправить:** согласовать role matrix в одном источнике; либо расширить read/write права, либо скрыть недоступные действия.
---
### 23. Генерация расписания имеет выраженный N+1 по дням, правилам и группам
**Файлы:**
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:64-90,107-124,187-229,297-308`
- `backend/src/main/java/com/magistr/app/service/AcademicDateService.java:58-87`
**Проблема:** семестр повторно ищется внутри каждого правила, а `isTheoryDay()` для каждой группы и даты выполняет отдельные запросы assignment + calendar day. В teacher mode это повторяется по всем группам правила.
**Риск:** диапазон 120 дней и десятки групп порождают тысячи/десятки тысяч SQL-запросов, таймауты и нагрузку на каждую tenant-БД.
**Как исправить:** заранее батчем загружать semesters, assignments и calendar days за диапазон; передавать уже найденный semester в `processRuleForDate()`. Добавить метрики query count и нагрузочный тест.
---
### 24. Истёкшие и отозванные refresh-токены никогда не удаляются
**Файлы:**
- `backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java:34-107`
- `backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java:8-11`
**Проблема:** каждый login/refresh добавляет строку, ротация лишь ставит `revoked_at`. Cleanup job/repository delete отсутствует.
**Риск:** неограниченный рост `auth_refresh_tokens` и индексов.
**Как исправить:** периодически удалять давно истёкшие/отозванные строки с разумным audit retention; покрыть job тестом.
---
### 25. DB constraint violations превращаются в 500 и иногда раскрывают текст драйвера
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java:23-55`
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:166-169,216-219`
- `backend/src/main/java/com/magistr/app/controller/SubjectController.java:122-125`
- `backend/src/main/java/com/magistr/app/controller/DatabaseController.java:121-124,166-171`
**Проблема:** нет общего обработчика `DataIntegrityViolationException`/constraint name. Часть контроллеров ловит общий `Exception` и возвращает клиенту `e.getMessage()`.
**Риск:** неправильный HTTP status, английские/технические сообщения в UI и раскрытие деталей схемы/JDBC.
**Как исправить:** добавить единый перевод constraint → 409/400 с русским сообщением; не отдавать внутренний exception text.
---
### 26. Чистый локальный запуск по документации не публикует приложение и имеет противоречивую конфигурацию БД
**Файлы:**
- `compose.yaml:1-43`
- `backend/src/main/resources/application.properties:3-6,18-22`
- `docs/README.md:33-43`
- `docs/INFRASTRUCTURE.md:3-37`
**Проблемы:**
- frontend/backend не имеют `ports`, а Caddy не входит в Compose, поэтому на чистой машине `localhost:80` после указанной команды недоступен;
- `POSTGRES_DB` может изменить создаваемую DB, но backend URL жёстко указывает `app_db`;
- JWT-переменные из `.env` не передаются контейнеру backend;
- для PostgreSQL не объявлен именованный volume, поэтому после down/recreate данные могут остаться в потерянном anonymous volume.
**Как исправить:** добавить локальный reverse proxy/ports, передавать согласованный JDBC URL и JWT env, объявить named volume и обновить quick start.
---
### 27. CI/CD разворачивает код без запуска тестов, а tag pipeline перезапускает `:main`
**Файлы:**
- `.gitea/workflows/docker-build.yaml:3-8,35-40,63-68,73-96`
- `backend/Dockerfile:1-6`
- `../k8s/backend.yaml:45-46`
- `../k8s/frontend.yaml:20-21`
**Проблема:** workflow не имеет test job, а Dockerfile выполняет `mvn package -DskipTests`. На событии tag metadata публикует tag-образ, но deployment продолжает ссылаться на mutable `:main` и просто перезапускается.
**Риск:** production получает непроверенный commit; release-tag может перезапустить старый main-образ вместо релиза.
**Как исправить:** обязательные test/compile/static-check jobs до push/deploy; деплой image digest или конкретного release tag; concurrency lock и rollback при failed rollout.
---
### 28. Нет ограничения попыток входа
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/AuthController.java:57-86`
- `../k8s/ingress.yaml:1-45`
**Проблема:** login не имеет rate limit, задержки, lockout или CAPTCHA; в Ingress также нет соответствующего middleware/аннотации.
**Риск:** online brute force и password spraying, особенно опасные вместе с известными seed-аккаунтами.
**Как исправить:** rate limit по tenant+IP+username, прогрессивная задержка и аудит неудачных входов без логирования пароля.
---
### 29. Часовой пояс backend не зафиксирован, а часть frontend дат строится через UTC
**Файлы:**
- `backend/src/main/java/com/magistr/app/model/LifecycleEntity.java:94-98`
- `backend/src/main/java/com/magistr/app/controller/UserController.java:85,164,235,271`
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:236`
- `frontend/admin/js/views/dashboard.js:48,159-160`
- `frontend/admin/js/views/department-workspace.js:494-504`
- `compose.yaml` и `../k8s/backend.yaml`
**Проблема:** бизнес-даты используют `LocalDate.now()`/`LocalDateTime.now()` с timezone JVM, но контейнеры не получают `TZ`/`user.timezone`. Frontend использует `toISOString()`, поэтому в Москве до 03:00 получает предыдущую календарную дату.
**Риск:** архивирование, перевод, доступность ресурсов и «сегодняшнее расписание» сдвигаются на день/несколько часов в зависимости от окружения.
**Как исправить:** внедрить `Clock` и явный `ZoneId` для бизнес-даты, хранить абсолютные timestamps как UTC/`Instant`, форматировать date-only локальными компонентами.
---
### 30. Сборки и deployment используют mutable/непроверяемые артефакты
**Файлы:**
- `backend/Dockerfile:7`
- `../k8s/otel-collector.yaml:59`
- `../k8s/backend.yaml:45-46`
- `../k8s/frontend.yaml:20-21`
- `.gitea/workflows/docker-build.yaml:83-87`
**Проблема:** javaagent и kubectl скачиваются как latest/stable без checksum, collector использует `latest`, приложения — mutable `main`.
**Риск:** невоспроизводимые сборки, неожиданные несовместимости и supply-chain подмена.
**Как исправить:** pin версии и image digest, проверять SHA256/signature, генерировать SBOM и сканировать образы.
---
### 31. Группы допускают некорректную численность и ломают существующее деление на подгруппы
**Файлы:**
- `backend/src/main/java/com/magistr/app/controller/GroupController.java:173-212,250-272`
- `backend/src/main/java/com/magistr/app/controller/SubgroupController.java:151-175`
- `backend/src/main/resources/db/migration/V1__init.sql:203-219`
**Проблема:** `groupSize` проверяется только на null, `yearStartStudy` — только на ноль; DB не имеет CHECK для положительной численности. При update можно уменьшить группу ниже суммы активных подгрупп, хотя SubgroupController такой результат запрещает при редактировании подгрупп.
**Риск:** отрицательная/нулевая численность, неверный расчёт вместимости и внутренне противоречивые подгруппы.
**Как исправить:** единый валидатор create/update, проверка суммы подгрупп и новая миграция с CHECK constraints.
## Низкий приоритет
### 32. Лимит «120 дней» фактически разрешает 121 дату
**Файлы:**
- `backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java:148-157`
- `backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java:135-144`
**Проблема:** проверяется `DAYS.between(start, end) > 120`, но обе границы включены. Разница 120 означает 121 календарную дату.
**Как исправить:** проверять включительную длину (`between + 1`) либо изменить текст/документацию.
---
### 33. Остались XSS-точки в сообщениях ошибок и видимые поля пароля
**Файлы:**
- `frontend/admin/settings/js/views/database.js:39,49`
- `frontend/admin/js/main.js:260-262`
- `frontend/admin/settings/js/main.js:130-133`
- `frontend/admin/views/users.html:11-12`
- `frontend/admin/js/views/teacher-requests.js:47-51`
**Проблема:** несколько `e.message` вставляются через `innerHTML` без экранирования. Поля пароля при создании пользователя/одобрении заявки имеют `type="text"`.
**Риск:** потенциальный DOM XSS при попадании управляемого текста ошибки; пароль виден на экране и может сохраняться как обычный текст формы.
**Как исправить:** использовать `textContent`/`escapeHtml`, а для паролей — `type="password"` и корректные `autocomplete`.
---
### 34. Проектный языковой регламент нарушен в UI и логах
**Файлы:**
- `backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java:65,78,95`
- `backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java:60,123`
- `frontend/admin/settings/js/views/database.js:14-16,60-62`
- `frontend/admin/js/otel.js:47`
**Проблема:** присутствуют английские production-логи и UI-метки `Online/Offline`; JDBC exception также может попасть в UI на английском.
**Как исправить:** привести пользовательские сообщения и проектные логи к русскому языку; технические идентификаторы оставлять в structured fields.
## Что покрыть тестами в первую очередь
1. PostgreSQL/Testcontainers + Flyway: чистая БД, upgrade существующей БД, отсутствие активных demo-аккаунтов.
2. Два параллельных refresh-запроса с одним токеном.
3. Цепочка interceptor: 403, затем публичный/защищённый запрос на том же потоке.
4. Override с конфликтом и поиск преподавателя, назначенного через `newTeacher`.
5. Внутренние конфликты одного нового правила, роль преподавателя, нечётные часы и недопустимый формат.
6. `saveGrid` с корректной и ошибочной строкой: после 400 старая сетка должна остаться целой.
7. Изменение tenant URL/credentials, неудачная миграция, параллельные PATCH от двух pod.
8. Более 50 активных групп для workload/free-classrooms.
9. Future-dated перевод преподавателя и исторические отчёты.
10. E2E для `EDUCATION_OFFICE`: календарные графики и настройки форм обучения.
11. Frontend-тест дат в первые дни месяца и в 00:0003:00 Europe/Moscow.
## Рекомендуемый порядок исправления
1. Ротация/удаление секретов и отключение seed-аккаунтов.
2. Data-loss и security races: calendar grid, refresh rotation, AuthContext.
3. Атомарность tenant-конфигурации и реальные health checks.
4. Конфликты overrides/rules и корректность расписания преподавателя.
5. Согласование ролей, календарных инвариантов и исторической модели кафедр.
6. Производительность генератора, Compose/CI/CD и frontend hardening.
Существующие Flyway-миграции не изменять. Все исправления схемы оформлять новой миграцией, начиная с `V3__...sql`.

292
DEVOPS.md Normal file
View File

@@ -0,0 +1,292 @@
# РАЗРАБОТКА И ВНЕДРЕНИЕ ОТКАЗОУСТОЙЧИВОЙ МУЛЬТИТЕНАНТНОЙ ИНФРАСТРУКТУРЫ ДЛЯ СИСТЕМЫ УПРАВЛЕНИЯ УНИВЕРСИТЕТСКИМ РАСПИСАНИЕМ «МАГИСТР»
**Диссертационное исследование и отчет о проделанной инженерной работе в качестве DevOps-архитектора проекта**
---
## ВВЕДЕНИЕ
Современные информационные системы для образовательных учреждений требуют строгого соблюдения требований к безопасности, изоляции данных различных организаций (университетов), гибкости масштабирования и высокой наблюдаемости (observability). В рамках разработки проекта **«Магистр»** — системы управления расписанием учебных занятий — передо мной встала задача проектирования и построения инфраструктуры, способной обслуживать десятки университетов в рамках единого прикладного контура при условии жесткой изоляции их баз данных.
В качестве DevOps-инженера проекта я спроектировал и реализовал архитектурное решение, объединяющее технологии аппаратной виртуализации, оркестрации контейнеров, автоматизации конфигурации, распределенного мониторинга и непрерывной интеграции. В данном документе подробно описаны теоретические предпосылки, практическая реализация и архитектурные решения, внедренные мной в продакшн-окружение проекта.
---
## РАЗДЕЛ 1. СИСТЕМНАЯ АРХИТЕКТУРА И КОНЦЕПЦИЯ МУЛЬТИТЕНАНТНОСТИ
### 1.1 Выбор паттерна изоляции данных
При проектировании мультитенантных (multi-tenant) систем классически выделяют три подхода к организации баз данных:
1. **Shared Database & Shared Schema**: Все клиенты используют общие таблицы, записи разделяются по полю `tenant_id`. Подход дешев в обслуживании, но несет колоссальные риски утечки данных из-за ошибок в SQL-запросах приложения и не позволяет разграничивать физический доступ к базам.
2. **Shared Database & Separate Schemas**: Одна СУБД, но разные логические схемы для каждого клиента. Обеспечивает умеренную изоляцию, но сохраняет единую точку отказа и общие аппаратные ресурсы СУБД.
3. **Database-per-Tenant (Выбранный подход)**: Каждый клиент (университет) владеет собственной физически изолированной базой данных, расположенной на выделенном сервере или виртуальной машине.
Для проекта «Магистр» мной был выбран и реализован паттерн **Database-per-Tenant**. Это обусловлено спецификой клиентов (высшие учебные заведения), которые согласно законодательству (например, ФЗ-152 «О персональных данных») обязаны хранить свои данные локально или требовать полной изоляции конфиденциальной информации. Кроме того, данный подход позволяет:
* Исключить влияние высокой нагрузки одного университета (например, в период составления сессий) на доступность системы для других вузов («синдром шумного соседа»).
* Выполнять индивидуальное резервное копирование и обслуживание БД каждого университета без остановки всей платформы.
* Переносить базы данных вузов на их собственные физические серверы в локальных сетях (on-premise), сохраняя при этом работу приложения в центральном облаке.
### 1.2 Архитектурная схема движения трафика
Ниже представлена разработанная мной схема прохождения сетевых запросов и агрегации телеметрии в системе:
```mermaid
graph TD
Client[Пользовательский браузер] -->|HTTPS: tenant.zuev.company| Caddy[Caddy Reverse Proxy]
Caddy -->|HTTP Round-Robin| Ingress[Traefik Ingress Controller]
subgraph K3s Cluster
Ingress -->|Route /*| Frontend[Frontend Pods x2 Apache]
Ingress -->|Route /api/*| Backend[Backend Pods x1-x2 Spring Boot]
Backend -->|K8s API: Get/Patch CM| K8sAPI[Kubernetes API Server]
BackendConfig[ConfigMap: tenants-config] -.->|Mounted JSON| Backend
end
subgraph Proxmox VE Infrastructure
Backend -->|JDBC Connection| VM1[(VM 1: DBMS MSU<br>PostgreSQL 16)]
Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU<br>PostgreSQL 16)]
VM1 -.->|Metrics OTLP/gRPC| Collector1[OTel Collector VM1]
VM2 -.->|Metrics OTLP/gRPC| Collector2[OTel Collector VM2]
end
subgraph Observability Hub
Backend -->|Logs & Traces OTLP| SigNoz[SigNoz APM Server]
Collector1 -->|Postgres Metrics| SigNoz
Collector2 -->|Postgres Metrics| SigNoz
end
classDef cluster fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef vm fill:#efebe9,stroke:#4e342e,stroke-width:2px;
classDef monitor fill:#efe8ff,stroke:#512da8,stroke-width:2px;
class K3s Cluster cluster;
class Proxmox VE Infrastructure vm;
class Observability Hub monitor;
```
---
## РАЗДЕЛ 2. АППАРАТНАЯ ВИРТУАЛИЗАЦИЯ НА БАЗЕ PROMVOX VE
Для размещения баз данных клиентов и вспомогательных инфраструктурных служб мной был развернут гипервизор **Proxmox Virtual Environment (PVE)** на выделенных серверных мощностях.
### 2.1 Конфигурация виртуализации и изоляции ресурсов
Каждая база данных университета разворачивается в выделенной виртуальной машине (KVM), что гарантирует жесткую изоляцию на уровне ядра ОС и гипервизора.
* **Сетевой уровень**: В Proxmox настроена виртуальная коммутация (Linux Bridge `vmbr0`). Сетевые интерфейсы виртуальных машин баз данных вынесены в приватную подсеть, изолированную от внешней сети. Доступ к ним имеет только подсеть кластера Kubernetes и управляющая машина администратора.
* **Дисковая подсистема**: Использовано локальное хранилище на базе ZFS с зеркалированием (RAID-10 на SSD-накопителях). Это обеспечивает:
* Высокую скорость операций ввода-вывода (IOPS), критически важную для СУБД при транзакционных нагрузках.
* Механизм мгновенных снимков (snapshots) для безопасного проведения обновлений.
* Аппаратное сжатие данных (LZ4) без потери производительности, снижающее износ накопителей.
* **Резервное копирование**: Интегрирован **Proxmox Backup Server (PBS)**. Каждую ночь выполняются инкрементальные бэкапы виртуальных машин с дедупликацией на уровне блоков данных, что минимизирует нагрузку на сеть и диски, позволяя восстановить любую БД на любой момент времени за последние 30 дней.
---
## РАЗДЕЛ 3. ОРКЕСТРАЦИЯ КОНТЕЙНЕРНОЙ ИНФРАСТРУКТУРЫ В KUBERNETES (K3s)
В качестве платформы оркестрации приложений был выбран **K3s** — сертифицированный дистрибутив Kubernetes от Rancher, оптимизированный под средние и малые нагрузки, обладающий минимальными накладными расходами на RAM/CPU для Control Plane, но сохраняющий полную совместимость с upstream-спецификациями Kubernetes API.
### 3.1 Архитектура манифестов и структура ресурсов
Мной была разработана декларативная структура ресурсов, развернутая в изолированном пространстве имен `magistr` (манифест `namespace.yaml`):
* **Frontend (`frontend.yaml`)**:
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd; версия и manifest digest закреплены в Dockerfile.
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
* **Backend (`backend.yaml`)**:
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
* Для сборки образов используется multi-stage сборка Maven (JDK 17); build/runtime-образы одновременно закреплены точным tag и manifest digest.
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
* **Ingress (`ingress.yaml`)**:
* В роли Ingress-контроллера выступает встроенный **Traefik** (`kube-system/traefik`).
* Он доступен извне через Klipper Service LoadBalancer (DaemonSet), который прокидывает порты 80/443 (hostPort) на внешние IP адреса нод кластера (`192.168.1.104`, `192.168.1.105`, `192.168.1.106`).
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` и `n8n.zuev.company`). Запросы к API (`/api`) проксируются на бэкенд, а к корню (`/`) — на фронтенд.
### 3.2 Реализация динамической мультитенантности через K8s API
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
1. **Хранение конфигурации**:
Список подключений к базам данных университетов хранится в JSON-формате (`tenants.json`) внутри Kubernetes `ConfigMap` с именем `tenants-config`.
2. **Проблема Read-Only монтирования**:
По умолчанию Kubernetes монтирует ConfigMap как файловую систему в режиме Read-Only. Это исключало возможность для Java-приложения перезаписывать `tenants.json` при запросах от администратора на добавление нового тенанта.
3. **Решение с использованием `initContainer` и `emptyDir`**:
Я спроектировал схему с временным перезаписываемым томом (`emptyDir`):
* В спецификацию пода backend добавлен `initContainer` на базе образа `busybox`.
* При старте пода `initContainer` монтирует ConfigMap `tenants-config` и копирует файл `tenants.json` во временный раздел `emptyDir` (путь `/config`).
* Основной контейнер `backend` монтирует этот же `emptyDir` в режиме Read-Write.
4. **K8s RBAC и динамическое сохранение**:
Чтобы предоставить бэкенду возможность сохранять изменения конфигурации обратно в кластер, мной были написаны манифесты авторизации (`rbac.yaml`):
* Создан `ServiceAccount` `backend-sa` и назначен поду бэкенда.
* Описана Kubernetes `Role` `backend-configmap-role`, дающая права только на операции `get` и `patch` для ресурса `configmaps` с конкретным именем `tenants-config` (принцип наименьших привилегий).
* Создан `RoleBinding` `backend-configmap-binding`, связывающий сервис-аккаунт с этой ролью.
В коде бэкенда класс `ConfigMapUpdater` через Kubernetes Java Client отправляет PATCH-запрос к API-серверу при добавлении нового тенанта, обновляя ConfigMap в самом кластере.
5. **Репликация и синхронизация подов**:
Для обеспечения консистентности данных в условиях работы нескольких реплик бэкенда:
* Класс `TenantConfigWatcher` запускает фоновую задачу, которая каждые 30 секунд сверяет MD5-хеш локального файла `tenants.json` с версией из ConfigMap.
* При обнаружении расхождения файл перезаписывается, DataSource-инстансы пересоздаются в памяти приложения на лету, исключая рассинхронизацию подов.
* *Примечание*: В ходе тестирования для минимизации задержек и исключения эффекта "мигания" данных на время отладки количество реплик backend было ограничено до 1, с последующим переходом на полноценный StatefulSet/Shared Storage при росте количества нод.
### 3.3 Повышение отказоустойчивости бэкенда (Resiliency)
При запуске бэкенда в облаке возникала проблема: если база данных одного из университетов становилась недоступной (например, из-за сетевого сбоя или регламентных работ), пул подключений HikariCP падал с ошибкой при инициализации бинов, отправляя под бэкенда в циклическую перезагрузку (`CrashLoopBackOff`), что делало недоступной систему для *всех* остальных вузов.
Для предотвращения этого каскадного сбоя я внедрил комплекс защитных механизмов:
* **H2 Fallback**: В сборку (`pom.xml`) добавлена СУБД H2 in-memory. Если при старте ConfigMap пуст, Spring Boot инициализирует пустой JPA-контекст на базе H2, что позволяет приложению успешно пройти фазу запуска.
* **HikariCP Resiliency**: В конфигурации источников данных задано свойство `setInitializationFailTimeout(-1)`. Это заставляет HikariCP игнорировать отсутствие связи с целевой БД при старте приложения, продолжая попытки подключения в фоновом режиме.
* **Явное указание диалекта Hibernate**: Принудительно задан `org.hibernate.dialect.PostgreSQLDialect` в `TenantDataSourceConfig.java`. Это избавило Hibernate от необходимости выполнять тестовый запрос к БД для автоматического определения диалекта на ранних этапах инициализации контекста.
* **Автоматическое создание таблиц (`DataInitializer`)**: Для новых БД вузов полностью автоматизирован процесс наката структуры. При обнаружении новой базы Java-код проверяет наличие таблицы `users` и, в случае ее отсутствия, самостоятельно применяет SQL-скрипт инициализации (`init.sql`), включая создание дефолтных справочников и учетной записи суперадминистратора.
---
## РАЗДЕЛ 4. АВТОМАТИЗАЦИЯ КОНФИГУРАЦИИ СУБД С ПОМОЩЬЮ ANSIBLE
Для развертывания и администрирования баз данных университетов на удаленных виртуальных машинах Proxmox мной был спроектирован и реализован комплексный плейбук **Ansible** с модульной структурой ролей.
### 4.1 Структура и функционал Ansible-ролей
Вся конфигурация целевого сервера разбита на 6 последовательных этапов (плейбук `site.yml`):
1. **`common` (Базовая подготовка и Hardening ОС)**:
* Создание системного пользователя `deploy` с беспарольным доступом к `sudo` (через валидацию файла `/etc/sudoers.d/deploy` утилитой `visudo`).
* Установка системных утилит (`curl`, `git`, `htop`, `chrony` для синхронизации времени по NTP).
* **SSH Hardening**: Изменение стандартного SSH-порта на `2222`, отключение авторизации по паролям, запрет входа для пользователя `root`, ограничение таймаута сессий.
* Адаптация под различные дистрибутивы ОС: написаны отдельные сценарии для семейств Debian/Ubuntu/Astra Linux (пакетный менеджер `apt`), RedHat/РЕД ОС (`dnf`) и ALT Linux (кастомная обертка для `apt-get`).
2. **`docker` (Среда контейнеризации)**:
* Автоматическое добавление официальных GPG-ключей и репозиториев Docker CE.
* Установка Docker Engine, CLI и Compose плагина.
* Включение системной службы `docker` и добавление пользователя `deploy` в группу `docker` для беспарольного запуска контейнеров.
3. **`firewall` (Сетевая безопасность)**:
* Настройка межсетевого экрана UFW (для Debian-based систем) или firewalld (для RedHat и ALT Linux).
* Полная блокировка входящего трафика по умолчанию.
* Разрешение входящих пакетов только на порт SSH (`2222`) и порт PostgreSQL (`5432`). Реализована поддержка ограничения доступа к порту `5432` только для IP-адресов нод Kubernetes-кластера (`firewall_postgresql_allowed_ips`).
4. **`postgresql` (Контейнеризированная СУБД)**:
* Генерация файлов конфигурации `docker-compose.yml` и `.env` на основе шаблонов Jinja2.
* Развертывание PostgreSQL 16 на базе легковесного Alpine-образа.
* Физическое монтирование каталога данных БД с хост-системы (`/opt/magistr/postgres/data`), предотвращающее потерю данных при пересоздании контейнера.
* Ограничение системных ресурсов контейнера (limits: memory: 512M) и тюнинг параметров PostgreSQL (`shared_buffers=256MB`, оптимизация пула соединений, логирование медленных запросов `log_min_duration_statement=1000`).
5. **`backup` (Резервное копирование на уровне БД)**:
* Установка на хост автоматического bash-скрипта бэкапа.
* Настройка задачи `cron` (запуск ежедневно в 03:00).
* Скрипт осуществляет горячий дамп базы через `docker exec pg_dump`, архивирует его с помощью `gzip` и сохраняет в локальный каталог `/opt/magistr/backups/`.
* Реализован алгоритм автоматической ротации: удаление архивных файлов старше заданного количества дней (по умолчанию 14 дней).
6. **`monitoring` (Сбор телеметрии хоста и БД)**:
* Развертывание контейнера OpenTelemetry Collector на целевом сервере БД.
* Конфигурирование коллектора на сбор метрик производительности PostgreSQL и ОС (процессор, память, диски) и их отправку на единый сервер SigNoz по OTLP/HTTP.
### 4.2 Управление секретами: Ansible Vault
Для исключения попадания паролей администраторов БД в систему контроля версий (Git) все чувствительные переменные (например, `vault_postgresql_password`) вынесены в файл `group_vars/databases/vault.yml` и зашифрованы алгоритмом AES-256 с помощью **Ansible Vault**.
Для бесшовной интеграции в CI/CD пароль расшифрования считывается из локального файла `.vault_pass` на управляющей машине, доступ к которому ограничен правами `chmod 600`.
---
## РАЗДЕЛ 5. НЕПРЕРЫВНАЯ ИНТЕГРАЦИЯ И ДОСТАВКА (CI/CD)
В рамках оптимизации внутренних процессов разработки и деплоя мной была развернута и настроена инфраструктура непрерывной интеграции на базе **Gitea** и **Gitea Actions**.
### 5.1 Автоматизация сборки (CI)
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
1. **Checks**: Backend/frontend-тесты, Compose validation, тест immutable rollback и статическая проверка закрепления артефактов.
2. **Build & Push**: Параллельная сборка и публикация backend/frontend с SHA-tag или release tag; mutable `latest` не создаётся.
3. **Attestations**: BuildKit добавляет к обоим образам SBOM и максимальную provenance-attestation.
4. **Security scan**: Закреплённый digest Trivy блокирует доставку при исправимых уязвимостях `HIGH`/`CRITICAL`.
5. **Deploy**: Только прошедшие gates registry digests передаются в production rollout. Все сторонние Actions закреплены полными commit SHA.
### 5.2 Доставка в кластер (CD)
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет `gitea-registry` типа `docker-registry` в пространстве имен `magistr`. Этот секрет ассоциирован со спецификациями деплоев через директиву `imagePullSecrets`.
Непосредственно Gitea Actions Runner работает как демон `act_runner` внутри изолированного LXC контейнера (CTID 107). В пайплайне шаг развертывания (`deploy-to-k8s`) динамически генерирует `kubeconfig` из секрета, проверяет checksum закреплённой версии `kubectl` и выполняет атомарное обновление обоих образов без использования тяжеловесных GitOps операторов:
```bash
bash scripts/deploy-images.sh \
gitea.zuev.company/zuev/magistr-backend@sha256:<digest> \
gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>
```
Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при
ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного
разрешения mutable tag.
---
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
Для маршрутизации внешнего трафика и защиты соединений на входе в инфраструктуру развернут веб-сервер **Caddy**.
### 6.1 Преимущества Caddy и управление SSL/TLS
В отличие от классического Nginx, Caddy был выбран благодаря:
* Встроенной интеграции с удостоверяющими центрами Let's Encrypt и ZeroSSL. При добавлении нового поддомена вуза (например, `swsu.zuev.company`) Caddy автоматически запрашивает, валидирует через DNS/HTTP-вызовы и устанавливает TLS-сертификат, а также следит за его продлением.
* Лаконичному синтаксису конфигурации (`Caddyfile`).
* Полноценной поддержке протокола HTTP/3 «из коробки».
### 6.2 Конфигурация балансировки
Внешний Caddy-сервер развернут в изолированном LXC контейнере (CTID 101) и обрабатывает запросы ко всем поддоменам проекта. Он перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика, а также проксирует телеметрию с фронтенда напрямую в сборщик OTel:
```caddyfile
(otel_proxy) {
handle_path /otel/* { reverse_proxy 192.168.1.100:4318 }
}
(k8s_nodes) {
handle {
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
}
}
}
magistr.zuev.company { import otel_proxy; import k8s_nodes }
n8n.zuev.company { import otel_proxy; import k8s_nodes }
```
Сетевые запросы к приложениям распределяются по 3 физическим нодам K3s. Благодаря сниппетам, добавление нового тенанта в инфраструктуре Caddy требует всего двух строчек конфигурации.
---
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** (с хранилищем **ClickHouse**) и стандартов **OpenTelemetry (OTel)**.
Платформа SigNoz вынесена в отдельный LXC контейнер (CTID 100) по адресу `192.168.1.100` и развернута через `docker-compose`. Данные ClickHouse и SQLite метаданные персистентно хранятся в Docker volumes хоста для обеспечения сохранности при рестартах.
### 7.1 Сбор телеметрии Backend
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
* **Трейсы (Traces)**: Сквозное прохождение HTTP-запросов через контроллеры, сервисы и JDBC-драйвер к базам данных.
* **Метрики (Metrics)**: Метрики JVM (утилизация Heap, активность сборщика мусора GC, состояние потоков), метрики HTTP-запросов (интенсивность, латентность, коды ответов).
* **Логи (Logs)**: Системные логи Logback экспортируются по протоколу OTLP напрямую в SigNoz.
### 7.2 Идентификация тенантов в мониторинге
Для глубокого анализа производительности СУБД конкретных университетов критически важно разделять телеметрию по тенантам. Мной была реализована сквозная маркировка:
* В бэкенде через Java-интерцептор `TenantInterceptor` при каждом запросе идентификатор тенанта помещается в контекст логирования SLF4J MDC (`MDC.put("tenant.id", tenant)`) и одновременно записывается в атрибуты текущего OpenTelemetry спана (`Span.current().setAttribute("tenant.id", tenant)`).
* Благодаря переменной окружения `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=tenant.id`, OTel-агент автоматически парсит MDC и прикрепляет его к логам, отправляемым в SigNoz. Это позволяет администраторам фильтровать ошибки и строить графики нагрузки в разрезе каждого университета.
### 7.3 Мониторинг баз данных на хостах
Для сбора детальной статистики с изолированных серверов баз данных:
1. На хостах БД силами Ansible разворачивается OpenTelemetry Collector.
2. В конфигурации коллектора (`otel-collector-config.yml.j2`) описывается ресивер `postgresql`, который подключается к локальной БД и считывает системные метрики (размер таблиц, cache hit ratio, количество транзакций, активные сессии, блокировки).
3. К собираемым метрикам жестко прикрепляются ресурсные атрибуты хоста: `service.name=magistr-db-<tenant_domain>` и `university.name=<University Name>`.
4. Метрики экспортируются в центральный коллектор SigNoz. В результате в интерфейсе SigNoz доступны кастомные дашборды JVM, PostgreSQL и HTTP, позволяющие оперативно локализовать проблемы с производительностью баз данных конкретных вузов.
---
## ЗАКЛЮЧЕНИЕ
Разработанная и внедренная мной DevOps-архитектура для проекта «Магистр» успешно решила задачи изоляции данных, отказоустойчивости и автоматизации администрирования.
### Ключевые результаты проделанной работы:
1. **Изоляция данных**: Реализована физическая изоляция БД университетов на выделенных серверах (VM в Proxmox VE), соответствующая требованиям безопасности и законодательства.
2. **Динамическое масштабирование**: Внедрен механизм динамического добавления тенантов без простоя системы (zero-downtime) за счет связки Spring Boot, K8s RBAC и ConfigMapWatcher.
3. **Отказоустойчивость**: Минимизированы риски каскадных сбоев бэкенда за счет применения in-memory fallback баз данных и оптимизации параметров подключения HikariCP.
4. **Автоматизация**: Создан универсальный Ansible-плейбук для быстрого ввода в эксплуатацию новых серверов БД (время развертывания «под ключ» сокращено до нескольких минут).
5. **Наблюдаемость**: Реализован сквозной мониторинг логов, метрик и трассировок с детализацией по тенантам на базе стека OpenTelemetry + SigNoz.
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».

118
STARTUP_GUIDE.md Normal file
View File

@@ -0,0 +1,118 @@
# Инструкция запуска Magistr
Ниже — минимальный порядок действий. Подробности по инфраструктуре и секретам находятся в
[`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) и
[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md).
## 1. Локальный запуск
Нужны Git, Docker Engine, Docker Compose и запущенный Caddy из `../сaddy-proxy/`.
1. В корне проекта создайте локальный файл настроек:
```bash
cp .env.example .env
openssl rand -base64 36 # значение POSTGRES_PASSWORD
openssl rand -base64 48 # значение JWT_SECRET
```
Вставьте полученные значения в `.env`. Файл `.env` не коммитьте.
2. Один раз создайте общую Docker-сеть, затем запустите Caddy и Magistr:
```bash
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
docker compose config --quiet
docker compose up -d --build
docker compose ps
```
3. Откройте `https://localhost` (`http://localhost` перенаправит на HTTPS). Проверьте backend
через Caddy:
```bash
curl -kfsS https://localhost/actuator/health/readiness
```
Если браузер не доверяет локальному сертификату Caddy, на CachyOS/Arch выполните:
```bash
docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-local-root.crt
sudo trust anchor --store /tmp/caddy-local-root.crt
```
До установки проверьте SHA-256 отпечаток командой
`openssl x509 -in /tmp/caddy-local-root.crt -noout -fingerprint -sha256`, затем полностью
перезапустите браузер.
Для первого локального входа: `admin` / `admin`. Эти данные предназначены только для
разработки.
4. Если запуск не удался:
```bash
docker compose logs --tail=200 backend db frontend
```
Полный локальный сброс БД выполняется командой `docker compose down -v`, но она удалит
все локальные данные. Обычная остановка без удаления данных: `docker compose down`.
## 2. Первый запуск в production
1. Подготовьте K3s/Kubernetes-кластер и убедитесь, что `kubectl get nodes` работает.
PostgreSQL в текущие манифесты не входит: заранее создайте доступную из кластера БД для
каждого университета. Пользователь БД должен иметь права на создание и изменение схемы —
Flyway применит `V1__init.sql` автоматически.
2. Настройте DNS для `magistr.zuev.company` и остальных tenant-доменов на публичный
reverse proxy/Ingress. В текущем `../k8s/ingress.yaml` нет секции TLS: для HTTPS нужно
настроить внешний Caddy с сертификатом либо добавить cert-manager/TLS в кластер.
3. Создайте namespace и четыре обязательных Secret по
[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md):
- `app-secret`: `JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD`;
- `tenants-secret`: файл `tenants.json`;
- `otel-postgres-secret`: подключения Collector к БД;
- `gitea-registry`: доступ к приватным Docker-образам.
Для `magistr.zuev.company` запись в `tenants.json` должна иметь `"domain": "magistr"`
и реальный JDBC URL. Не сохраняйте значения Secret в Git или логах.
4. В Gitea Actions добавьте секреты `ZUEV_TOKEN` (доступ к registry) и
`KUBECONFIG_DATA` (kubeconfig в Base64). Push в `main` соберёт, проверит и опубликует
образы. При самом первом запуске автоматический deploy может ещё не найти Deployment —
возьмите два опубликованных digest из CI/registry и выполните bootstrap вручную:
```bash
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<digest>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>'
K8S_DIR=../k8s bash scripts/check-production-secrets.sh
K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh
kubectl kustomize ../k8s >/dev/null
bash ../k8s/deploy.sh apply
```
5. Проверьте результат:
```bash
bash ../k8s/deploy.sh status
kubectl rollout status deployment/backend -n magistr --timeout=300s
kubectl logs -n magistr -l app=backend --tail=200
curl -fsS https://magistr.zuev.company/
```
После первого входа немедленно смените/отключите все тестовые пароли из `V1__init.sql`.
Следующие push в `main` CI/CD сможет развёртывать автоматически.
## 3. Что могу выполнить я
По вашей команде я могу подготовить `.env`, запустить и диагностировать локальный Compose,
проверить/исправить код и манифесты, прогнать тесты и dry-run Kubernetes, а при доступном
`kubectl` — проверить состояние кластера.
Без вас я не могу получить реальные пароли и токены, настроить DNS/сертификаты у внешнего
провайдера или войти в Gitea/Kubernetes, если доступы не предоставлены. Production deploy,
ротацию Secret и удаление БД я не выполняю без отдельного явного разрешения: это меняет
внешнюю систему и может прервать работу либо уничтожить данные.

View File

@@ -1,12 +1,27 @@
FROM maven:3.9-eclipse-temurin-17 AS build
FROM maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
FROM eclipse-temurin:17-jre-alpine
ARG OTEL_JAVAAGENT_VERSION=2.28.1
ARG OTEL_JAVAAGENT_SHA256=faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589
RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
-Dartifact=io.opentelemetry.javaagent:opentelemetry-javaagent:${OTEL_JAVAAGENT_VERSION} \
-DoutputDirectory=/app \
-Dmdep.stripVersion=true \
-B \
&& echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c -
FROM eclipse-temurin:17-jre-alpine@sha256:02320dd4ce20e243dfb915c686089cf9315c763084fafbb12d5c9993aee18b57
# Best practice: run as a non-root user
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]

View File

@@ -27,11 +27,23 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Стандартные liveness/readiness probes Kubernetes -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- JPA + PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Flyway Database Migrations -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
@@ -43,6 +55,55 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- JWT/JWS support without enabling full Spring Security auto-flow -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- OpenTelemetry API for custom span attributes -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>1.49.0</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-tls</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -2,8 +2,12 @@ package com.magistr.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
@EnableScheduling
public class Application {
public static void main(String[] args) {

View File

@@ -1,50 +0,0 @@
package com.magistr.app.config;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) {
Optional<User> existing = userRepository.findByUsername("admin");
if (existing.isEmpty()) {
User admin = new User();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Created default admin user");
} else {
User admin = existing.get();
if (!passwordEncoder.matches("admin", admin.getPassword())) {
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRole(Role.ADMIN);
userRepository.save(admin);
log.info("Reset admin password (hash was invalid)");
} else {
log.info("Admin user already exists with correct password");
}
}
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.config.auth;
public final class AuthContext {
private static final ThreadLocal<AuthenticatedUser> CURRENT_USER = new ThreadLocal<>();
private AuthContext() {
}
public static void setCurrentUser(AuthenticatedUser user) {
CURRENT_USER.set(user);
}
public static AuthenticatedUser getCurrentUser() {
return CURRENT_USER.get();
}
public static Long currentUserId() {
AuthenticatedUser user = getCurrentUser();
return user == null ? null : user.id();
}
public static void clear() {
CURRENT_USER.remove();
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) {
}

View File

@@ -0,0 +1,95 @@
package com.magistr.app.config.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.model.Role;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper;
public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
AuthContext.clear();
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (isPublicAuthEndpoint(request)) {
return true;
}
String token = bearerToken(request.getHeader("Authorization"));
AuthenticatedUser user = jwtTokenService.authenticate(
token,
com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
).orElse(null);
if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false;
}
RequireRoles roles = resolveRoles(handler);
if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) {
writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции");
return false;
}
AuthContext.setCurrentUser(user);
return true;
}
private boolean isPublicAuthEndpoint(HttpServletRequest request) {
if (!request.getRequestURI().startsWith("/api/auth/")) {
return false;
}
return "POST".equalsIgnoreCase(request.getMethod())
&& (request.getRequestURI().equals("/api/auth/login")
|| request.getRequestURI().equals("/api/auth/refresh")
|| request.getRequestURI().equals("/api/auth/logout"));
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
}
private RequireRoles resolveRoles(Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return null;
}
RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class);
if (methodRoles != null) {
return methodRoles;
}
return handlerMethod.getBeanType().getAnnotation(RequireRoles.class);
}
private String bearerToken(String header) {
if (header == null || !header.startsWith("Bearer ")) {
return null;
}
return header.substring("Bearer ".length()).trim();
}
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getOutputStream(), Map.of("message", message));
}
}

View File

@@ -0,0 +1,146 @@
package com.magistr.app.config.auth;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
*/
@Component
public class ClientIpResolver {
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
private static final int MAX_PROXY_HOPS = 20;
private static final String UNKNOWN_ADDRESS = "неизвестен";
private final List<CidrRange> trustedProxyRanges;
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
}
public String resolve(HttpServletRequest request) {
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
return fallback;
}
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded == null || forwarded.isBlank()) {
return fallback;
}
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
return fallback;
}
String[] hops = forwarded.split(",", -1);
if (hops.length > MAX_PROXY_HOPS) {
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
return fallback;
}
InetAddress leftmost = null;
for (int index = hops.length - 1; index >= 0; index--) {
Optional<InetAddress> hop = parseAddress(hops[index].trim());
if (hop.isEmpty()) {
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
return fallback;
}
leftmost = hop.get();
if (!isTrusted(hop.get())) {
return hop.get().getHostAddress();
}
}
return leftmost == null ? fallback : leftmost.getHostAddress();
}
private boolean isTrusted(InetAddress address) {
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
}
private List<CidrRange> parseTrustedRanges(String rawRanges) {
if (rawRanges == null || rawRanges.isBlank()) {
return List.of();
}
List<CidrRange> ranges = new ArrayList<>();
for (String rawRange : rawRanges.split(",")) {
String value = rawRange.trim();
if (!value.isEmpty()) {
ranges.add(CidrRange.parse(value));
}
}
return List.copyOf(ranges);
}
private static Optional<InetAddress> parseAddress(String rawAddress) {
if (rawAddress == null) {
return Optional.empty();
}
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
if (value.startsWith("[") && value.endsWith("]")) {
value = value.substring(1, value.length() - 1);
}
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
return Optional.empty();
}
try {
return Optional.of(InetAddress.getByName(value));
} catch (UnknownHostException ignored) {
return Optional.empty();
}
}
private record CidrRange(byte[] network, int prefixLength) {
static CidrRange parse(String value) {
String[] parts = value.split("/", -1);
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
if (address.isEmpty()) {
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
}
int addressBits = address.get().getAddress().length * 8;
int prefix;
try {
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
} catch (NumberFormatException exception) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
}
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
}
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
}
boolean contains(InetAddress candidate) {
byte[] candidateBytes = candidate.getAddress();
return candidateBytes.length == network.length
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
}
private static byte[] mask(byte[] address, int prefix) {
byte[] masked = Arrays.copyOf(address, address.length);
int fullBytes = prefix / 8;
int remainingBits = prefix % 8;
if (fullBytes < masked.length && remainingBits > 0) {
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
fullBytes++;
}
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
return masked;
}
}
}

View File

@@ -0,0 +1,114 @@
package com.magistr.app.config.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
@Component
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private static final Set<String> LEGACY_INSECURE_SECRET_FINGERPRINTS = Set.of(
"884e52f1fbb3b0caf409bc76a14ba8e601d6117be5a9188319f2e84f9e19a853",
"54f5809ada311ba1e12146a14db39ede2082b6f73ca7749bae8cd8450726eaf9"
);
private static final Set<String> PLACEHOLDER_MARKERS = Set.of(
"change-me",
"change_this",
"change-this",
"dev-only",
"placeholder",
"replace-with",
"replace_this",
"replace-this"
);
private String secret;
private Duration accessTtl = Duration.ofMinutes(15);
private Duration refreshTtl = Duration.ofDays(7);
private String refreshCookieName = "magistr_refresh";
private boolean refreshCookieSecure = false;
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public Duration getAccessTtl() {
return accessTtl;
}
public void setAccessTtl(Duration accessTtl) {
this.accessTtl = accessTtl;
}
public Duration getRefreshTtl() {
return refreshTtl;
}
public void setRefreshTtl(Duration refreshTtl) {
this.refreshTtl = refreshTtl;
}
public String getRefreshCookieName() {
return refreshCookieName;
}
public void setRefreshCookieName(String refreshCookieName) {
this.refreshCookieName = refreshCookieName;
}
public boolean isRefreshCookieSecure() {
return refreshCookieSecure;
}
public void setRefreshCookieSecure(boolean refreshCookieSecure) {
this.refreshCookieSecure = refreshCookieSecure;
}
public byte[] secretBytes() {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("JWT_SECRET обязателен и не может быть пустым");
}
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
}
String normalized = secret.toLowerCase(Locale.ROOT);
if (PLACEHOLDER_MARKERS.stream().anyMatch(normalized::contains)
|| LEGACY_INSECURE_SECRET_FINGERPRINTS.contains(sha256(bytes))) {
throw new IllegalStateException("JWT_SECRET содержит известное небезопасное или шаблонное значение");
}
return bytes;
}
public void validateForEnvironment(boolean production) {
secretBytes();
if (production && !refreshCookieSecure) {
throw new IllegalStateException(
"В production-профиле JWT_REFRESH_COOKIE_SECURE должен иметь значение true"
);
}
}
private String sha256(byte[] value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", e);
}
}
}

View File

@@ -0,0 +1,24 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
@Component
public class JwtSecurityStartupValidator {
private final JwtProperties properties;
private final Environment environment;
public JwtSecurityStartupValidator(JwtProperties properties, Environment environment) {
this.properties = properties;
this.environment = environment;
}
@PostConstruct
void validate() {
boolean production = environment.acceptsProfiles(Profiles.of("prod", "production"));
properties.validateForEnvironment(production);
}
}

View File

@@ -0,0 +1,110 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Service
public class JwtTokenService {
private static final String ISSUER = "magistr";
private final JwtProperties properties;
private final JwtEncoder encoder;
private final JwtDecoder decoder;
public JwtTokenService(JwtProperties properties) {
this.properties = properties;
SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256");
this.encoder = new NimbusJwtEncoder(new ImmutableSecret<SecurityContext>(secretKey));
this.decoder = NimbusJwtDecoder
.withSecretKey(secretKey)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
public String createAccessToken(User user, String tenant) {
return createAccessToken(new AuthenticatedUser(
user.getId(),
user.getUsername(),
user.getRole(),
user.getDepartmentId()
), tenant);
}
public String createAccessToken(AuthenticatedUser user, String tenant) {
Instant now = Instant.now();
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
.issuer(ISSUER)
.issuedAt(now)
.expiresAt(now.plus(properties.getAccessTtl()))
.subject(String.valueOf(user.id()))
.id(UUID.randomUUID().toString())
.claim("tenant", tenant)
.claim("userId", user.id())
.claim("username", user.username())
.claim("role", user.role().name());
if (user.departmentId() != null) {
claims.claim("departmentId", user.departmentId());
}
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue();
}
public Optional<AuthenticatedUser> authenticate(String token, String expectedTenant) {
if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) {
return Optional.empty();
}
try {
Jwt jwt = decoder.decode(token);
String tenant = jwt.getClaimAsString("tenant");
if (!expectedTenant.equalsIgnoreCase(tenant)) {
return Optional.empty();
}
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
String username = jwt.getClaimAsString("username");
Role role = Role.valueOf(jwt.getClaimAsString("role"));
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
} catch (JwtException | IllegalArgumentException e) {
return Optional.empty();
}
}
private Optional<Long> asLong(Object value) {
if (value == null) {
return Optional.empty();
}
if (value instanceof Number number) {
return Optional.of(number.longValue());
}
try {
return Optional.of(Long.valueOf(String.valueOf(value)));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,167 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
@Component
public class LoginAttemptAuditCleanupJob {
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthLoginAttemptAuditRepository auditRepository;
private final AuthLoginRateLimitRepository rateLimitRepository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public LoginAttemptAuditCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
batchSize, maxBatchesPerTenant, Clock.systemUTC());
}
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
AuthLoginAttemptAuditRepository auditRepository,
AuthLoginRateLimitRepository rateLimitRepository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(routingDataSource);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock);
}
@Scheduled(
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
Instant now = clock.instant();
Instant cutoff = now.minus(retention);
int deletedAudits = 0;
int deletedLimits = 0;
int failedTenants = 0;
for (String tenant : tenants.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedAudits += cleanupAudit(tenant, cutoff);
deletedLimits += cleanupLimits(tenant, cutoff, now);
} catch (RuntimeException failure) {
failedTenants++;
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, failure.getClass().getSimpleName());
log.debug("Технические детали очистки аудита входа", failure);
} finally {
TenantContext.clear();
}
}
if (deletedAudits + deletedLimits > 0) {
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
tenants.size(), deletedAudits, deletedLimits, failedTenants);
}
return new CleanupSummary(
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupAudit(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
return deletedTotal;
}
private void restoreTenant(String tenant) {
if (tenant == null || tenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(tenant);
}
}
public record CleanupSummary(int tenantCount,
int auditDeletedCount,
int rateLimitDeletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -0,0 +1,226 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@Service
public class LoginRateLimitService {
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
private static final String OUTCOME_FAILURE = "FAILURE";
private static final String OUTCOME_BLOCKED = "BLOCKED";
private final AuthLoginRateLimitRepository rateLimitRepository;
private final AuthLoginAttemptAuditRepository auditRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginSecurityProperties properties;
private final Clock clock;
private final String dummyPasswordHash;
@Autowired
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties) {
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
properties, Clock.systemUTC());
}
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
AuthLoginAttemptAuditRepository auditRepository,
UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
LoginSecurityProperties properties,
Clock clock) {
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
this.auditRepository = Objects.requireNonNull(auditRepository);
this.userRepository = Objects.requireNonNull(userRepository);
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
this.properties = Objects.requireNonNull(properties);
this.clock = Objects.requireNonNull(clock);
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
}
/**
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
*/
@Transactional
public AuthenticationDecision authenticate(String tenant,
String username,
String rawPassword,
String clientIp) {
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
Instant now = clock.instant();
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
AuthLoginRateLimit state = rateLimitRepository
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
state.setFailureCount(0);
state.setWindowStartedAt(now);
state.setBlockedUntil(null);
}
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
boolean passwordMatches = passwordEncoder.matches(
rawPassword == null ? "" : rawPassword,
storedHash
);
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
if (authenticated) {
rateLimitRepository.delete(state);
return AuthenticationDecision.authenticated(user.get());
}
int failureCount = state.getFailureCount() + 1;
state.setFailureCount(failureCount);
state.setLastFailureAt(now);
state.setUpdatedAt(now);
if (failureCount >= properties.getMaxFailures()) {
Duration blockDuration = progressiveBlockDuration(failureCount);
Instant blockedUntil = now.plus(blockDuration);
state.setBlockedUntil(blockedUntil);
long retryAfter = retryAfterSeconds(now, blockedUntil);
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
return AuthenticationDecision.blocked(retryAfter);
}
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
return AuthenticationDecision.rejected();
}
private Duration progressiveBlockDuration(int failureCount) {
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
long multiplier = 1L << exponent;
Duration candidate;
try {
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
} catch (ArithmeticException overflow) {
return properties.getMaxBlockDuration();
}
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
? properties.getMaxBlockDuration()
: candidate;
}
private void audit(String tenant,
String username,
String clientIp,
String outcome,
Instant occurredAt,
Long retryAfterSeconds) {
Integer retryAfter = retryAfterSeconds == null
? null
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
auditRepository.save(new AuthLoginAttemptAudit(
tenant, username, clientIp, outcome, occurredAt, retryAfter
));
}
private void logDenied(String tenant,
String username,
String clientIp,
String outcome,
Long retryAfterSeconds) {
log.warn(
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
tenant,
fingerprint(username),
clientIp,
outcome,
retryAfterSeconds
);
}
private String normalizeAndLimit(String value, int maxLength, String fallback) {
String bounded = value == null
? fallback
: value.substring(0, Math.min(value.length(), maxLength * 4));
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
.replaceAll("\\p{C}", "")
.strip()
.toLowerCase(Locale.ROOT);
if (normalized.isBlank()) {
normalized = fallback;
}
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
Duration remaining = Duration.between(now, blockedUntil);
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
return Math.max(1, seconds);
}
private String fingerprint(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
}
}
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
static AuthenticationDecision authenticated(User user) {
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
}
static AuthenticationDecision rejected() {
return new AuthenticationDecision(Status.REJECTED, null, 0);
}
static AuthenticationDecision blocked(long retryAfterSeconds) {
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
}
public enum Status {
AUTHENTICATED,
REJECTED,
BLOCKED
}
}
}

View File

@@ -0,0 +1,70 @@
package com.magistr.app.config.auth;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "app.login-security")
public class LoginSecurityProperties {
private int maxFailures = 5;
private Duration attemptWindow = Duration.ofMinutes(15);
private Duration baseBlockDuration = Duration.ofMinutes(1);
private Duration maxBlockDuration = Duration.ofMinutes(15);
@PostConstruct
void validate() {
if (maxFailures < 2 || maxFailures > 100) {
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
}
requirePositive(attemptWindow, "Окно учёта попыток входа");
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
throw new IllegalStateException(
"Максимальный срок блокировки входа не может быть меньше начального"
);
}
}
private void requirePositive(Duration duration, String fieldName) {
if (duration == null || duration.isZero() || duration.isNegative()) {
throw new IllegalStateException(fieldName + " должен быть положительным");
}
}
public int getMaxFailures() {
return maxFailures;
}
public void setMaxFailures(int maxFailures) {
this.maxFailures = maxFailures;
}
public Duration getAttemptWindow() {
return attemptWindow;
}
public void setAttemptWindow(Duration attemptWindow) {
this.attemptWindow = attemptWindow;
}
public Duration getBaseBlockDuration() {
return baseBlockDuration;
}
public void setBaseBlockDuration(Duration baseBlockDuration) {
this.baseBlockDuration = baseBlockDuration;
}
public Duration getMaxBlockDuration() {
return maxBlockDuration;
}
public void setMaxBlockDuration(Duration maxBlockDuration) {
this.maxBlockDuration = maxBlockDuration;
}
}

View File

@@ -0,0 +1,163 @@
package com.magistr.app.config.auth;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
*/
@Component
public class RefreshTokenCleanupJob {
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
private final TenantRoutingDataSource routingDataSource;
private final AuthRefreshTokenRepository repository;
private final boolean enabled;
private final Duration retention;
private final int batchSize;
private final int maxBatchesPerTenant;
private final Clock clock;
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
@Autowired
public RefreshTokenCleanupJob(
TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
this(
routingDataSource,
repository,
enabled,
retention,
batchSize,
maxBatchesPerTenant,
Clock.systemUTC()
);
}
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
AuthRefreshTokenRepository repository,
boolean enabled,
Duration retention,
int batchSize,
int maxBatchesPerTenant,
Clock clock) {
this.routingDataSource = Objects.requireNonNull(
routingDataSource,
"Маршрутизатор tenant-БД обязателен"
);
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
if (retention == null || retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
}
if (batchSize < 1 || batchSize > 10_000) {
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
}
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
}
this.enabled = enabled;
this.retention = retention;
this.batchSize = batchSize;
this.maxBatchesPerTenant = maxBatchesPerTenant;
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
}
@Scheduled(
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
)
public void cleanupScheduled() {
if (enabled) {
cleanupNow();
}
}
/**
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
*/
public CleanupSummary cleanupNow() {
if (!cleanupInProgress.compareAndSet(false, true)) {
return new CleanupSummary(0, 0, 0, true);
}
String previousTenant = TenantContext.getCurrentTenant();
try {
TenantContext.clear();
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
Instant cutoff = clock.instant().minus(retention);
int deletedTotal = 0;
int failedTenants = 0;
for (String tenant : tenantSnapshot.keySet()) {
TenantContext.setCurrentTenant(tenant);
try {
deletedTotal += cleanupTenant(tenant, cutoff);
} catch (RuntimeException cleanupFailure) {
failedTenants++;
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
tenant, cleanupFailure.getClass().getSimpleName());
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
} finally {
TenantContext.clear();
}
}
if (deletedTotal > 0) {
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
tenantSnapshot.size(), deletedTotal, failedTenants);
}
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
} finally {
restoreTenant(previousTenant);
cleanupInProgress.set(false);
}
}
private int cleanupTenant(String tenant, Instant cutoff) {
int deletedTotal = 0;
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
deletedTotal += deleted;
if (deleted < batchSize) {
return deletedTotal;
}
}
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
tenant);
return deletedTotal;
}
private void restoreTenant(String previousTenant) {
if (previousTenant == null || previousTenant.isBlank()) {
TenantContext.clear();
} else {
TenantContext.setCurrentTenant(previousTenant);
}
}
public record CleanupSummary(int tenantCount,
int deletedCount,
int failedTenantCount,
boolean skippedBecauseAlreadyRunning) {
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.User;
public record RefreshTokenRotation(User user, String refreshToken) {
}

View File

@@ -0,0 +1,141 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.AuthRefreshToken;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
@Service
public class RefreshTokenService {
private static final int TOKEN_BYTES = 32;
private static final int USER_AGENT_LIMIT = 512;
private static final int IP_LIMIT = 64;
private final SecureRandom secureRandom = new SecureRandom();
private final AuthRefreshTokenRepository repository;
private final JwtProperties properties;
private final Clock clock;
@Autowired
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
this(repository, properties, Clock.systemUTC());
}
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
this.repository = repository;
this.properties = properties;
this.clock = clock;
}
@Transactional
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
String refreshToken = generateRawToken();
AuthRefreshToken token = new AuthRefreshToken();
Instant now = clock.instant();
token.setUser(user);
token.setTenant(tenant);
token.setTokenHash(hashToken(refreshToken));
token.setIssuedAt(now);
token.setExpiresAt(now.plus(properties.getRefreshTtl()));
token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
token.setIpAddress(limit(ipAddress, IP_LIMIT));
repository.save(token);
return refreshToken;
}
@Transactional
public Optional<RefreshTokenRotation> rotate(String rawToken, String tenant, String userAgent, String ipAddress) {
if (rawToken == null || rawToken.isBlank()) {
return Optional.empty();
}
Instant now = clock.instant();
String currentHash = hashToken(rawToken);
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
if (storedOpt.isEmpty()) {
return Optional.empty();
}
AuthRefreshToken stored = storedOpt.get();
if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) {
return Optional.empty();
}
User user = stored.getUser();
if (user.isArchivedRecord()) {
stored.setRevokedAt(now);
repository.save(stored);
return Optional.empty();
}
String newRawToken = generateRawToken();
String newHash = hashToken(newRawToken);
stored.setRevokedAt(now);
stored.setRotatedToTokenHash(newHash);
repository.save(stored);
AuthRefreshToken next = new AuthRefreshToken();
next.setUser(user);
next.setTenant(tenant);
next.setTokenHash(newHash);
next.setIssuedAt(now);
next.setExpiresAt(now.plus(properties.getRefreshTtl()));
next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
next.setIpAddress(limit(ipAddress, IP_LIMIT));
repository.save(next);
return Optional.of(new RefreshTokenRotation(user, newRawToken));
}
@Transactional
public void revoke(String rawToken, String tenant) {
if (rawToken == null || rawToken.isBlank()) {
return;
}
repository.findByTokenHash(hashToken(rawToken))
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
.filter(token -> token.getRevokedAt() == null)
.ifPresent(token -> {
token.setRevokedAt(clock.instant());
repository.save(token);
});
}
String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен", e);
}
}
private String generateRawToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String limit(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String trimmed = value.trim();
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
}
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.Role;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequireRoles {
Role[] value();
}

View File

@@ -0,0 +1,792 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
/**
* Атомарно изменяет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
*
* <p>Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
* списку и отправляет полный объект обратно условным {@code PUT} с
* {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
* состояние и заново применяет только свою мутацию.</p>
*/
@Service
public class KubernetesTenantSecretUpdater implements TenantConfigStore {
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
private static final Path DEFAULT_TOKEN_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token");
private static final Path DEFAULT_NAMESPACE_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/namespace");
private static final Path DEFAULT_CA_PATH =
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
private static final int DEFAULT_MAX_ATTEMPTS = 3;
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofMillis(50);
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
);
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
};
private final ObjectMapper objectMapper;
private final Path tokenPath;
private final Path namespacePath;
private final Path caPath;
private final String apiBase;
private final String secretName;
private final boolean runningInKubernetes;
private final int maxAttempts;
private final Duration retryDelay;
private volatile HttpClient httpClient;
public KubernetesTenantSecretUpdater() {
this(
DEFAULT_TOKEN_PATH,
DEFAULT_NAMESPACE_PATH,
DEFAULT_CA_PATH,
DEFAULT_API_BASE,
DEFAULT_SECRET_NAME,
new ObjectMapper()
);
}
KubernetesTenantSecretUpdater(Path tokenPath,
Path namespacePath,
Path caPath,
String apiBase,
String secretName,
ObjectMapper objectMapper) {
this(
tokenPath,
namespacePath,
caPath,
apiBase,
secretName,
objectMapper,
DEFAULT_MAX_ATTEMPTS,
DEFAULT_RETRY_DELAY
);
}
KubernetesTenantSecretUpdater(Path tokenPath,
Path namespacePath,
Path caPath,
String apiBase,
String secretName,
ObjectMapper objectMapper,
int maxAttempts,
Duration retryDelay) {
this(
tokenPath,
namespacePath,
caPath,
apiBase,
secretName,
objectMapper,
maxAttempts,
retryDelay,
detectKubernetesEnvironment(tokenPath)
);
}
KubernetesTenantSecretUpdater(Path tokenPath,
Path namespacePath,
Path caPath,
String apiBase,
String secretName,
ObjectMapper objectMapper,
int maxAttempts,
Duration retryDelay,
boolean kubernetesEnvironment) {
this.tokenPath = Objects.requireNonNull(tokenPath, "Путь к ServiceAccount token не задан");
this.namespacePath = Objects.requireNonNull(namespacePath, "Путь к namespace не задан");
this.caPath = Objects.requireNonNull(caPath, "Путь к ServiceAccount CA не задан");
this.apiBase = normalizeApiBase(apiBase);
this.secretName = requireText(secretName, "Имя tenant Secret не задано");
this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper не задан");
if (maxAttempts < 1) {
throw new IllegalArgumentException("Количество попыток должно быть положительным");
}
if (retryDelay == null || retryDelay.isNegative()) {
throw new IllegalArgumentException("Задержка повтора не может быть отрицательной");
}
this.maxAttempts = maxAttempts;
this.retryDelay = retryDelay;
this.runningInKubernetes = kubernetesEnvironment || Files.exists(tokenPath);
if (!runningInKubernetes) {
log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
}
}
@Override
public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
);
String domain = normalizeDomain(requested.getDomain());
requested.setDomain(domain);
if (!runningInKubernetes) {
log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
}
return mutate(tenants -> {
List<TenantConfig> updated = new ArrayList<>(tenants);
updated.removeIf(existing -> domain.equals(existing.getDomain()));
updated.add(requested);
return updated;
});
}
@Override
public TenantSecretUpdateReceipt removeTenant(String domain) {
String normalizedDomain = normalizeDomain(domain);
if (!runningInKubernetes) {
log.warn("Приложение запущено вне Kubernetes, постоянное удаление тенанта пропущено");
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of());
}
return mutate(tenants -> {
List<TenantConfig> updated = new ArrayList<>(tenants);
updated.removeIf(existing -> normalizedDomain.equals(existing.getDomain()));
return updated;
});
}
@Override
public TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
if (!receipt.persisted() || !receipt.changed()) {
return TenantSecretCompensationResult.NOT_REQUIRED;
}
if (!runningInKubernetes) {
return TenantSecretCompensationResult.NOT_REQUIRED;
}
String committedVersion = requireText(
receipt.committedResourceVersion(),
"В квитанции отсутствует resourceVersion сохранённого tenant Secret"
);
RequestContext context = requestContext();
TenantConfigPersistenceException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
CurrentSecret current;
try {
current = readCurrent(context);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
} catch (IOException e) {
lastFailure = persistenceFailure("Не удалось прочитать tenant Secret для компенсации", e);
if (!prepareRetry(attempt, "чтение перед компенсацией")) {
throw lastFailure;
}
continue;
}
if (!committedVersion.equals(current.resourceVersion())) {
log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
}
if (!sameTenants(current.tenants(), receipt.committedTenants())) {
throw new TenantConfigPersistenceException(
"Содержимое tenant Secret не соответствует resourceVersion из квитанции"
);
}
HttpResponse<String> response;
try {
response = putSecret(context, current, receipt.previousTenants());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
} catch (IOException e) {
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
if (reconciled != null) {
return reconciled;
}
lastFailure = persistenceFailure("Не удалось подтвердить компенсацию tenant Secret", e);
if (!prepareRetry(attempt, "компенсация после сетевой ошибки")) {
throw lastFailure;
}
continue;
}
if (isSuccess(response.statusCode())) {
TenantSecretCompensationResult confirmed = confirmCompensation(
context,
receipt,
response.body()
);
if (confirmed != null) {
if (confirmed == TenantSecretCompensationResult.RESTORED) {
log.info("Конфигурация tenant Secret восстановлена: tenantCount={}",
receipt.previousTenants().size());
}
return confirmed;
}
lastFailure = new TenantConfigPersistenceException(
"Kubernetes API не подтвердил содержимое после компенсации tenant Secret"
);
if (!prepareRetry(attempt, "проверка компенсации tenant Secret")) {
throw lastFailure;
}
continue;
}
if (response.statusCode() == 409) {
log.warn("Компенсация tenant Secret пропущена: обнаружено конкурентное изменение");
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
}
if (isRetryable(response.statusCode())) {
TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
if (reconciled != null) {
return reconciled;
}
lastFailure = new TenantConfigPersistenceException(
"Kubernetes API временно отклонил компенсацию tenant Secret: HTTP "
+ response.statusCode()
);
if (!prepareRetry(attempt, "компенсация tenant Secret")) {
throw lastFailure;
}
continue;
}
throw new TenantConfigPersistenceException(
"Kubernetes API отклонил компенсацию tenant Secret: HTTP " + response.statusCode()
);
}
throw lastFailure == null
? new TenantConfigPersistenceException("Не удалось компенсировать tenant Secret")
: lastFailure;
}
private TenantSecretUpdateReceipt mutate(Function<List<TenantConfig>, List<TenantConfig>> mutation) {
RequestContext context = requestContext();
TenantConfigPersistenceException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
CurrentSecret current;
try {
current = readCurrent(context);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
} catch (IOException e) {
lastFailure = persistenceFailure("Не удалось прочитать актуальный tenant Secret", e);
if (!prepareRetry(attempt, "чтение tenant Secret")) {
throw lastFailure;
}
continue;
}
List<TenantConfig> previous = normalizeTenants(current.tenants());
List<TenantConfig> committed = normalizeTenants(mutation.apply(previous));
if (sameTenants(previous, committed)) {
return new TenantSecretUpdateReceipt(
true,
false,
current.resourceVersion(),
previous,
committed
);
}
HttpResponse<String> response;
try {
response = putSecret(context, current, committed);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
} catch (IOException e) {
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
if (reconciled != null) {
return reconciled;
}
lastFailure = persistenceFailure("Не удалось подтвердить изменение tenant Secret", e);
if (!prepareRetry(attempt, "изменение после сетевой ошибки")) {
throw lastFailure;
}
continue;
}
if (isSuccess(response.statusCode())) {
TenantSecretUpdateReceipt confirmed = confirmMutation(
context,
current,
previous,
committed,
response.body()
);
if (confirmed != null) {
log.info("Tenant Secret атомарно изменён: tenantCount={}", committed.size());
return confirmed;
}
throw new TenantConfigPersistenceException(
"Kubernetes API не подтвердил сохранённое содержимое tenant Secret"
);
}
if (response.statusCode() == 409) {
lastFailure = new TenantConfigPersistenceException(
"Исчерпаны попытки изменения tenant Secret из-за конкурентных обновлений"
);
if (!prepareRetry(attempt, "конфликт resourceVersion")) {
throw lastFailure;
}
continue;
}
if (isRetryable(response.statusCode())) {
TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
if (reconciled != null) {
return reconciled;
}
lastFailure = new TenantConfigPersistenceException(
"Kubernetes API временно отклонил изменение tenant Secret: HTTP "
+ response.statusCode()
);
if (!prepareRetry(attempt, "изменение tenant Secret")) {
throw lastFailure;
}
continue;
}
throw new TenantConfigPersistenceException(
"Kubernetes API отклонил изменение tenant Secret: HTTP " + response.statusCode()
);
}
throw lastFailure == null
? new TenantConfigPersistenceException("Не удалось изменить tenant Secret")
: lastFailure;
}
private CurrentSecret readCurrent(RequestContext context) throws IOException, InterruptedException {
HttpRequest request = requestBuilder(context)
.GET()
.build();
HttpResponse<String> response = secureClient().send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
if (response.statusCode() == 200) {
return parseSecret(response.body());
}
if (isRetryable(response.statusCode())) {
throw new RetryableApiException(
"Kubernetes API временно не вернул tenant Secret: HTTP " + response.statusCode()
);
}
throw new TenantConfigPersistenceException(
"Kubernetes API не вернул tenant Secret: HTTP " + response.statusCode()
);
}
private HttpResponse<String> putSecret(RequestContext context,
CurrentSecret current,
List<TenantConfig> tenants)
throws IOException, InterruptedException {
ObjectNode body = current.secret().deepCopy();
ObjectNode metadata = objectNode(body, "metadata");
metadata.put("resourceVersion", current.resourceVersion());
ObjectNode data = objectNode(body, "data");
data.put("tenants.json", encodeTenants(tenants));
HttpRequest request = requestBuilder(context)
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
return secureClient().send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
}
private TenantSecretUpdateReceipt reconcileMutation(RequestContext context,
List<TenantConfig> previous,
List<TenantConfig> committed) {
try {
CurrentSecret observed = readCurrent(context);
if (sameTenants(observed.tenants(), committed)) {
log.info("Изменение tenant Secret подтверждено повторным чтением: tenantCount={}",
committed.size());
return new TenantSecretUpdateReceipt(
true,
false,
observed.resourceVersion(),
previous,
observed.tenants()
);
}
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Проверка изменения tenant Secret прервана", e);
} catch (IOException e) {
log.warn("Не удалось подтвердить состояние tenant Secret повторным чтением");
return null;
}
}
private TenantSecretUpdateReceipt confirmMutation(RequestContext context,
CurrentSecret previousSecret,
List<TenantConfig> previous,
List<TenantConfig> committed,
String responseBody) {
try {
CurrentSecret confirmed = parseSecret(responseBody);
if (sameTenants(confirmed.tenants(), committed)
&& !Objects.equals(confirmed.resourceVersion(), previousSecret.resourceVersion())) {
return new TenantSecretUpdateReceipt(
true,
true,
confirmed.resourceVersion(),
previous,
confirmed.tenants()
);
}
log.warn("Ответ Kubernetes API после изменения tenant Secret не совпал с ожидаемым состоянием");
} catch (TenantConfigPersistenceException invalidResponse) {
log.warn("Kubernetes API вернул неполный ответ после изменения tenant Secret, выполняется повторное чтение");
log.debug("Технические детали неполного ответа tenant Secret", invalidResponse);
}
return reconcileMutation(context, previous, committed);
}
private TenantSecretCompensationResult confirmCompensation(RequestContext context,
TenantSecretUpdateReceipt receipt,
String responseBody) {
try {
CurrentSecret confirmed = parseSecret(responseBody);
if (sameTenants(confirmed.tenants(), receipt.previousTenants())) {
return TenantSecretCompensationResult.RESTORED;
}
log.warn("Ответ Kubernetes API после компенсации tenant Secret не совпал с ожидаемым состоянием");
} catch (TenantConfigPersistenceException invalidResponse) {
log.warn("Kubernetes API вернул неполный ответ после компенсации tenant Secret, выполняется повторное чтение");
log.debug("Технические детали неполного ответа компенсации tenant Secret", invalidResponse);
}
return reconcileCompensation(context, receipt);
}
private TenantSecretCompensationResult reconcileCompensation(RequestContext context,
TenantSecretUpdateReceipt receipt) {
try {
CurrentSecret observed = readCurrent(context);
if (sameTenants(observed.tenants(), receipt.previousTenants())) {
log.info("Компенсация tenant Secret подтверждена повторным чтением");
return TenantSecretCompensationResult.RESTORED;
}
if (!Objects.equals(observed.resourceVersion(), receipt.committedResourceVersion())) {
log.warn("Компенсация tenant Secret остановлена из-за конкурентного изменения");
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
}
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Проверка компенсации tenant Secret прервана", e);
} catch (IOException e) {
log.warn("Не удалось подтвердить компенсацию tenant Secret повторным чтением");
return null;
}
}
private CurrentSecret parseSecret(String responseBody) {
try {
JsonNode root = objectMapper.readTree(responseBody);
if (!(root instanceof ObjectNode secret)) {
throw new TenantConfigPersistenceException("Kubernetes API вернул некорректный tenant Secret");
}
String resourceVersion = requireText(
root.path("metadata").path("resourceVersion").asText(null),
"В tenant Secret отсутствует metadata.resourceVersion"
);
JsonNode data = root.path("data");
if (!data.isObject() || !data.hasNonNull("tenants.json")) {
throw new TenantConfigPersistenceException(
"В tenant Secret отсутствует обязательный ключ data.tenants.json"
);
}
String encoded = requireText(
data.path("tenants.json").asText(null),
"В tenant Secret ключ data.tenants.json не содержит конфигурацию"
);
byte[] decoded = Base64.getDecoder().decode(encoded);
List<TenantConfig> tenants = objectMapper.readValue(decoded, TENANT_LIST_TYPE);
return new CurrentSecret(secret, resourceVersion, normalizeTenants(tenants));
} catch (TenantConfigPersistenceException e) {
throw e;
} catch (Exception e) {
throw new TenantConfigPersistenceException(
"Не удалось разобрать конфигурацию из tenant Secret",
e
);
}
}
private List<TenantConfig> normalizeTenants(List<TenantConfig> tenants) {
if (tenants == null) {
throw new TenantConfigPersistenceException("Список конфигураций в tenant Secret не задан");
}
List<TenantConfig> normalized = new ArrayList<>(tenants.size());
Set<String> domains = new HashSet<>();
for (TenantConfig source : tenants) {
TenantConfig tenant = TenantSecretUpdateReceipt.copyOf(
Objects.requireNonNull(source, "В tenant Secret обнаружена пустая конфигурация")
);
String domain = normalizeDomain(tenant.getDomain());
tenant.setDomain(domain);
if (!domains.add(domain)) {
throw new TenantConfigPersistenceException(
"В tenant Secret обнаружены повторяющиеся домены"
);
}
normalized.add(tenant);
}
normalized.sort(Comparator.comparing(TenantConfig::getDomain));
return List.copyOf(normalized);
}
private boolean sameTenants(List<TenantConfig> first, List<TenantConfig> second) {
List<TenantConfig> left = normalizeTenants(first);
List<TenantConfig> right = normalizeTenants(second);
if (left.size() != right.size()) {
return false;
}
for (int i = 0; i < left.size(); i++) {
if (!sameTenant(left.get(i), right.get(i))) {
return false;
}
}
return true;
}
private boolean sameTenant(TenantConfig first, TenantConfig second) {
return Objects.equals(first.getName(), second.getName())
&& Objects.equals(first.getDomain(), second.getDomain())
&& Objects.equals(first.getUrl(), second.getUrl())
&& Objects.equals(first.getUsername(), second.getUsername())
&& Objects.equals(first.getPassword(), second.getPassword());
}
private RequestContext requestContext() {
try {
String token = requireText(
Files.readString(tokenPath).trim(),
"ServiceAccount token не настроен"
);
String namespace = requireText(
Files.readString(namespacePath).trim(),
"ServiceAccount namespace не настроен"
);
URI uri = URI.create(String.format(
"%s/api/v1/namespaces/%s/secrets/%s",
apiBase,
namespace,
secretName
));
return new RequestContext(token, uri);
} catch (TenantConfigPersistenceException e) {
throw e;
} catch (Exception e) {
throw new TenantConfigPersistenceException(
"Не удалось прочитать параметры ServiceAccount для tenant Secret",
e
);
}
}
private HttpRequest.Builder requestBuilder(RequestContext context) {
return HttpRequest.newBuilder()
.uri(context.uri())
.timeout(REQUEST_TIMEOUT)
.header("Authorization", "Bearer " + context.token())
.header("Accept", "application/json");
}
private synchronized HttpClient secureClient() {
if (httpClient == null) {
try {
httpClient = createSecureClient(caPath);
} catch (Exception e) {
throw new TenantConfigPersistenceException(
"Не удалось настроить защищённое соединение с Kubernetes API",
e
);
}
}
return httpClient;
}
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates;
try (InputStream input = Files.newInputStream(serviceAccountCaPath)) {
certificates = certificateFactory.generateCertificates(input);
}
if (certificates.isEmpty()) {
throw new IllegalStateException("ServiceAccount CA не содержит сертификатов");
}
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
int index = 0;
for (Certificate certificate : certificates) {
trustStore.setCertificateEntry("kubernetes-ca-" + index++, certificate);
}
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
);
trustManagerFactory.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SSLParameters sslParameters = new SSLParameters();
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
return HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.sslContext(sslContext)
.sslParameters(sslParameters)
.build();
}
private boolean prepareRetry(int attempt, String operation) {
if (attempt >= maxAttempts) {
return false;
}
log.warn("Временная ошибка tenant Secret, операция будет повторена: операция={}, попытка={}/{}",
operation,
attempt,
maxAttempts);
try {
long delayMillis = Math.multiplyExact(retryDelay.toMillis(), attempt);
if (delayMillis > 0) {
Thread.sleep(delayMillis);
}
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TenantConfigPersistenceException("Ожидание повтора tenant Secret прервано", e);
} catch (ArithmeticException e) {
throw new TenantConfigPersistenceException("Некорректная задержка повтора tenant Secret", e);
}
}
private String encodeTenants(List<TenantConfig> tenants) throws IOException {
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
}
private ObjectNode objectNode(ObjectNode parent, String fieldName) {
JsonNode existing = parent.get(fieldName);
if (existing instanceof ObjectNode objectNode) {
return objectNode;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(fieldName, created);
return created;
}
private static boolean isSuccess(int statusCode) {
return statusCode >= 200 && statusCode < 300;
}
private static boolean isRetryable(int statusCode) {
return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500;
}
private static boolean detectKubernetesEnvironment(Path tokenPath) {
String kubernetesServiceHost = System.getenv("KUBERNETES_SERVICE_HOST");
return (tokenPath != null && Files.exists(tokenPath))
|| (kubernetesServiceHost != null && !kubernetesServiceHost.isBlank());
}
private static String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new TenantConfigPersistenceException(message);
}
return value.trim();
}
private static String normalizeDomain(String value) {
String domain = requireText(value, "Домен тенанта не задан").toLowerCase(Locale.ROOT);
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
throw new TenantConfigPersistenceException("Домен тенанта содержит недопустимые символы");
}
return domain;
}
private static String normalizeApiBase(String apiBase) {
String normalized = requireText(apiBase, "Адрес Kubernetes API не задан");
return normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1)
: normalized;
}
private static TenantConfigPersistenceException persistenceFailure(String message, Exception cause) {
return cause instanceof TenantConfigPersistenceException persistenceException
? persistenceException
: new TenantConfigPersistenceException(message, cause);
}
private record RequestContext(String token, URI uri) {
}
private record CurrentSecret(ObjectNode secret,
String resourceVersion,
List<TenantConfig> tenants) {
}
private static final class RetryableApiException extends IOException {
private RetryableApiException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,41 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Модель конфигурации тенанта (университета).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class TenantConfig {
private String name; // "ЮЗГУ", "МГУ"
private String domain; // "swsu", "mgu" (поддомен)
private String url; // "jdbc:postgresql://192.168.1.50:5432/magistr_db"
private String username;
private String password;
public TenantConfig() {}
public TenantConfig(String name, String domain, String url, String username, String password) {
this.name = name;
this.domain = domain;
this.url = url;
this.username = username;
this.password = password;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDomain() { return domain; }
public void setDomain(String domain) { this.domain = domain; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}

View File

@@ -0,0 +1,15 @@
package com.magistr.app.config.tenant;
/**
* Ошибка безопасного чтения или изменения постоянной конфигурации тенантов.
*/
public class TenantConfigPersistenceException extends RuntimeException {
public TenantConfigPersistenceException(String message) {
super(message);
}
public TenantConfigPersistenceException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.config.tenant;
/**
* Хранилище конфигурации тенантов с защитой от потерянных конкурентных обновлений.
*/
public interface TenantConfigStore {
TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant);
TenantSecretUpdateReceipt removeTenant(String domain);
TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt);
}

View File

@@ -0,0 +1,372 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.LongSupplier;
/**
* Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
* Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
*/
@Component
public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private static final String MISSING_FAILURE_KEY = "missing";
private static final String READ_FAILURE_KEY = "read";
static final long RETRY_BASE_DELAY_MILLIS = 30_000L;
static final long RETRY_MAX_DELAY_MILLIS = 300_000L;
private final TenantLifecycleService tenantLifecycleService;
private final TenantReadinessRegistry readinessRegistry;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
@Value("${app.tenants.config-required:false}")
private boolean tenantsConfigRequired;
/** Хеш означает только полностью применённое содержимое файла. */
private String lastConfigHash = "";
private ConfigSnapshot lastAppliedSnapshot;
private String failedRevisionKey = "";
private int consecutiveFailures;
private long retryNotBeforeMillis;
/**
* Fence хранит семантические снимки, а не байтовые представления JSON.
* Поэтому форматирование файла не может ошибочно снять или установить fence.
*/
private ConfigSnapshot expectedCommittedSnapshot;
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
private boolean configurationUnavailable;
private LongSupplier currentTimeMillis = System::currentTimeMillis;
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
TenantReadinessRegistry readinessRegistry) {
this.tenantLifecycleService = tenantLifecycleService;
this.readinessRegistry = readinessRegistry;
}
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() {
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
}
private void watchForChangesSerialized() {
long now = currentTimeMillis.getAsLong();
Path path = Path.of(tenantsConfigPath);
if (!Files.exists(path)) {
if (tenantsConfigRequired) {
handleRequiredMissingFile(now);
}
return;
}
if (isRetryDeferred(READ_FAILURE_KEY, now)) {
logDeferredRetry(now);
return;
}
String failureKey = READ_FAILURE_KEY;
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
String hash = configHash(content);
failureKey = hash;
Projection projection = parseProjection(content, hash);
// Даже неизменившееся содержимое сначала разбирается: fence никогда не
// принимает решение по сырым байтам до проверки структуры JSON.
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
return;
}
if (isRetryDeferred(hash, now)) {
logDeferredRetry(now);
return;
}
if (!hash.equals(failedRevisionKey)) {
resetRetryState();
}
if (shouldDefer(projection.snapshot())) {
return;
}
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
syncTenants(projection.tenants());
markApplied(projection);
} catch (Exception failure) {
recordFailure(failureKey, failure);
}
}
/**
* Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
* с неизвестного baseline.
*/
public void prepareForApiMutation() {
tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
}
private void prepareForApiMutationSerialized() {
Path path = Path.of(tenantsConfigPath);
if (!Files.exists(path)) {
if (!tenantsConfigRequired) {
return;
}
TenantLifecycleException failure = new TenantLifecycleException(
"Обязательный файл конфигурации тенантов недоступен"
);
recordFailure(MISSING_FAILURE_KEY, failure);
throw failure;
}
String failureKey = READ_FAILURE_KEY;
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
String hash = configHash(content);
failureKey = hash;
Projection projection = parseProjection(content, hash);
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
return;
}
if (shouldDefer(projection.snapshot())) {
return;
}
syncTenants(projection.tenants());
markApplied(projection);
} catch (Exception failure) {
recordFailure(failureKey, failure);
if (failure instanceof TenantLifecycleException lifecycleFailure) {
throw lifecycleFailure;
}
throw new TenantLifecycleException(
"Не удалось подготовить актуальную конфигурацию тенантов перед изменением",
failure
);
}
}
/**
* Устанавливает snapshot fence только после подтверждённой записи в общее
* хранилище. Локальные и no-op операции не создают ложных ожиданий от mount.
*/
public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) {
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt));
}
private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) {
if (!receipt.persisted() || !receipt.changed()) {
return;
}
ConfigSnapshot previous = snapshotOf(receipt.previousTenants());
ConfigSnapshot committed = snapshotOf(receipt.committedTenants());
if (lastAppliedSnapshot != null) {
deferredSnapshots.add(lastAppliedSnapshot);
}
if (expectedCommittedSnapshot != null) {
deferredSnapshots.add(expectedCommittedSnapshot);
}
deferredSnapshots.add(previous);
deferredSnapshots.remove(committed);
expectedCommittedSnapshot = committed;
}
static String configHash(String content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
}
}
private Projection parseProjection(String content, String hash) throws IOException {
List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
if (tenants == null) {
throw new IllegalArgumentException(
"Корневое значение конфигурации тенантов должно быть массивом"
);
}
if (tenantsConfigRequired && tenants.isEmpty()) {
throw new IllegalArgumentException(
"Обязательная конфигурация тенантов не может быть пустой"
);
}
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
}
private ConfigSnapshot snapshotOf(List<TenantConfig> tenants) {
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
List<NormalizedTenantConfig> normalized = new ArrayList<>(tenants.size());
for (TenantConfig tenant : tenants) {
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана");
String domain = normalizeDomain(tenant.getDomain());
String name = tenant.getName() == null || tenant.getName().isBlank()
? domain
: tenant.getName().trim();
String url = tenant.getUrl() == null ? null : tenant.getUrl().trim();
normalized.add(new NormalizedTenantConfig(
name,
domain,
url,
tenant.getUsername(),
tenant.getPassword()
));
}
normalized.sort(NormalizedTenantConfig.ORDER);
return new ConfigSnapshot(normalized);
}
private String normalizeDomain(String domain) {
return domain == null ? null : domain.trim().toLowerCase(Locale.ROOT);
}
private void syncTenants(List<TenantConfig> tenants) {
tenantLifecycleService.synchronizeFromPersistedConfig(tenants);
}
private boolean shouldDefer(ConfigSnapshot snapshot) {
return expectedCommittedSnapshot != null && deferredSnapshots.contains(snapshot);
}
private void clearFenceWhenExpectedSnapshotIsAlreadyApplied(ConfigSnapshot snapshot) {
if (snapshot.equals(expectedCommittedSnapshot)) {
clearSnapshotFence();
}
}
private void markApplied(Projection projection) {
lastConfigHash = projection.hash();
lastAppliedSnapshot = projection.snapshot();
configurationUnavailable = false;
resetRetryState();
clearSnapshotFence();
}
private void handleRequiredMissingFile(long now) {
if (isRetryDeferred(MISSING_FAILURE_KEY, now)) {
logDeferredRetry(now);
return;
}
recordFailure(
MISSING_FAILURE_KEY,
new TenantLifecycleException("Обязательный файл конфигурации тенантов недоступен")
);
}
private boolean isRetryDeferred(String failureKey, long now) {
return failureKey.equals(failedRevisionKey) && now < retryNotBeforeMillis;
}
private void logDeferredRetry(long now) {
log.debug("Повторная синхронизация tenants.json отложена ещё на {} мс",
Math.max(0L, retryNotBeforeMillis - now));
}
private void recordFailure(String failureKey, Exception failure) {
readinessRegistry.markConfigurationFailure();
configurationUnavailable = true;
long retryDelay = registerFailure(failureKey);
log.error("Ошибка синхронизации конфигурации тенантов: попытка={}, "
+ "следующий повтор не ранее чем через {} мс, errorType={}",
consecutiveFailures,
retryDelay,
failure.getClass().getSimpleName());
log.debug("Технические детали синхронизации конфигурации тенантов", failure);
}
private long registerFailure(String failureKey) {
if (failureKey.equals(failedRevisionKey) && consecutiveFailures > 0) {
consecutiveFailures = Math.min(consecutiveFailures + 1, 31);
} else {
failedRevisionKey = failureKey;
consecutiveFailures = 1;
}
long delay = retryDelayMillis(consecutiveFailures);
retryNotBeforeMillis = safeAdd(currentTimeMillis.getAsLong(), delay);
return delay;
}
static long retryDelayMillis(int failureCount) {
long delay = RETRY_BASE_DELAY_MILLIS;
for (int attempt = 1; attempt < failureCount && delay < RETRY_MAX_DELAY_MILLIS; attempt++) {
delay = Math.min(delay * 2, RETRY_MAX_DELAY_MILLIS);
}
return delay;
}
private long safeAdd(long value, long increment) {
return value > Long.MAX_VALUE - increment ? Long.MAX_VALUE : value + increment;
}
private void resetRetryState() {
failedRevisionKey = "";
consecutiveFailures = 0;
retryNotBeforeMillis = 0L;
}
private void clearSnapshotFence() {
expectedCommittedSnapshot = null;
deferredSnapshots.clear();
}
private record Projection(String hash, List<TenantConfig> tenants, ConfigSnapshot snapshot) {
}
private record ConfigSnapshot(List<NormalizedTenantConfig> tenants) {
private ConfigSnapshot {
tenants = List.copyOf(tenants);
}
}
private record NormalizedTenantConfig(
String name,
String domain,
String url,
String username,
String password
) {
private static final Comparator<String> NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo);
private static final Comparator<NormalizedTenantConfig> ORDER =
Comparator.comparing(NormalizedTenantConfig::domain, NULL_SAFE_TEXT)
.thenComparing(NormalizedTenantConfig::name, NULL_SAFE_TEXT)
.thenComparing(NormalizedTenantConfig::url, NULL_SAFE_TEXT)
.thenComparing(NormalizedTenantConfig::username, NULL_SAFE_TEXT)
.thenComparing(NormalizedTenantConfig::password, NULL_SAFE_TEXT);
}
}

View File

@@ -0,0 +1,22 @@
package com.magistr.app.config.tenant;
/**
* ThreadLocal хранилище текущего тенанта (домена).
* Устанавливается в TenantInterceptor на каждый HTTP-запрос.
*/
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static String getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(String tenant) {
CURRENT_TENANT.set(tenant);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}

View File

@@ -0,0 +1,223 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.io.File;
import java.sql.Connection;
import java.util.*;
/**
* Конфигурация мультитенантного DataSource.
* Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
*
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
* как заглушку, чтобы Spring JPA мог инициализироваться.
*/
@Configuration
public class TenantDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
@Value("${app.tenants.config-required:false}")
private boolean tenantsConfigRequired;
@Value("${spring.datasource.url:}")
private String defaultDbUrl;
@Value("${spring.datasource.username:}")
private String defaultDbUsername;
@Value("${spring.datasource.password:}")
private String defaultDbPassword;
@Bean
@Primary
public DataSource dataSource(TenantDatabaseMigrationService migrationService,
TenantReadinessRegistry readinessRegistry) {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// Загружаем тенантов из JSON (read-only Secret mount)
TenantConfigLoadResult loadResult = loadTenantsFromFile();
List<TenantConfig> tenants = new ArrayList<>(loadResult.tenants());
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
TenantConfig defaultTenant = new TenantConfig(
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
);
tenants.add(defaultTenant);
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
}
readinessRegistry.clearConfigurationFailure();
readinessRegistry.replaceDesired(tenants);
if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
readinessRegistry.markConfigurationFailure();
}
// Регистрируем тенантов
for (TenantConfig tenant : tenants) {
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
}
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
if (routingDataSource.getTenantConfigs().isEmpty()) {
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
log.warn("Добавьте тенант через POST /api/database/tenants");
TenantConfig h2Fallback = new TenantConfig(
"H2 Placeholder", "default",
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
"sa", ""
);
if (!registerPreparedTenant(
routingDataSource,
migrationService,
readinessRegistry,
h2Fallback,
false
)) {
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
}
}
return routingDataSource;
}
@Bean
public TenantRoutingDataSource tenantRoutingDataSource(DataSource dataSource) {
return (TenantRoutingDataSource) dataSource;
}
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.magistr.app.model");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setGenerateDdl(false);
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
em.setJpaVendorAdapter(vendorAdapter);
Map<String, Object> props = new HashMap<>();
props.put("hibernate.hbm2ddl.auto", "none");
props.put("hibernate.show_sql", "false");
em.setJpaPropertyMap(props);
return em;
}
@Bean
@Primary
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
private TenantConfigLoadResult loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
if (tenantsConfigRequired) {
log.error("Обязательный файл конфигурации тенантов не найден");
} else {
log.info("Файл конфигурации тенантов не найден");
}
return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
}
try {
ObjectMapper mapper = new ObjectMapper();
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
if (list == null) {
throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
}
log.info("Загружено конфигураций тенантов: {}", list.size());
return new TenantConfigLoadResult(List.copyOf(list), false);
} catch (Exception e) {
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
return new TenantConfigLoadResult(List.of(), true);
}
}
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
TenantDatabaseMigrationService migrationService,
TenantReadinessRegistry readinessRegistry,
TenantConfig tenant,
boolean requiredForReadiness) {
HikariDataSource candidate = null;
boolean activated = false;
if (requiredForReadiness) {
readinessRegistry.markMigrationStarted(tenant);
}
try {
candidate = routingDataSource.prepareTenantDataSource(tenant);
try (Connection connection = candidate.getConnection()) {
if (!connection.isValid(5)) {
throw new IllegalStateException("База данных не подтвердила готовность подключения");
}
}
if (!tenant.getUrl().trim().startsWith("jdbc:h2:")) {
migrationService.migrate(candidate);
}
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
activated = true;
if (requiredForReadiness) {
readinessRegistry.markMigrationSucceeded(tenant);
readinessRegistry.markConnectivity(tenant.getDomain(), true);
}
closeDataSource(previous == null ? null : previous.dataSource());
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
if (requiredForReadiness) {
readinessRegistry.markMigrationFailed(tenant);
}
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
return false;
} finally {
if (!activated) {
closeDataSource(candidate);
}
}
}
private void closeDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource hikariDataSource) {
try {
hikariDataSource.close();
} catch (RuntimeException closeFailure) {
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
closeFailure.getClass().getSimpleName());
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
}
}
}
private record TenantConfigLoadResult(List<TenantConfig> tenants, boolean failed) {
}
}

View File

@@ -0,0 +1,106 @@
package com.magistr.app.config.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Map;
import org.slf4j.MDC;
import io.opentelemetry.api.trace.Span;
/**
* Interceptor: извлекает поддомен из Host header и кладёт в TenantContext.
*
* Если тенант не настроен в TenantRoutingDataSource —
* сразу возвращает HTTP 404 (не допускает fallback на чужой тенант).
*
* Примеры:
* "swsu.zuev.company" → tenant = "swsu"
* "mgu.zuev.company" → tenant = "mgu"
* "localhost" → tenant = "default"
* "localhost:8080" → tenant = "default"
*
* API управления тенантами (/api/database/**) пропускается без проверки,
* чтобы администратор мог добавлять тенантов с любого домена.
*/
public class TenantInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class);
private TenantRoutingDataSource routingDataSource;
/**
* Устанавливается после создания бина (из TenantDataSourceConfig).
*/
public void setRoutingDataSource(TenantRoutingDataSource routingDataSource) {
this.routingDataSource = routingDataSource;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String host = request.getHeader("Host");
String tenant = resolveTenant(host);
String path = request.getRequestURI();
// API управления тенантами — всегда пропускаем
// (нужно чтобы админ мог добавить тенант даже если его домен не настроен)
if (path.startsWith("/api/database")) {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
return true;
}
// Проверяем, существует ли тенант
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
response.setStatus(404);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"error", "Тенант не найден",
"tenant", tenant,
"message", "Домен " + host + " не настроен. Обратитесь к администратору."
));
return false; // Останавливаем обработку запроса
}
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
MDC.remove("tenant.id");
}
private String resolveTenant(String host) {
if (host == null || host.isBlank()) {
return "default";
}
// Убираем порт (localhost:8080 → localhost)
String hostname = host.contains(":") ? host.substring(0, host.indexOf(':')) : host;
// localhost или IP → default
if ("localhost".equalsIgnoreCase(hostname) || hostname.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) {
return "default";
}
// Извлекаем первый поддомен: swsu.zuev.company → swsu
int firstDot = hostname.indexOf('.');
if (firstDot > 0) {
return hostname.substring(0, firstDot).toLowerCase();
}
return "default";
}
}

View File

@@ -0,0 +1,259 @@
package com.magistr.app.config.tenant;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* DataSource, который переключается между БД разных тенантов.
* Runtime-состояние публикуется одним неизменяемым snapshot, поэтому запрос
* никогда не видит конфигурацию и DataSource из разных версий.
*/
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
private final Object lifecycleMonitor = new Object();
private volatile Map<String, TenantState> tenantStates = Map.of();
public TenantRoutingDataSource() {
// Inherited lifecycle Spring требует initial target map. Runtime routing
// выполняется переопределённым determineTargetDataSource().
setTargetDataSources(Map.of());
setLenientFallback(false);
}
@Override
protected Object determineCurrentLookupKey() {
return determineLookupKey(tenantStates);
}
@Override
protected DataSource determineTargetDataSource() {
Map<String, TenantState> snapshot = tenantStates;
String lookupKey = determineLookupKey(snapshot);
TenantState state = snapshot.get(lookupKey);
if (state == null || state.dataSource() == null) {
throw new IllegalStateException("Подключение для выбранного тенанта не настроено");
}
return state.dataSource();
}
/**
* Создаёт Hikari pool, но не включает его в маршрутизацию.
*/
public HikariDataSource prepareTenantDataSource(TenantConfig config) {
TenantConfig normalized = normalizeConfig(config);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(normalized.getUrl());
dataSource.setUsername(normalized.getUsername());
dataSource.setPassword(normalized.getPassword());
dataSource.setPoolName("tenant-" + normalized.getDomain() + "-candidate-"
+ Integer.toUnsignedString(System.identityHashCode(dataSource)));
dataSource.setMaximumPoolSize(10);
dataSource.setMinimumIdle(2);
dataSource.setConnectionTimeout(10_000);
dataSource.setValidationTimeout(5_000);
dataSource.setIdleTimeout(300_000);
dataSource.setMaxLifetime(600_000);
dataSource.setInitializationFailTimeout(10_000);
return dataSource;
}
/**
* Атомарно публикует подготовленный DataSource и возвращает прежнее состояние.
*/
public TenantState swapTenant(TenantConfig config, DataSource candidate) {
Objects.requireNonNull(candidate, "Подготовленный DataSource обязателен");
TenantConfig normalized = normalizeConfig(config);
String domain = normalized.getDomain();
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = copyState(current.get(domain));
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.put(domain, new TenantState(domain, normalized, candidate));
tenantStates = immutableStates(updated);
return previous;
}
}
/**
* Атомарно исключает тенанта из маршрутизации, но не закрывает возвращённый DataSource.
*/
public TenantState removeTenantAtomically(String domain) {
String normalizedDomain = normalizeDomain(domain);
synchronized (lifecycleMonitor) {
Map<String, TenantState> current = tenantStates;
TenantState previous = current.get(normalizedDomain);
if (previous == null) {
return null;
}
Map<String, TenantState> updated = new LinkedHashMap<>(current);
updated.remove(normalizedDomain);
tenantStates = immutableStates(updated);
return copyState(previous);
}
}
public Optional<TenantState> snapshotTenant(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(copyState(tenantStates.get(normalizeDomain(domain))));
}
public Map<String, TenantConfig> snapshotTenantConfigs() {
Map<String, TenantState> snapshot = tenantStates;
Map<String, TenantConfig> configs = new LinkedHashMap<>();
snapshot.forEach((domain, state) -> configs.put(domain, copyConfig(state.config())));
return Collections.unmodifiableMap(configs);
}
public Optional<DataSource> getTenantDataSource(String domain) {
if (domain == null || domain.isBlank()) {
return Optional.empty();
}
TenantState state = tenantStates.get(normalizeDomain(domain));
return state == null ? Optional.empty() : Optional.of(state.dataSource());
}
/**
* Проверяет подключение к БД для зарегистрированного тенанта.
*/
public boolean testConnection(String domain) {
Optional<DataSource> tenantDataSource = getTenantDataSource(domain);
if (tenantDataSource.isEmpty()) {
return false;
}
try (Connection connection = tenantDataSource.get().getConnection()) {
return connection.isValid(5);
} catch (SQLException e) {
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
normalizeDomain(domain), e.getClass().getSimpleName());
log.debug("Технические детали проверки подключения тенанта", e);
return false;
}
}
/**
* Тестирует подключение по произвольным параметрам без регистрации тенанта.
*/
public String testExternalConnection(String url, String username, String password) {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setMaximumPoolSize(1);
dataSource.setConnectionTimeout(5_000);
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(5)) {
return "OK";
}
return "Подключение не валидно";
} catch (Exception e) {
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
e.getClass().getSimpleName());
log.debug("Технические детали проверки внешнего подключения", e);
return "Не удалось подключиться к базе данных";
} finally {
dataSource.close();
}
}
/**
* Возвращает неизменяемый снимок конфигураций для совместимости существующих consumers.
*/
public Map<String, TenantConfig> getTenantConfigs() {
return snapshotTenantConfigs();
}
public boolean hasTenant(String domain) {
return domain != null && !domain.isBlank() && tenantStates.containsKey(normalizeDomain(domain));
}
public boolean isInitialized() {
return !tenantStates.isEmpty();
}
private String determineLookupKey(Map<String, TenantState> snapshot) {
String tenant = TenantContext.getCurrentTenant();
if (tenant != null) {
return tenant;
}
return snapshot.isEmpty() ? "default" : snapshot.keySet().iterator().next();
}
private Map<String, TenantState> immutableStates(Map<String, TenantState> source) {
Map<String, TenantState> copy = new LinkedHashMap<>();
source.forEach((domain, state) -> copy.put(domain, new TenantState(
domain,
copyConfig(state.config()),
state.dataSource()
)));
return Collections.unmodifiableMap(copy);
}
private TenantState copyState(TenantState state) {
if (state == null) {
return null;
}
return new TenantState(state.domain(), copyConfig(state.config()), state.dataSource());
}
private TenantConfig normalizeConfig(TenantConfig config) {
if (config == null) {
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
}
String domain = normalizeDomain(config.getDomain());
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("URL базы данных не может быть пустым");
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new TenantConfig(
name,
domain,
config.getUrl().trim(),
config.getUsername(),
config.getPassword()
);
}
private TenantConfig copyConfig(TenantConfig config) {
if (config == null) {
return null;
}
return new TenantConfig(
config.getName(),
config.getDomain(),
config.getUrl(),
config.getUsername(),
config.getPassword()
);
}
private String normalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен не может быть пустым");
}
return domain.trim().toLowerCase(Locale.ROOT);
}
public record TenantState(String domain, TenantConfig config, DataSource dataSource) {
}
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.config.tenant;
/**
* Результат компенсирующего изменения конфигурации тенантов.
*/
public enum TenantSecretCompensationResult {
RESTORED,
NOT_REQUIRED,
SKIPPED_CONCURRENT_CHANGE
}

View File

@@ -0,0 +1,95 @@
package com.magistr.app.config.tenant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* Квитанция об атомарном изменении tenant Secret.
*
* <p>Снимки конфигурации копируются глубоко и сортируются по домену. Это позволяет
* безопасно использовать квитанцию для компенсации и проверки ожидаемого состояния.</p>
*/
public final class TenantSecretUpdateReceipt {
private static final Comparator<TenantConfig> BY_DOMAIN = Comparator.comparing(
TenantConfig::getDomain,
Comparator.nullsFirst(String::compareTo)
);
private final boolean persisted;
private final boolean changed;
private final String committedResourceVersion;
private final List<TenantConfig> previousTenants;
private final List<TenantConfig> committedTenants;
public TenantSecretUpdateReceipt(boolean persisted,
boolean changed,
String committedResourceVersion,
List<TenantConfig> previousTenants,
List<TenantConfig> committedTenants) {
this.persisted = persisted;
this.changed = changed;
this.committedResourceVersion = committedResourceVersion;
this.previousTenants = copyAndSort(previousTenants);
this.committedTenants = copyAndSort(committedTenants);
}
public boolean persisted() {
return persisted;
}
/**
* Возвращает {@code true}, только если хранилище доказало владение подтверждённой
* записанной версией. После неоднозначного результата повторное чтение может подтвердить
* целевое состояние, но оставляет этот признак {@code false}, чтобы запретить опасную
* автоматическую компенсацию.
*/
public boolean changed() {
return changed;
}
public String committedResourceVersion() {
return committedResourceVersion;
}
public List<TenantConfig> previousTenants() {
return copyAndSort(previousTenants);
}
public List<TenantConfig> committedTenants() {
return copyAndSort(committedTenants);
}
static List<TenantConfig> copyAndSort(List<TenantConfig> tenants) {
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
List<TenantConfig> result = new ArrayList<>(tenants.size());
for (TenantConfig tenant : tenants) {
result.add(copyOf(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")));
}
result.sort(BY_DOMAIN);
return List.copyOf(result);
}
static TenantConfig copyOf(TenantConfig source) {
return new TenantConfig(
source.getName(),
source.getDomain(),
source.getUrl(),
source.getUsername(),
source.getPassword()
);
}
@Override
public String toString() {
return "TenantSecretUpdateReceipt{" +
"persisted=" + persisted +
", changed=" + changed +
", committedResourceVersion='" + committedResourceVersion + '\'' +
", previousTenantCount=" + previousTenants.size() +
", committedTenantCount=" + committedTenants.size() +
'}';
}
}

View File

@@ -0,0 +1,35 @@
package com.magistr.app.config.tenant;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class TenantWebMvcConfig implements WebMvcConfigurer {
private final TenantRoutingDataSource tenantRoutingDataSource;
private final AuthorizationInterceptor authorizationInterceptor;
public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
AuthorizationInterceptor authorizationInterceptor) {
this.tenantRoutingDataSource = tenantRoutingDataSource;
this.authorizationInterceptor = authorizationInterceptor;
}
@Bean
public TenantInterceptor tenantInterceptor() {
TenantInterceptor interceptor = new TenantInterceptor();
interceptor.setRoutingDataSource(tenantRoutingDataSource);
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/actuator/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
}
}

View File

@@ -0,0 +1,220 @@
package com.magistr.app.config.tenant.health;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Фоновая проверка доступности tenant-БД с ограниченным числом параллельных задач.
*/
@Component
public class TenantDatabaseHealthMonitor {
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseHealthMonitor.class);
private final TenantReadinessRegistry registry;
private final TenantRoutingDataSource routingDataSource;
private final int parallelism;
private final Duration checkTimeout;
private final Duration maxStaleness;
private final Clock clock;
private final ExecutorService executor;
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
@Autowired
public TenantDatabaseHealthMonitor(
TenantReadinessRegistry registry,
TenantRoutingDataSource routingDataSource,
@Value("${app.health.tenant-database.parallelism:4}") int parallelism,
@Value("${app.health.tenant-database.check-timeout-ms:6000}") long checkTimeoutMillis,
@Value("${app.health.tenant-database.max-staleness-ms:30000}") long maxStalenessMillis) {
this(registry, routingDataSource, parallelism,
Duration.ofMillis(checkTimeoutMillis),
Duration.ofMillis(maxStalenessMillis),
Clock.systemUTC());
}
public TenantDatabaseHealthMonitor(TenantReadinessRegistry registry,
TenantRoutingDataSource routingDataSource,
int parallelism,
Duration checkTimeout,
Duration maxStaleness,
Clock clock) {
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
this.routingDataSource = Objects.requireNonNull(routingDataSource, "Маршрутизатор tenant-БД обязателен");
if (parallelism < 1 || parallelism > 32) {
throw new IllegalArgumentException("Параллелизм проверки должен быть от 1 до 32");
}
this.parallelism = parallelism;
this.checkTimeout = requirePositive(checkTimeout, "Таймаут проверки должен быть положительным");
this.maxStaleness = requirePositive(maxStaleness, "Максимальный возраст проверки должен быть положительным");
this.clock = Objects.requireNonNull(clock, "Часы монитора обязательны");
this.executor = Executors.newFixedThreadPool(parallelism, new HealthThreadFactory());
}
@Scheduled(
fixedDelayString = "${app.health.tenant-database.interval-ms:10000}",
initialDelayString = "${app.health.tenant-database.initial-delay-ms:1000}")
public void refreshScheduled() {
refreshNow();
}
/**
* Выполняет немедленный проход. Одновременно может выполняться только один проход.
*/
public RefreshSummary refreshNow() {
if (!refreshInProgress.compareAndSet(false, true)) {
return new RefreshSummary(0, 0, 0, true);
}
try {
List<TenantReadinessRegistry.ConnectivityTarget> targets = registry.connectivityTargets();
int available = 0;
int unavailable = 0;
for (int offset = 0; offset < targets.size(); offset += parallelism) {
int end = Math.min(offset + parallelism, targets.size());
List<TenantReadinessRegistry.ConnectivityTarget> batch = targets.subList(offset, end);
List<Callable<CheckResult>> checks = new ArrayList<>(batch.size());
for (TenantReadinessRegistry.ConnectivityTarget target : batch) {
checks.add(() -> check(target));
}
List<Future<CheckResult>> futures;
try {
futures = executor.invokeAll(checks, checkTimeout.toNanos(), TimeUnit.NANOSECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
Instant interruptedAt = clock.instant();
log.warn("Пакетная проверка tenant-БД была прервана");
for (int remaining = offset; remaining < targets.size(); remaining++) {
TenantReadinessRegistry.ConnectivityTarget target = targets.get(remaining);
if (registry.markConnectivity(
target.domain(), target.fingerprint(), false, interruptedAt)) {
unavailable++;
}
}
return new RefreshSummary(targets.size(), available, unavailable, false);
}
for (int index = 0; index < batch.size(); index++) {
TenantReadinessRegistry.ConnectivityTarget target = batch.get(index);
CheckResult result = completedResult(target, futures.get(index));
boolean applied = registry.markConnectivity(
target.domain(), target.fingerprint(), result.connected(), result.checkedAt());
if (applied && result.connected()) {
available++;
} else if (applied) {
unavailable++;
}
}
}
return new RefreshSummary(targets.size(), available, unavailable, false);
} finally {
refreshInProgress.set(false);
}
}
public Duration maxStaleness() {
return maxStaleness;
}
public int parallelism() {
return parallelism;
}
@PreDestroy
public void close() {
executor.shutdownNow();
}
private CheckResult check(TenantReadinessRegistry.ConnectivityTarget target) {
try {
boolean connected = routingDataSource.testConnection(target.domain());
return new CheckResult(connected, clock.instant());
} catch (RuntimeException checkFailure) {
log.warn("Проверка tenant-БД '{}' завершилась ошибкой: errorType={}",
target.domain(), checkFailure.getClass().getSimpleName());
log.debug("Технические детали фоновой проверки tenant-БД", checkFailure);
return new CheckResult(false, clock.instant());
}
}
private CheckResult completedResult(TenantReadinessRegistry.ConnectivityTarget target,
Future<CheckResult> future) {
if (future.isCancelled()) {
log.warn("Проверка tenant-БД '{}' превысила допустимое время", target.domain());
return new CheckResult(false, clock.instant());
}
try {
return future.get();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
future.cancel(true);
log.warn("Проверка tenant-БД '{}' была прервана", target.domain());
return new CheckResult(false, clock.instant());
} catch (ExecutionException failure) {
log.warn("Не удалось проверить tenant-БД '{}': errorType={}",
target.domain(), failure.getCause() == null
? failure.getClass().getSimpleName()
: failure.getCause().getClass().getSimpleName());
return new CheckResult(false, clock.instant());
}
}
private Duration requirePositive(Duration value, String message) {
Objects.requireNonNull(value, message);
if (value.isNegative() || value.isZero()) {
throw new IllegalArgumentException(message);
}
return value;
}
public record RefreshSummary(int requested,
int available,
int unavailable,
boolean skipped) {
}
private record CheckResult(boolean connected, Instant checkedAt) {
private CheckResult {
Objects.requireNonNull(checkedAt, "Время завершения проверки tenant-БД обязательно");
}
}
private static final class HealthThreadFactory implements ThreadFactory {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public Thread newThread(Runnable task) {
Thread thread = new Thread(task,
"проверка-готовности-tenant-бд-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
}
}
}

View File

@@ -0,0 +1,38 @@
package com.magistr.app.config.tenant.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* Агрегированный индикатор readiness без раскрытия доменов и реквизитов tenant-БД.
*/
@Component("tenantReadiness")
public class TenantReadinessHealthIndicator implements HealthIndicator {
private final TenantReadinessRegistry registry;
private final TenantDatabaseHealthMonitor monitor;
public TenantReadinessHealthIndicator(TenantReadinessRegistry registry,
TenantDatabaseHealthMonitor monitor) {
this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
this.monitor = Objects.requireNonNull(monitor, "Монитор tenant-БД обязателен");
}
@Override
public Health health() {
return readiness().ready()
? Health.up().build()
: Health.down().build();
}
public TenantReadinessRegistry.AggregateReadiness readiness() {
return registry.aggregate(monitor.maxStaleness());
}
public boolean isReady() {
return readiness().ready();
}
}

View File

@@ -0,0 +1,461 @@
package com.magistr.app.config.tenant.health;
import com.magistr.app.config.tenant.TenantConfig;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
/**
* Потокобезопасный реестр готовности обязательных tenant-БД.
*
* <p>Каждое изменение публикуется единым неизменяемым снимком. Результат фоновой
* проверки соединения привязан к непрозрачному fingerprint конфигурации, поэтому
* запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.</p>
*/
@Component
public class TenantReadinessRegistry {
private static final String FINGERPRINT_ALGORITHM = "HmacSHA256";
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
);
private final AtomicReference<RegistryState> state = new AtomicReference<>(RegistryState.empty());
private final Clock clock;
private final byte[] fingerprintKey;
public TenantReadinessRegistry() {
this(Clock.systemUTC(), randomFingerprintKey());
}
TenantReadinessRegistry(Clock clock, byte[] fingerprintKey) {
this.clock = Objects.requireNonNull(clock, "Часы реестра готовности обязательны");
if (fingerprintKey == null || fingerprintKey.length < 16) {
throw new IllegalArgumentException("Ключ fingerprint должен содержать не менее 16 байт");
}
this.fingerprintKey = fingerprintKey.clone();
}
/**
* Полностью заменяет список обязательных конфигураций.
* Состояние неизменившегося tenant сохраняется, изменившегося — сбрасывается.
*/
public void replaceDesired(List<TenantConfig> desired) {
boolean missingDesired = desired == null;
List<TenantConfig> safeDesired = desired == null ? List.of() : new ArrayList<>(desired);
mutate(current -> {
Map<String, TenantStatus> replacement = new LinkedHashMap<>();
boolean invalidConfiguration = missingDesired;
for (TenantConfig config : safeDesired) {
NormalizedConfig normalized = normalize(config);
if (normalized == null || replacement.containsKey(normalized.domain())) {
invalidConfiguration = true;
continue;
}
String fingerprint = fingerprint(normalized);
TenantStatus previous = current.tenants().get(normalized.domain());
if (previous != null && previous.fingerprint().equals(fingerprint)) {
replacement.put(normalized.domain(), previous);
} else {
replacement.put(normalized.domain(), TenantStatus.pending(
normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant()));
}
}
return new RegistryState(replacement,
current.configurationFailure() || invalidConfiguration);
});
}
/** Удаляет tenant из обязательного набора. */
public void removeDesired(String domain) {
String normalizedDomain = normalizeDomain(domain);
mutate(current -> {
if (!current.tenants().containsKey(normalizedDomain)) {
return current;
}
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
updated.remove(normalizedDomain);
return new RegistryState(updated, current.configurationFailure());
});
}
public void markMigrationStarted(TenantConfig config) {
updateByConfig(config, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
}
public void markMigrationSucceeded(TenantConfig config) {
updateByConfig(config, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
}
public void markMigrationFailed(TenantConfig config) {
updateByConfig(config, MigrationState.FAILED, ConnectivityState.UNKNOWN);
}
public void markMigrationStarted(String domain) {
updateExisting(domain, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN);
}
public void markMigrationSucceeded(String domain) {
updateExisting(domain, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN);
}
public void markMigrationFailed(String domain) {
updateExisting(domain, MigrationState.FAILED, ConnectivityState.UNKNOWN);
}
/**
* Сохраняет результат проверки текущей конфигурации tenant.
*/
public boolean markConnectivity(String domain, boolean connected) {
String normalizedDomain = normalizeDomain(domain);
TenantStatus current = state.get().tenants().get(normalizedDomain);
if (current == null) {
return false;
}
return markConnectivity(normalizedDomain, current.fingerprint(), connected, clock.instant());
}
/**
* Сохраняет результат только если fingerprint всё ещё соответствует проверенной конфигурации.
*/
public boolean markConnectivity(String domain,
String expectedFingerprint,
boolean connected,
Instant checkedAt) {
String normalizedDomain = normalizeDomain(domain);
Objects.requireNonNull(expectedFingerprint, "Fingerprint проверяемой конфигурации обязателен");
Objects.requireNonNull(checkedAt, "Время проверки соединения обязательно");
AtomicReference<Boolean> applied = new AtomicReference<>(false);
mutate(current -> {
applied.set(false);
TenantStatus previous = current.tenants().get(normalizedDomain);
if (previous == null || !previous.fingerprint().equals(expectedFingerprint)
|| previous.migrationState() != MigrationState.SUCCEEDED
|| previous.h2Placeholder()) {
return current;
}
if (previous.connectivityCheckedAt() != null
&& checkedAt.isBefore(previous.connectivityCheckedAt())) {
return current;
}
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
updated.put(normalizedDomain, previous.withConnectivity(
connected ? ConnectivityState.AVAILABLE : ConnectivityState.UNAVAILABLE,
checkedAt,
clock.instant()));
applied.set(true);
return new RegistryState(updated, current.configurationFailure());
});
return applied.get();
}
/** Отмечает ошибку чтения или разбора общей конфигурации без сохранения её текста. */
public void markConfigurationFailure() {
mutate(current -> new RegistryState(current.tenants(), true));
}
public void clearConfigurationFailure() {
mutate(current -> new RegistryState(current.tenants(), false));
}
/** Возвращает безопасные задания для фоновой проверки соединений. */
public List<ConnectivityTarget> connectivityTargets() {
List<ConnectivityTarget> targets = new ArrayList<>();
state.get().tenants().values().stream()
.filter(status -> status.migrationState() == MigrationState.SUCCEEDED)
.filter(status -> !status.h2Placeholder())
.forEach(status -> targets.add(new ConnectivityTarget(status.domain(), status.fingerprint())));
return List.copyOf(targets);
}
/** Формирует агрегированную готовность на текущий момент. */
public AggregateReadiness aggregate(Duration maxStaleness) {
Objects.requireNonNull(maxStaleness, "Максимальный возраст проверки обязателен");
if (maxStaleness.isNegative() || maxStaleness.isZero()) {
throw new IllegalArgumentException("Максимальный возраст проверки должен быть положительным");
}
RegistryState snapshot = state.get();
Instant assessedAt = clock.instant();
Instant oldestAllowed = assessedAt.minus(maxStaleness);
int ready = 0;
int migrationFailures = 0;
int migrationPending = 0;
int staleConnectivity = 0;
int unavailableConnectivity = 0;
int unsupported = 0;
for (TenantStatus status : snapshot.tenants().values()) {
if (status.h2Placeholder()) {
unsupported++;
continue;
}
if (status.migrationState() == MigrationState.FAILED) {
migrationFailures++;
continue;
}
if (status.migrationState() != MigrationState.SUCCEEDED) {
migrationPending++;
continue;
}
if (status.connectivityCheckedAt() == null
|| status.connectivityCheckedAt().isBefore(oldestAllowed)) {
staleConnectivity++;
continue;
}
if (status.connectivityState() != ConnectivityState.AVAILABLE) {
unavailableConnectivity++;
continue;
}
ready++;
}
int desiredCount = snapshot.tenants().size();
boolean isReady = !snapshot.configurationFailure()
&& desiredCount > 0
&& unsupported == 0
&& ready == desiredCount;
return new AggregateReadiness(
isReady,
isReady ? "ГОТОВ" : "НЕ ГОТОВ",
desiredCount,
ready,
desiredCount - ready,
migrationPending,
migrationFailures,
unavailableConnectivity,
staleConnectivity,
unsupported,
snapshot.configurationFailure(),
assessedAt
);
}
/** Неизменяемый диагностический снимок для внутренних тестов и интеграции. */
public Map<String, TenantStatus> snapshot() {
return state.get().tenants();
}
private void updateByConfig(TenantConfig config,
MigrationState migrationState,
ConnectivityState connectivityState) {
NormalizedConfig normalized = normalize(config);
if (normalized == null) {
markConfigurationFailure();
return;
}
String fingerprint = fingerprint(normalized);
mutate(current -> {
TenantStatus previous = current.tenants().get(normalized.domain());
TenantStatus base = previous != null && previous.fingerprint().equals(fingerprint)
? previous
: TenantStatus.pending(normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant());
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
updated.put(normalized.domain(), base.withMigration(migrationState, connectivityState, clock.instant()));
return new RegistryState(updated, current.configurationFailure());
});
}
private void updateExisting(String domain,
MigrationState migrationState,
ConnectivityState connectivityState) {
String normalizedDomain = normalizeDomain(domain);
mutate(current -> {
TenantStatus previous = current.tenants().get(normalizedDomain);
if (previous == null) {
return current;
}
Map<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
updated.put(normalizedDomain, previous.withMigration(migrationState, connectivityState, clock.instant()));
return new RegistryState(updated, current.configurationFailure());
});
}
private void mutate(UnaryOperator<RegistryState> mutation) {
RegistryState current;
RegistryState updated;
do {
current = state.get();
updated = Objects.requireNonNull(mutation.apply(current), "Новое состояние реестра обязательно");
if (updated == current) {
return;
}
} while (!state.compareAndSet(current, updated));
}
private NormalizedConfig normalize(TenantConfig config) {
if (config == null || config.getDomain() == null || config.getDomain().isBlank()
|| config.getUrl() == null || config.getUrl().isBlank()) {
return null;
}
String domain = config.getDomain().trim().toLowerCase(Locale.ROOT);
String url = config.getUrl().trim();
if (!DOMAIN_PATTERN.matcher(domain).matches()) {
return null;
}
String name = config.getName() == null || config.getName().isBlank()
? domain
: config.getName().trim();
return new NormalizedConfig(
name,
domain,
url,
config.getUsername(),
config.getPassword(),
url.startsWith("jdbc:h2:")
);
}
private String normalizeDomain(String domain) {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Домен tenant не может быть пустым");
}
return domain.trim().toLowerCase(Locale.ROOT);
}
private String fingerprint(NormalizedConfig normalized) {
try {
Mac mac = Mac.getInstance(FINGERPRINT_ALGORITHM);
mac.init(new SecretKeySpec(fingerprintKey, FINGERPRINT_ALGORITHM));
updateFingerprint(mac, normalized.name());
updateFingerprint(mac, normalized.domain());
updateFingerprint(mac, normalized.url());
updateFingerprint(mac, normalized.username());
updateFingerprint(mac, normalized.password());
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal());
} catch (GeneralSecurityException cryptoFailure) {
throw new IllegalStateException("Не удалось сформировать безопасный fingerprint конфигурации", cryptoFailure);
}
}
private void updateFingerprint(Mac mac, String value) {
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
mac.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
mac.update(bytes);
}
private static byte[] randomFingerprintKey() {
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
return key;
}
public enum MigrationState {
PENDING,
IN_PROGRESS,
SUCCEEDED,
FAILED
}
public enum ConnectivityState {
UNKNOWN,
AVAILABLE,
UNAVAILABLE
}
public record ConnectivityTarget(String domain, String fingerprint) {
public ConnectivityTarget {
Objects.requireNonNull(domain, "Домен проверки обязателен");
Objects.requireNonNull(fingerprint, "Fingerprint проверки обязателен");
}
}
public record TenantStatus(String domain,
String fingerprint,
MigrationState migrationState,
ConnectivityState connectivityState,
Instant connectivityCheckedAt,
boolean h2Placeholder,
Instant changedAt) {
public TenantStatus {
Objects.requireNonNull(domain, "Домен состояния обязателен");
Objects.requireNonNull(fingerprint, "Fingerprint состояния обязателен");
Objects.requireNonNull(migrationState, "Состояние миграции обязательно");
Objects.requireNonNull(connectivityState, "Состояние соединения обязательно");
Objects.requireNonNull(changedAt, "Время изменения состояния обязательно");
}
private static TenantStatus pending(String domain,
String fingerprint,
boolean h2Placeholder,
Instant changedAt) {
return new TenantStatus(domain, fingerprint, MigrationState.PENDING,
ConnectivityState.UNKNOWN, null, h2Placeholder, changedAt);
}
private TenantStatus withMigration(MigrationState newMigrationState,
ConnectivityState newConnectivityState,
Instant now) {
return new TenantStatus(domain, fingerprint, newMigrationState,
newConnectivityState, null, h2Placeholder, now);
}
private TenantStatus withConnectivity(ConnectivityState newConnectivityState,
Instant checkedAt,
Instant now) {
return new TenantStatus(domain, fingerprint, migrationState,
newConnectivityState, checkedAt, h2Placeholder, now);
}
}
public record AggregateReadiness(boolean ready,
String status,
int desiredTenants,
int readyTenants,
int unavailableTenants,
int pendingMigrations,
int failedMigrations,
int unavailableConnections,
int staleConnections,
int unsupportedTenants,
boolean configurationFailure,
Instant assessedAt) {
public AggregateReadiness {
Objects.requireNonNull(status, "Текст готовности обязателен");
Objects.requireNonNull(assessedAt, "Время оценки готовности обязательно");
}
}
private record NormalizedConfig(String name,
String domain,
String url,
String username,
String password,
boolean h2Placeholder) {
}
private record RegistryState(Map<String, TenantStatus> tenants, boolean configurationFailure) {
private RegistryState {
tenants = Collections.unmodifiableMap(new LinkedHashMap<>(tenants));
}
private static RegistryState empty() {
return new RegistryState(Map.of(), false);
}
}
}

View File

@@ -0,0 +1,195 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicPeriodService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/calendar")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicPeriodService academicPeriodService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository,
ScheduleGeneratorService scheduleGeneratorService,
AcademicPeriodService academicPeriodService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicPeriodService = academicPeriodService;
}
@GetMapping("/years")
public List<AcademicYearDto> getYears() {
return academicYearRepository.findAll().stream()
.sorted(Comparator.comparing(AcademicYear::getStartDate).reversed())
.map(this::toAcademicYearDto)
.toList();
}
@PostMapping("/years")
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
}
@PutMapping("/years/{id}")
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
}
@DeleteMapping("/years/{id}")
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
academicPeriodService.deleteYear(id);
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
}
@GetMapping("/years/{academicYearId}/semesters")
public List<SemesterDto> getSemesters(@PathVariable Long academicYearId) {
return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream()
.map(this::toSemesterDto)
.toList();
}
@PostMapping("/years/{academicYearId}/semesters")
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
}
@PutMapping("/semesters/{id}")
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
}
@GetMapping("/activity-types")
public List<AcademicCalendarActivityTypeDto> getActivityTypes() {
return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream()
.map(this::toActivityTypeDto)
.toList();
}
@PostMapping("/activity-types")
public ResponseEntity<?> createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) {
String validationError = validateActivityType(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
}
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
applyActivityType(activityType, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
}
@PutMapping("/activity-types/{id}")
public ResponseEntity<?> updateActivityType(@PathVariable Long id,
@RequestBody AcademicCalendarActivityTypeDto request) {
AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null);
if (activityType == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateActivityType(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (activityTypeRepository.findByCode(request.code().trim())
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует"));
}
applyActivityType(activityType, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType)));
}
@DeleteMapping("/activity-types/{id}")
public ResponseEntity<?> deleteActivityType(@PathVariable Long id) {
if (!activityTypeRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
if (calendarDayRepository.existsByActivityTypeId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках"));
}
activityTypeRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
}
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
if (request == null) {
return "Передайте код активности";
}
if (request.code() == null || request.code().isBlank()) {
return "Код активности обязателен";
}
if (request.name() == null || request.name().isBlank()) {
return "Название активности обязательно";
}
return null;
}
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
activityType.setCode(request.code().trim());
activityType.setName(request.name().trim());
activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule()));
activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank()
? "#64748b"
: request.colorCode().trim());
activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder());
activityType.setDescription(request.description() == null || request.description().isBlank()
? null
: request.description().trim());
}
private AcademicYearDto toAcademicYearDto(AcademicYear year) {
List<SemesterDto> semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId())
.stream()
.map(this::toSemesterDto)
.toList();
return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters);
}
private SemesterDto toSemesterDto(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) {
return new AcademicCalendarActivityTypeDto(
activityType.getId(),
activityType.getCode(),
activityType.getName(),
activityType.getAllowSchedule(),
activityType.getColorCode(),
activityType.getDisplayOrder(),
activityType.getDescription()
);
}
}

View File

@@ -0,0 +1,285 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicStructureService academicStructureService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService,
AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicStructureService = academicStructureService;
}
@GetMapping
public List<AcademicCalendarDto> getCalendars(@RequestParam(required = false) Long academicYearId,
@RequestParam(required = false) Long specialtyId,
@RequestParam(required = false) Long profileId) {
return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream()
.map(this::toCalendarDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
return calendarRepository.findByIdWithDetails(id)
.<ResponseEntity<?>>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try {
AcademicCalendar calendar = new AcademicCalendar();
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteCalendar(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
calendarRepository.deleteById(id);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
}
@GetMapping("/{id}/grid")
public ResponseEntity<?> getGrid(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream()
.map(this::toGridDayDto)
.toList());
}
@PutMapping("/{id}/grid")
public ResponseEntity<?> saveGrid(@PathVariable("id") Long id,
@RequestBody List<AcademicCalendarGridDayDto> rows) {
calendarGridService.replaceGrid(id, rows);
return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён"));
}
@GetMapping("/{id}/subjects")
public ResponseEntity<?> getSubjects(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
}
@PutMapping("/{id}/subjects")
@Transactional
public ResponseEntity<?> saveSubjects(@PathVariable Long id,
@RequestBody List<AcademicCalendarSubjectDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
}
AcademicYear year = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
}
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
calendar.setAcademicYear(year);
calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile);
calendar.setStudyForm(studyForm);
calendar.setCourseCount(request.courseCount());
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
}
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
Set<Long> subjectIds = new HashSet<>();
Set<String> uniqueRows = new HashSet<>();
for (AcademicCalendarSubjectDto row : rows) {
if (row == null || row.semesterNumber() == null || row.subjectId() == null) {
throw new IllegalArgumentException("Семестр и дисциплина обязательны");
}
if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) {
throw new IllegalArgumentException("Номер семестра выходит за пределы графика");
}
if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) {
throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру");
}
subjectIds.add(row.subjectId());
}
Map<Long, Subject> subjectsById = subjectRepository.findAllById(subjectIds).stream()
.collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new));
for (Long subjectId : subjectIds) {
Subject subject = subjectsById.get(subjectId);
if (subject == null) {
throw new IllegalArgumentException("Дисциплина не найдена");
}
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику");
}
}
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
calendarSubjectRepository.flush();
List<AcademicCalendarSubject> entities = rows.stream()
.map(row -> {
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
calendarSubject.setAcademicCalendar(calendar);
calendarSubject.setSemesterNumber(row.semesterNumber());
calendarSubject.setSubject(subjectsById.get(row.subjectId()));
return calendarSubject;
})
.toList();
calendarSubjectRepository.saveAll(entities);
}
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality();
SpecialtyProfile profile = calendar.getSpecialtyProfile();
EducationForm studyForm = calendar.getStudyForm();
return new AcademicCalendarDto(
calendar.getId(),
calendar.getTitle(),
year.getId(),
year.getTitle(),
year.getStartDate(),
year.getEndDate(),
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getId(),
profile.getName(),
studyForm.getId(),
studyForm.getName(),
calendar.getCourseCount()
);
}
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) {
AcademicCalendarActivityType activityType = day.getActivityType();
return new AcademicCalendarGridDayDto(
day.getId(),
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate(),
day.getWeekNumber(),
day.getDayOfWeek(),
activityType.getId(),
activityType.getCode(),
activityType.getName(),
activityType.getAllowSchedule()
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -1,51 +1,214 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.JwtProperties;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.auth.ClientIpResolver;
import com.magistr.app.config.auth.LoginRateLimitService;
import com.magistr.app.config.auth.RefreshTokenRotation;
import com.magistr.app.config.auth.RefreshTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final LoginRateLimitService loginRateLimitService;
private final ClientIpResolver clientIpResolver;
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
"EDUCATION_OFFICE", "/admin/#schedule-view",
"DEPARTMENT", "/admin/#department-workspace",
"SCHEDULE_VIEWER", "/admin/#schedule-view",
"TEACHER", "/teacher/",
"STUDENT", "/student/"
);
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
public AuthController(LoginRateLimitService loginRateLimitService,
ClientIpResolver clientIpResolver,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.loginRateLimitService = loginRateLimitService;
this.clientIpResolver = clientIpResolver;
this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
TenantContext.getCurrentTenant(),
request.getUsername(),
request.getPassword(),
clientIpResolver.resolve(servletRequest)
);
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
.body(new LoginResponse(
false,
"Слишком много попыток входа. Повторите позже",
null,
null,
null,
null
));
}
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
.body(new LoginResponse(
false,
"Неверное имя пользователя или пароль",
null,
null,
null,
null
));
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
User user = decision.user();
String tenant = TenantContext.getCurrentTenant();
String accessToken = jwtTokenService.createAccessToken(user, tenant);
String refreshToken = refreshTokenService.createSession(
user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIpResolver.resolve(servletRequest)
);
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
.body(loginResponse(user, accessToken));
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(HttpServletRequest request) {
Optional<String> refreshToken = refreshCookieValue(request);
if (refreshToken.isEmpty()) {
return unauthorized();
}
Optional<RefreshTokenRotation> rotation = refreshTokenService.rotate(
refreshToken.get(),
TenantContext.getCurrentTenant(),
request.getHeader("User-Agent"),
clientIpResolver.resolve(request)
);
if (rotation.isEmpty()) {
return unauthorizedWithClearedCookie();
}
User user = rotation.get().user();
String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString())
.body(loginResponse(user, accessToken));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
refreshCookieValue(request).ifPresent(token ->
refreshTokenService.revoke(token, TenantContext.getCurrentTenant())
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
.body(Map.of("success", true, "message", "Выход выполнен"));
}
@GetMapping("/me")
public ResponseEntity<?> me() {
var user = AuthContext.getCurrentUser();
if (user == null) {
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("userId", user.id());
response.put("username", user.username());
response.put("role", user.role().name());
response.put("departmentId", user.departmentId());
return ResponseEntity.ok(response);
}
private LoginResponse loginResponse(User user, String token) {
String roleName = user.getRole().name();
return new LoginResponse(
true,
"OK",
token,
roleName,
ROLE_REDIRECTS.getOrDefault(roleName, "/"),
user.getDepartmentId(),
user.getId()
);
}
private ResponseEntity<LoginResponse> unauthorized() {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
}
private ResponseEntity<LoginResponse> unauthorizedWithClearedCookie() {
return ResponseEntity
.status(401)
.header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString())
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
}
private ResponseCookie refreshCookie(String token) {
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token)
.httpOnly(true)
.secure(jwtProperties.isRefreshCookieSecure())
.sameSite("Lax")
.path("/api/auth")
.maxAge(jwtProperties.getRefreshTtl())
.build();
}
private ResponseCookie clearRefreshCookie() {
return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "")
.httpOnly(true)
.secure(jwtProperties.isRefreshCookieSecure())
.sameSite("Lax")
.path("/api/auth")
.maxAge(Duration.ZERO)
.build();
}
private Optional<String> refreshCookieValue(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank());
}
}
return Optional.empty();
}
}

View File

@@ -1,9 +1,12 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomRequest;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Equipment;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -15,6 +18,7 @@ import java.util.Optional;
@RestController
@RequestMapping("/api/classrooms")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ClassroomController {
private final ClassroomRepository classroomRepository;
@@ -26,8 +30,12 @@ public class ClassroomController {
}
@GetMapping
public List<ClassroomResponse> getAllClassrooms() {
return classroomRepository.findAll().stream()
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
List<Classroom> classrooms = includeArchived
? classroomRepository.findAll()
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
return classrooms.stream()
.map(this::mapToResponse)
.toList();
}
@@ -47,10 +55,14 @@ public class ClassroomController {
Classroom classroom = new Classroom();
classroom.setName(request.getName().trim());
classroom.setCapacity(request.getCapacity());
classroom.setBuilding(cleanText(request.getBuilding()));
classroom.setFloor(request.getFloor());
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -80,12 +92,23 @@ public class ClassroomController {
classroom.setCapacity(request.getCapacity());
}
if (request.getBuilding() != null) {
classroom.setBuilding(cleanText(request.getBuilding()));
}
if (request.getFloor() != null) {
classroom.setFloor(request.getFloor());
}
if (request.getIsAvailable() != null) {
classroom.setIsAvailable(request.getIsAvailable());
}
if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
equipments = equipments.stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -95,15 +118,37 @@ public class ClassroomController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
classroomRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
Classroom classroom = classroomOpt.get();
classroom.archive("Выведена из эксплуатации");
classroom.setIsAvailable(false);
classroomRepository.save(classroom);
return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreClassroom(@PathVariable Long id) {
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Classroom classroom = classroomOpt.get();
classroom.restore();
classroom.setIsAvailable(true);
classroomRepository.save(classroom);
return ResponseEntity.ok(mapToResponse(classroom));
}
private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
new java.util.ArrayList<>(c.getEquipments()));
}
private String cleanText(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -0,0 +1,194 @@
package com.magistr.app.controller;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
*/
final class DatabaseConstraintViolationMapper {
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
);
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"),
badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"),
badRequest("chk_subgroups_student_capacity_positive", "Численность подгруппы должна быть больше нуля"),
badRequest("chk_student_group_subgroup_capacity", "Сумма численностей активных подгрупп не может превышать численность группы"),
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("uq_calendar_days", "День календарного графика уже существует"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
);
ViolationResponse map(DataIntegrityViolationException exception) {
String constraintName = constraintName(exception).orElse(null);
String sqlState = sqlState(exception).orElse(null);
if (constraintName != null) {
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
if (known != null) {
return known.withDetails(constraintName, sqlState);
}
if (constraintName.endsWith("_fkey")) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за связанных данных",
constraintName,
sqlState
);
}
}
if ("23502".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Не заполнено обязательное поле",
constraintName,
sqlState
);
}
if ("23514".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.BAD_REQUEST,
"Данные нарушают правила предметной области",
constraintName,
sqlState
);
}
if ("23505".equals(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Такая запись уже существует",
constraintName,
sqlState
);
}
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция невозможна из-за конфликта или связанных данных",
constraintName,
sqlState
);
}
return new ViolationResponse(
HttpStatus.CONFLICT,
"Операция нарушает ограничения целостности данных",
constraintName,
sqlState
);
}
private Optional<String> constraintName(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof ConstraintViolationException constraintViolation
&& constraintViolation.getConstraintName() != null) {
return normalize(constraintViolation.getConstraintName());
}
String message = current.getMessage();
if (message != null) {
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
if (matcher.find()) {
return normalize(matcher.group(1));
}
}
}
return Optional.empty();
}
private Optional<String> sqlState(Throwable failure) {
for (Throwable current = failure; current != null; current = nextCause(current)) {
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
return Optional.of(sqlException.getSQLState());
}
}
return Optional.empty();
}
private Throwable nextCause(Throwable current) {
Throwable cause = current.getCause();
return cause == current ? null : cause;
}
private Optional<String> normalize(String constraintName) {
if (constraintName == null || constraintName.isBlank()) {
return Optional.empty();
}
String normalized = constraintName.trim()
.replace("\"", "")
.toLowerCase(Locale.ROOT);
int dot = normalized.lastIndexOf('.');
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
}
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
}
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
}
record ViolationResponse(HttpStatus status,
String message,
String constraintName,
String sqlState) {
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
}
}
}

View File

@@ -0,0 +1,168 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleMutationResult;
import com.magistr.app.service.TenantLifecycleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
/**
* API управления подключениями к базам данных (тенантами).
* Доступно только для ADMIN.
*
* Изменения проходят безопасный lifecycle: подготовка и миграция временного
* пула, сохранение desired-конфигурации и только затем локальная активация.
*/
@RestController
@RequestMapping("/api/database")
@RequireRoles({Role.ADMIN})
public class DatabaseController {
private final TenantRoutingDataSource routingDataSource;
private final TenantLifecycleService tenantLifecycleService;
private final TenantConfigWatcher tenantConfigWatcher;
public DatabaseController(TenantRoutingDataSource routingDataSource,
TenantLifecycleService tenantLifecycleService,
TenantConfigWatcher tenantConfigWatcher) {
this.routingDataSource = routingDataSource;
this.tenantLifecycleService = tenantLifecycleService;
this.tenantConfigWatcher = tenantConfigWatcher;
}
/**
* Статус текущего подключения (по домену запроса).
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getStatus() {
String currentTenant = TenantContext.getCurrentTenant();
boolean connected = routingDataSource.testConnection(currentTenant);
TenantConfig config = routingDataSource.getTenantConfigs().get(currentTenant);
Map<String, Object> result = new HashMap<>();
result.put("tenant", currentTenant);
result.put("connected", connected);
result.put("configured", config != null);
if (config != null) {
result.put("name", config.getName());
result.put("url", config.getUrl());
}
return ResponseEntity.ok(result);
}
/**
* Список всех тенантов.
*/
@GetMapping("/tenants")
public ResponseEntity<List<Map<String, Object>>> getTenants() {
List<Map<String, Object>> result = new ArrayList<>();
for (TenantConfig config : routingDataSource.getTenantConfigs().values()) {
Map<String, Object> tenant = new HashMap<>();
tenant.put("name", config.getName());
tenant.put("domain", config.getDomain());
tenant.put("url", config.getUrl());
tenant.put("username", config.getUsername());
tenant.put("connected", routingDataSource.testConnection(config.getDomain()));
result.add(tenant);
}
return ResponseEntity.ok(result);
}
/**
* Добавить новый тенант.
*/
@PostMapping("/tenants")
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
try {
return tenantLifecycleService.executeSerialized(() -> {
tenantConfigWatcher.prepareForApiMutation();
TenantLifecycleMutationResult mutation =
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig saved = mutation.tenant();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
return ResponseEntity.ok(result);
});
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
}
/**
* Удалить тенант.
*/
@DeleteMapping("/tenants/{domain}")
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
try {
return tenantLifecycleService.executeSerialized(() -> {
tenantConfigWatcher.prepareForApiMutation();
TenantLifecycleMutationResult mutation =
tenantLifecycleService.removeTenantWithReceipt(domain);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig removed = mutation.tenant();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
return ResponseEntity.ok(result);
});
} catch (NoSuchElementException notFound) {
return lifecycleError(HttpStatus.NOT_FOUND, notFound.getMessage());
} catch (IllegalArgumentException validationFailure) {
return lifecycleError(HttpStatus.BAD_REQUEST, validationFailure.getMessage());
} catch (TenantLifecycleException lifecycleFailure) {
return lifecycleError(HttpStatus.SERVICE_UNAVAILABLE, lifecycleFailure.getMessage());
}
}
/**
* Тест подключения к произвольной БД.
*/
@PostMapping("/test")
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody Map<String, String> params) {
String url = params.get("url");
String username = params.get("username");
String password = params.get("password");
Map<String, Object> result = new HashMap<>();
if (url == null || url.isBlank()) {
result.put("success", false);
result.put("message", "URL не указан");
return ResponseEntity.badRequest().body(result);
}
String testResult = routingDataSource.testExternalConnection(url, username, password);
boolean success = "OK".equals(testResult);
result.put("success", success);
result.put("message", success ? "Подключение успешно!" : testResult);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> lifecycleError(HttpStatus status, String message) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", message);
return ResponseEntity.status(status).body(result);
}
}

View File

@@ -0,0 +1,182 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/departments")
@RequireRoles({Role.ADMIN})
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
private final DepartmentRepository departmentRepository;
public DepartmentController(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
d.getDepartmentName(),
d.getDepartmentCode()
))
.toList();
logger.info("Получено {} кафедр", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createDepartment(@RequestBody CreateDepartmentRequest request) {
logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode());
try {
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){
String errorMessage = "Название кафедры обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) {
String errorMessage = "Кафедра с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
String errorMessage = "Код кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) {
String errorMessage = "Кафедра с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department department = new Department();
department.setDepartmentName(request.getDepartmentName());
department.setDepartmentCode(request.getDepartmentCode());
departmentRepository.save(department);
logger.info("Кафедра успешно создана с ID: {}", department.getId());
return ResponseEntity.ok(
new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании кафедры", e);
throw e;
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateDepartment(@PathVariable Long id, @RequestBody CreateDepartmentRequest request) {
logger.info("Получен запрос на обновление кафедры с ID: {}", id);
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
try {
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()) {
String errorMessage = "Название кафедры обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
String errorMessage = "Код кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
String departmentName = request.getDepartmentName().trim();
if (departmentRepository.findByDepartmentName(departmentName)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Кафедра с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode())
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Кафедра с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
department.setDepartmentName(departmentName);
department.setDepartmentCode(request.getDepartmentCode());
departmentRepository.save(department);
logger.info("Кафедра с ID - {} успешно обновлена", id);
return ResponseEntity.ok(new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
throw e;
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
department.archive("Кафедра архивирована");
departmentRepository.save(department);
logger.info("Кафедра с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Кафедра архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreDepartment(@PathVariable Long id) {
Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
return ResponseEntity.notFound().build();
}
department.restore();
departmentRepository.save(department);
return ResponseEntity.ok(new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
));
}
}

View File

@@ -0,0 +1,359 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.SubjectImportService;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/department")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
private final TeacherDepartmentService teacherDepartmentService;
private final SubjectImportService subjectImportService;
private final BusinessTimeService businessTime;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService) {
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
scheduleQueryService, teacherDepartmentService, subjectImportService,
BusinessTimeService.systemDefault());
}
@Autowired
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService,
TeacherDepartmentService teacherDepartmentService,
SubjectImportService subjectImportService,
BusinessTimeService businessTime) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
this.teacherDepartmentService = teacherDepartmentService;
this.subjectImportService = subjectImportService;
this.businessTime = businessTime;
}
@GetMapping("/subjects")
public List<Subject> getSubjects(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping("/subjects/import")
public ResponseEntity<?> importSubjects(@RequestBody List<CreateSubjectRequest> requests) {
if (requests == null || requests.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки"));
}
Long requestedDepartmentId = requests.stream()
.map(CreateSubjectRequest::getDepartmentId)
.filter(id -> id != null && id > 0)
.findFirst()
.orElse(null);
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
return ResponseEntity.ok(Map.of(
"message", "Дисциплины загружены",
"count", saved.size()
));
}
@GetMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> getComments(@PathVariable Long subjectId) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream()
.map(this::toCommentDto)
.toList());
}
@PostMapping("/subjects/{subjectId}/comments")
public ResponseEntity<?> addComment(@PathVariable Long subjectId, @RequestBody Map<String, String> request) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || !canAccessDepartment(subject.getDepartmentId())) {
return ResponseEntity.notFound().build();
}
String commentText = request == null ? null : request.get("comment");
if (commentText == null || commentText.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
}
SubjectComment comment = new SubjectComment();
comment.setCreatedAt(businessTime.now());
comment.setSubject(subject);
comment.setComment(commentText.trim());
Long currentUserId = AuthContext.currentUserId();
if (currentUserId != null) {
userRepository.findById(currentUserId).ifPresent(comment::setAuthor);
}
return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment)));
}
@GetMapping("/teachers")
public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(user -> toUserResponse(
user,
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
user.getId(),
businessTime.today()
).orElse(user.getDepartmentId())
))
.toList();
}
return teacherDepartmentService
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
.map(user -> toUserResponse(user, effectiveDepartmentId))
.toList();
}
@PostMapping("/teachers/{teacherId}/assignments")
@Transactional
public ResponseEntity<?> addTeacherToDepartment(@PathVariable Long teacherId,
@RequestBody(required = false) Map<String, Object> request) {
Long requestedDepartmentId = asLong(request == null ? null : request.get("departmentId"));
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = userRepository.findById(teacherId).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = businessTime.today();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(today);
assignment.setPrimaryAssignment(false);
assignment.setComment(trimToNull(request == null ? null : request.get("comment")));
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
return ResponseEntity.ok(toAssignmentMap(assignment));
}
@GetMapping("/teacher-requests")
public List<TeacherCreationRequestResponse> getTeacherRequests(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
return teacherCreationRequestRepository.findByDepartment_IdOrderByCreatedAtDesc(effectiveDepartmentId).stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
@PostMapping("/teacher-requests")
public ResponseEntity<?> createTeacherRequest(@RequestBody CreateTeacherCreationRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные заявки обязательны"));
}
Long departmentId = effectiveDepartmentId(request == null ? null : request.getDepartmentId());
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
String username = trimToNull(request.getUsername());
String fullName = trimToNull(request.getFullName());
String jobTitle = trimToNull(request.getJobTitle());
if (username == null || fullName == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин и ФИО преподавателя обязательны"));
}
if (username.length() > 50 || fullName.length() > 255 || (jobTitle != null && jobTitle.length() > 255)) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей заявки"));
}
if (userRepository.findByUsername(username).isPresent()
|| teacherCreationRequestRepository.existsByUsernameAndStatus(username, TeacherCreationRequestStatus.PENDING)) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин уже занят или ожидает одобрения"));
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setCreatedAt(businessTime.now());
teacherRequest.setUpdatedAt(businessTime.now());
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
teacherRequest.setJobTitle(jobTitle == null ? "Не указано" : jobTitle);
teacherRequest.setComment(trimToNull(request.getComment()));
teacherRequest.setRequestedBy(AuthContext.currentUserId());
return ResponseEntity.ok(toTeacherCreationRequestResponse(teacherCreationRequestRepository.save(teacherRequest)));
}
@GetMapping("/schedule")
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId,
null, null, null, null, startDate, endDate));
}
private SubjectCommentDto toCommentDto(SubjectComment comment) {
User author = comment.getAuthor();
return new SubjectCommentDto(
comment.getId(),
comment.getSubject().getId(),
author == null ? null : author.getId(),
author == null ? null : author.getFullName(),
comment.getComment(),
comment.getCreatedAt()
);
}
private UserResponse toUserResponse(User user, Long departmentId) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
departmentId
);
response.setStatus(user.getStatus());
return response;
}
private TeacherCreationRequestResponse toTeacherCreationRequestResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private Map<String, Object> toAssignmentMap(TeacherDepartmentAssignment assignment) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", assignment.getId());
response.put("teacherId", assignment.getTeacher().getId());
response.put("teacherName", assignment.getTeacher().getFullName());
response.put("departmentId", assignment.getDepartment().getId());
response.put("departmentName", assignment.getDepartment().getDepartmentName());
response.put("validFrom", assignment.getValidFrom());
response.put("primaryAssignment", assignment.getPrimaryAssignment());
return response;
}
private Long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && !text.isBlank()) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
}
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
}
private boolean canAccessDepartment(Long departmentId) {
var user = AuthContext.getCurrentUser();
return user == null
|| user.role() == Role.ADMIN
|| (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
}
}

View File

@@ -1,7 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import org.springframework.http.ResponseEntity;
@@ -12,20 +15,25 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;
private final GroupRepository groupRepository;
private final AcademicCalendarRepository academicCalendarRepository;
public EducationFormController(EducationFormRepository educationFormRepository,
GroupRepository groupRepository) {
GroupRepository groupRepository,
AcademicCalendarRepository academicCalendarRepository) {
this.educationFormRepository = educationFormRepository;
this.groupRepository = groupRepository;
this.academicCalendarRepository = academicCalendarRepository;
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Map<String, Object>> getAll() {
return educationFormRepository.findAll().stream()
return educationFormRepository.findAllByOrderByNameAsc().stream()
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
.toList();
}
@@ -54,12 +62,17 @@ public class EducationFormController {
return ResponseEntity.notFound().build();
}
// Check if any groups use this education form
// Нельзя удалить форму обучения, пока она используется группами или графиками.
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
if (!linked.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
}
long linkedCalendars = academicCalendarRepository.countByStudyFormId(id);
if (linkedCalendars > 0) {
return ResponseEntity.badRequest().body(Map.of(
"message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")"));
}
educationFormRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));

View File

@@ -1,5 +1,8 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Equipment;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
@@ -10,6 +13,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/equipments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EquipmentController {
private final EquipmentRepository equipmentRepository;
@@ -19,8 +23,8 @@ public class EquipmentController {
}
@GetMapping
public List<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
}
@PostMapping
@@ -42,10 +46,23 @@ public class EquipmentController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
if (!equipmentRepository.existsById(id)) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
equipment.archive("Оборудование архивировано");
equipmentRepository.save(equipment);
return ResponseEntity.ok(Map.of("message", "Оборудование архивировано"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreEquipment(@PathVariable Long id) {
Equipment equipment = equipmentRepository.findById(id).orElse(null);
if (equipment == null) {
return ResponseEntity.notFound().build();
}
equipment.restore();
equipmentRepository.save(equipment);
return ResponseEntity.ok(equipment);
}
}

View File

@@ -0,0 +1,107 @@
package com.magistr.app.controller;
import com.magistr.app.service.ScheduleConflictException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final DatabaseConstraintViolationMapper constraintViolationMapper =
new DatabaseConstraintViolationMapper();
@ExceptionHandler(ScheduleConflictException.class)
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Изменение создаёт конфликт расписания"
: exception.getMessage();
return error(HttpStatus.CONFLICT, message, request);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Некорректный запрос"
: exception.getMessage();
return error(HttpStatus.BAD_REQUEST, message, request);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException exception,
HttpServletRequest request) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Запрошенная запись не найдена"
: exception.getMessage();
return error(HttpStatus.NOT_FOUND, message, request);
}
@ExceptionHandler({
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<Map<String, Object>> handleBadRequest(Exception exception,
HttpServletRequest request) {
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
DataIntegrityViolationException exception,
HttpServletRequest request) {
DatabaseConstraintViolationMapper.ViolationResponse violation =
constraintViolationMapper.map(exception);
log.warn(
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
request.getMethod(),
request.getRequestURI(),
violation.constraintName() == null ? "не определено" : violation.constraintName(),
violation.sqlState() == null ? "не определено" : violation.sqlState()
);
log.debug("Технические детали нарушения целостности данных", exception);
return error(violation.status(), violation.message(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
HttpServletRequest request) {
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now().toString());
body.put("status", status.value());
body.put("error", reason(status));
body.put("message", message);
body.put("path", request.getRequestURI());
return ResponseEntity.status(status).body(body);
}
private String reason(HttpStatus status) {
return switch (status) {
case BAD_REQUEST -> "Некорректный запрос";
case CONFLICT -> "Конфликт";
case NOT_FOUND -> "Не найдено";
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
default -> status.name();
};
}
}

View File

@@ -1,83 +1,290 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.Subject;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.service.BusinessTimeService;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/groups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
private final GroupRepository groupRepository;
private final EducationFormRepository educationFormRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicStructureService academicStructureService;
private final BusinessTimeService businessTime;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository) {
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService) {
this(groupRepository, calendarSubjectRepository, assignmentRepository,
scheduleGeneratorService, groupLifecycleService, academicStructureService,
BusinessTimeService.systemDefault());
}
@Autowired
public GroupController(GroupRepository groupRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService,
BusinessTimeService businessTime) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
this.academicStructureService = academicStructureService;
this.businessTime = businessTime;
}
@GetMapping
public List<GroupResponse> getAllGroups() {
return groupRepository.findAll().stream()
.map(g -> new GroupResponse(
g.getId(),
g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(),
g.getEducationForm().getName()))
.toList();
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
.toList();
logger.info("Получено {} групп", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.toList();
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Группы для указанной кафедры не найдены");
}
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
.toList();
logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка групп");
}
}
@PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
}
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
}
if (request.getGroupSize() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
}
if (request.getEducationFormId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
}
logger.info("Получен запрос на создание новой группы");
StudentGroup group = academicStructureService.createGroup(request);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
@PutMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
StudentGroup group = academicStructureService.updateGroup(id, request);
logger.info("Группа с ID - {} успешно обновлена", id);
return ResponseEntity.ok(mapToResponse(group));
}
StudentGroup group = new StudentGroup();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
@DeleteMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id);
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
groupRepository.save(group);
logger.info("Группа с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
}
return ResponseEntity.ok(new GroupResponse(
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreGroup(@PathVariable Long id) {
StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
group.restore();
groupRepository.save(group);
return ResponseEntity.ok(mapToResponse(group));
}
private GroupResponse mapToResponse(StudentGroup group) {
LocalDate today = businessTime.today();
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse(
group.getId(),
group.getName(),
group.getGroupSize(),
group.getEducationForm().getId(),
group.getEducationForm().getName()));
group.getEducationForm().getName(),
group.getDepartmentId(),
group.getYearStartStudy(),
course,
semester,
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getId(),
profile.getName(),
group.getStatus(),
groupLifecycleService.isAvailableForSelection(group, today),
studyState.name(),
studyState.getLabel()
);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
@GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
return ResponseEntity.ok(assignments.stream()
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
.toList());
}
@PutMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) {
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
}
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {
return ResponseEntity.notFound().build();
}
assignmentRepository.delete(assignment);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
}
private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
List<StudentGroupCalendarAssignment> assignments) {
Set<Long> calendarIds = assignments.stream()
.map(assignment -> assignment.getAcademicCalendar().getId())
.collect(Collectors.toSet());
if (calendarIds.isEmpty()) {
return Collections.emptyMap();
}
return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream()
.collect(Collectors.groupingBy(
calendarSubject -> calendarSubject.getAcademicCalendar().getId(),
Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList())
));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
return toAssignmentDto(
assignment,
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
);
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
List<AcademicCalendarSubjectDto> subjects) {
return new GroupCalendarAssignmentDto(
assignment.getId(),
assignment.getStudentGroup().getId(),
assignment.getStudentGroup().getName(),
assignment.getAcademicYear().getId(),
assignment.getAcademicYear().getTitle(),
assignment.getAcademicCalendar().getId(),
assignment.getAcademicCalendar().getTitle(),
subjects
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -0,0 +1,33 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.LessonTypeResponse;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.Role;
import com.magistr.app.repository.LessonTypesRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Comparator;
import java.util.List;
@RestController
@RequestMapping("/api/lesson-types")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class LessonTypeController {
private final LessonTypesRepository lessonTypesRepository;
public LessonTypeController(LessonTypesRepository lessonTypesRepository) {
this.lessonTypesRepository = lessonTypesRepository;
}
@GetMapping
public List<LessonTypeResponse> getAllLessonTypes() {
return lessonTypesRepository.findAll().stream()
.sorted(Comparator.comparing(LessonType::getLessonType))
.map(lessonType -> new LessonTypeResponse(lessonType.getId(), lessonType.getLessonType()))
.toList();
}
}

View File

@@ -1,507 +0,0 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateLessonRequest;
import com.magistr.app.dto.LessonResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.utils.DayAndWeekValidator;
import com.magistr.app.utils.TypeAndFormatLessonValidator;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import org.slf4j.Logger;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/users/lessons")
public class LessonsController {
private static final Logger logger = LoggerFactory.getLogger(LessonsController.class);
private final LessonRepository lessonRepository;
private final UserRepository teacherRepository;
private final GroupRepository groupRepository;
private final SubjectRepository subjectRepository;
private final EducationFormRepository educationFormRepository;
private final ClassroomRepository classroomRepository;
public LessonsController(LessonRepository lessonRepository, UserRepository teacherRepository, GroupRepository groupRepository, SubjectRepository subjectRepository, EducationFormRepository educationForm, ClassroomRepository classroomRepository) {
this.lessonRepository = lessonRepository;
this.teacherRepository = teacherRepository;
this.groupRepository = groupRepository;
this.subjectRepository = subjectRepository;
this.educationFormRepository = educationForm;
this.classroomRepository = classroomRepository;
}
//Создание нового занятия
@PostMapping("/create")
public ResponseEntity<?> createLesson(@RequestBody CreateLessonRequest request) {
//Полное логирование входящего запроса
logger.info("Получен запрос на создание занятия: teacherId={}, groupId={}, subjectId={}, day={}, week={}, time={}",
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getDay(), request.getWeek(), request.getTime());
//Проверка teacherId
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
String errorMessage = "ID преподавателя обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка groupId
if (request.getGroupId() == null || request.getGroupId() == 0) {
String errorMessage = "ID группы обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка subjectId
if (request.getSubjectId() == null || request.getSubjectId() == 0) {
String errorMessage = "ID предмета обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка lessonFormat
if (request.getLessonFormat() == null || request.getLessonFormat().isBlank()) {
String errorMessage = "Выбор формата занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())){
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
logger.info("Ошибка валидации формата: '{}' - {}", request.getLessonFormat(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка typeLesson
if (request.getTypeLesson() == null || request.getTypeLesson().isBlank()) {
String errorMessage = "Выбор типа занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())){
String errorMessage = "Некорректный тип занятия. " + TypeAndFormatLessonValidator.getValidTypesMessage();
logger.info("Ошибка валидации типа: '{}' - {}", request.getTypeLesson(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка classroomId
if (request.getClassroomId() == null || request.getClassroomId() == 0) {
String errorMessage = "ID аудитории обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка day
if (request.getDay() == null || request.getDay().isBlank()) {
String errorMessage = "Выбор дня обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!DayAndWeekValidator.isValidDay(request.getDay())){
String errorMessage = "Некорректный день недели. " + DayAndWeekValidator.getValidDaysMessage();
logger.info("Ошибка валидации дня: '{}' - {}", request.getDay(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка week
if (request.getWeek() == null || request.getWeek().isBlank()) {
String errorMessage = "Выбор недели обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!DayAndWeekValidator.isValidWeek(request.getWeek())){
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
logger.info("Ошибка валидации недели: '{}' - {}", request.getWeek(), errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Проверка time
if (request.getTime() == null || request.getTime().isBlank()) {
String errorMessage = "Время обязательно";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
//Сохранение полученных данных и формирование ответа клиенту
try {
Lesson lesson = new Lesson();
lesson.setTeacherId(request.getTeacherId());
lesson.setSubjectId(request.getSubjectId());
lesson.setGroupId(request.getGroupId());
lesson.setLessonFormat(request.getLessonFormat());
lesson.setTypeLesson(request.getTypeLesson());
lesson.setClassroomId(request.getClassroomId());
lesson.setDay(request.getDay());
lesson.setWeek(request.getWeek());
lesson.setTime(request.getTime());
Lesson savedLesson = lessonRepository.save(lesson);
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", savedLesson.getId());
response.put("teacherId", savedLesson.getTeacherId());
response.put("groupId", savedLesson.getGroupId());
response.put("subjectId", savedLesson.getSubjectId());
response.put("formatLesson", savedLesson.getLessonFormat());
response.put("typeLesson", savedLesson.getTypeLesson());
response.put("classroomId", savedLesson.getClassroomId());
response.put("day", savedLesson.getDay());
response.put("week", savedLesson.getWeek());
response.put("time", savedLesson.getTime());
logger.info("Занятие успешно создано с ID: {}", savedLesson.getId());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при сохранении занятия: {}", e.getMessage(),e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании занятия: " + e.getMessage()));
}
}
//Запрос для получения всего списка занятий
@GetMapping
public List<LessonResponse> getAllLessons() {
logger.info("Запрос на получение всех занятий");
try {
List<Lesson> lessons = lessonRepository.findAll();
List<LessonResponse> response = lessons.stream()
.map(lesson -> {
String teacherName = teacherRepository.findById(lesson.getTeacherId())
.map(User::getUsername)
.orElse("Неизвестно");
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
String groupName = groupRepository.findById(lesson.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
String educationFormName = "Неизвестно";
if(group != null && group.getEducationForm() != null) {
Long educationFormId = group.getEducationForm().getId();
educationFormName = educationFormRepository.findById(educationFormId)
.map(EducationForm::getName)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(lesson.getSubjectId())
.map(Subject::getName)
.orElse("Неизвестно");
String classroomName = classroomRepository.findById(lesson.getClassroomId())
.map(Classroom::getName)
.orElse("Неизвестно");
return new LessonResponse(
lesson.getId(),
teacherName,
groupName,
classroomName,
educationFormName,
subjectName,
lesson.getTypeLesson(),
lesson.getLessonFormat(),
lesson.getDay(),
lesson.getWeek(),
lesson.getTime()
);
})
.toList();
logger.info("Получено {} занятий", lessons.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка всех занятий: {}", e.getMessage(), e);
throw e;
}
}
//Запрос на получение всех занятий для конкретного преподавателя
@GetMapping("/{teacherId}")
public ResponseEntity<?> getLessonsById(@PathVariable Long teacherId) {
logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId);
try {
List<Lesson> lessons = lessonRepository.findByTeacherId(teacherId);
if(lessons.isEmpty()) {
logger.info("У преподавателя с ID {} нет занятий", teacherId);
return ResponseEntity.ok(Map.of(
"message", "У преподавателя с ID " + teacherId +" нет занятий.",
"lessons", Collections.emptyList()
));
}
List<LessonResponse> lessonResponses = lessons.stream()
.map(lesson -> {
String teacherName = teacherRepository.findById(lesson.getTeacherId())
.map(User::getUsername)
.orElse("Неизвестно");
StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null);
String groupName = groupRepository.findById(lesson.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
String educationFormName = "Неизвестно";
if(group != null && group.getEducationForm() != null) {
Long educationFormId = group.getEducationForm().getId();
educationFormName = educationFormRepository.findById(educationFormId)
.map(EducationForm::getName)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(lesson.getSubjectId())
.map(Subject::getName)
.orElse("Неизвестно");
String classroomName = classroomRepository.findById(lesson.getClassroomId())
.map(Classroom::getName)
.orElse("Неизвестно");
return new LessonResponse(
lesson.getId(),
teacherName,
groupName,
classroomName,
educationFormName,
subjectName,
lesson.getTypeLesson(),
lesson.getLessonFormat(),
lesson.getDay(),
lesson.getWeek(),
lesson.getTime()
);
})
.toList();
logger.info("Найдено {} занятий для преподавателя с ID: {}", lessonResponses.size(), teacherId);
return ResponseEntity.ok(lessonResponses);
} catch (Exception e ){
logger.error("Ошибка при получении занятий для преподавателя с ID {}: {}", teacherId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Ошибка при поиске занятий: " + e.getMessage()));
}
}
//Тестовый запрос на проверку доступности контроллера
@GetMapping("/ping")
public String ping() {
logger.debug("Получен ping запрос");
String response = "pong";
logger.debug("Ответ на ping: {}", response);
return response;
}
//Удаление занятия по его ID
@DeleteMapping("/delete/{lessonId}")
public ResponseEntity<?> deleteLessonById(@PathVariable Long lessonId){
logger.info("Запрос на удаление занятия по ID: {}", lessonId);
if(!lessonRepository.existsById(lessonId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Занятие не найдено"));
}
lessonRepository.deleteById(lessonId);
logger.info("Занятие с ID - {} успешно удалено", lessonId);
return ResponseEntity.ok(Map.of("message", "Занятие успешно удалено"));
}
//Обновление занятия по его ID
@PutMapping("/update/{lessonId}")
public ResponseEntity<?> updateLessonById(@PathVariable Long lessonId, @RequestBody CreateLessonRequest request) {
logger.info("Получен запрос на обновление занятия с ID - {}", lessonId);
logger.info("Данные для обновления: teacherId={}, groupId={}, subjectId={}, lessonFormat={}, typeLesson={}, classroomId={}, day={}, week={}, time={}",
request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getLessonFormat(), request.getTypeLesson(), request.getClassroomId(),
request.getDay(), request.getWeek(), request.getTime());
try {
//Проверка на наличие записи
Lesson existingLesson = lessonRepository.findById(lessonId).orElse(null);
if(existingLesson == null) {
String errorMessage = "Занятие с ID " + lessonId + " не найдено";
logger.info("Ошибка: {}", errorMessage);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", errorMessage));
}
boolean hasChanges = false;
Map<String, Object> changes = new LinkedHashMap<>();
//Проверка и обновление teacherId, если он передан и отличается
if(request.getTeacherId() != null) {
if(!request.getTeacherId().equals(existingLesson.getTeacherId())) {
if(request.getTeacherId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID преподавателя не может быть равен 0"));
}
existingLesson.setTeacherId(request.getTeacherId());
changes.put("teacherId", request.getTeacherId());
hasChanges = true;
}
}
//Проверка и обновление groupId, если он передан и отличается
if(request.getGroupId() != null) {
if(!request.getGroupId().equals(existingLesson.getGroupId())) {
if(request.getGroupId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID группы не может быть равен 0"));
}
existingLesson.setGroupId(request.getGroupId());
changes.put("groupId", request.getGroupId());
hasChanges = true;
}
}
//Проверка и обновление subjectId, если он передан и отличается
if(request.getSubjectId() != null) {
if(!request.getSubjectId().equals(existingLesson.getSubjectId())) {
if(request.getSubjectId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID дисциплины не может быть равен 0"));
}
existingLesson.setSubjectId(request.getSubjectId());
changes.put("subjectId", request.getSubjectId());
hasChanges = true;
}
}
//Проверка и обновление lessonFormat, если он передан и отличается
if(request.getLessonFormat() != null) {
if(!request.getLessonFormat().equals(existingLesson.getLessonFormat())) {
if(request.getLessonFormat().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Формат занятия не может быть пустым"));
}
if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())) {
String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage();
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
existingLesson.setLessonFormat(request.getLessonFormat());
changes.put("lessonFormat", request.getLessonFormat());
hasChanges = true;
}
}
//Проверка и обновление typeLesson, если он передан и отличается
if(request.getTypeLesson() != null) {
if(!request.getTypeLesson().equals(existingLesson.getTypeLesson())) {
if(request.getTypeLesson().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Тип занятия не может быть пустым"));
}
if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())) {
String errorMessage = "Некорректный тип занятий. " + TypeAndFormatLessonValidator.getValidTypesMessage();
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
existingLesson.setLessonFormat(request.getTypeLesson());
changes.put("typeLesson", request.getTypeLesson());
hasChanges = true;
}
}
//Проверка и обновление classroomId, если он передан и отличается
if(request.getClassroomId() != null) {
if(!request.getClassroomId().equals(existingLesson.getClassroomId())) {
if(request.getClassroomId() == 0) {
return ResponseEntity.badRequest()
.body(Map.of("message", "ID аудитории не можеть быть равен 0"));
}
existingLesson.setClassroomId(request.getClassroomId());
changes.put("classroomId", request.getClassroomId());
hasChanges = true;
}
}
//Проверка и обновление day, если он передан и отличается
if(request.getDay() != null){
if(!request.getDay().equals(existingLesson.getDay())) {
if(request.getDay().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"День\" не может быть пустым"));
}
if(!DayAndWeekValidator.isValidDay(request.getDay())) {
String errorMessage = "Некорректный день. " + DayAndWeekValidator.getValidDaysMessage();
return ResponseEntity.badRequest()
.body(Map.of("message", errorMessage));
}
existingLesson.setDay(request.getDay());
changes.put("day", request.getDay());
hasChanges = true;
}
}
//Проверка и обновление week, если он передан и отличается
if(request.getWeek() != null) {
if(!request.getWeek().equals(existingLesson.getWeek())) {
if (request.getWeek().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"Неделя\" не может быть пустым"));
}
if (!DayAndWeekValidator.isValidWeek(request.getWeek())) {
String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage();
return ResponseEntity.badRequest()
.body((Map.of("message", errorMessage)));
}
existingLesson.setWeek(request.getWeek());
changes.put("week", request.getWeek());
hasChanges = true;
}
}
//Проверка и обновление time, если он передан и отличается
if(request.getTime() != null) {
if(!request.getTime().equals(existingLesson.getTime())) {
if(request.getTime().isBlank()){
return ResponseEntity.badRequest()
.body(Map.of("message", "Поле \"Время\" не может быть пустым"));
}
existingLesson.setTime(request.getTime());
changes.put("time", request.getTime());
hasChanges = true;
}
}
if(!hasChanges) {
logger.info("Обновление не требуется - все полня идентичны существующим для занятия с ID: {}", lessonId);
Map<String, Object> response = buildResponse(existingLesson);
response.put("message", "Изменений не обнаружено");
return ResponseEntity.ok(response);
}
Lesson updatedLesson = lessonRepository.save(existingLesson);
Map<String, Object> response = buildResponse(updatedLesson);
response.put("updatedFields", changes);
response.put("message", "Занятие успешно обновлено");
logger.info("Занятие с ID - {} успешно обновлено. Изменения: {}", lessonId, changes);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при обновлении занятия с ID {}: {}", lessonId, e.getMessage(),e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при обновлении занятия: " + e.getMessage()));
}
}
private Map<String, Object> buildResponse(Lesson lesson) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", lesson.getId());
response.put("teacherId", lesson.getTeacherId());
response.put("groupId", lesson.getGroupId());
response.put("subjectId", lesson.getSubjectId());
response.put("LessonFormat", lesson.getLessonFormat());
response.put("typeLesson", lesson.getTypeLesson());
response.put("classroomId", lesson.getClassroomId());
response.put("day", lesson.getDay());
response.put("week", lesson.getWeek());
response.put("time", lesson.getTime());
return response;
}
}

View File

@@ -0,0 +1,84 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.Semester;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/schedule")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleQueryService scheduleQueryService;
private final SemesterRepository semesterRepository;
public ScheduleController(ScheduleQueryService scheduleQueryService,
SemesterRepository semesterRepository) {
this.scheduleQueryService = scheduleQueryService;
this.semesterRepository = semesterRepository;
}
@GetMapping("/semesters")
public List<SemesterDto> getSemesters() {
return semesterRepository.findAll().stream()
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
.map(this::toSemesterDto)
.toList();
}
@GetMapping
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long groupId,
@RequestParam(required = false) Long teacherId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}",
groupId, teacherId, startDate, endDate);
if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
}
return ResponseEntity.ok(scheduleQueryService.search(
groupId,
teacherId,
null,
null,
null,
null,
null,
null,
startDate,
endDate
));
}
private SemesterDto toSemesterDto(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
}

View File

@@ -0,0 +1,57 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleOverrideService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/edu-office/schedule/overrides")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleOverrideController {
private final ScheduleOverrideService scheduleOverrideService;
public ScheduleOverrideController(ScheduleOverrideService scheduleOverrideService) {
this.scheduleOverrideService = scheduleOverrideService;
}
@GetMapping
public List<ScheduleOverrideDto> getAll(
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate
) {
return scheduleOverrideService.getAll(startDate, endDate);
}
@GetMapping("/availability")
public ScheduleOverrideAvailabilityDto getAvailability(
@RequestParam Long baseRuleSlotId,
@RequestParam LocalDate lessonDate
) {
return scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleOverrideDto request) {
return ResponseEntity.ok(scheduleOverrideService.create(request));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) {
return ResponseEntity.ok(scheduleOverrideService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
scheduleOverrideService.delete(id);
return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено"));
}
}

View File

@@ -0,0 +1,80 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController {
private final ScheduleRuleService scheduleRuleService;
public ScheduleRuleAdminController(ScheduleRuleService scheduleRuleService) {
this.scheduleRuleService = scheduleRuleService;
}
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
@RequestParam(name = "groupId", required = false) Long groupId) {
return scheduleRuleService.getAll(semesterId, groupId);
}
@GetMapping("/{id}")
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(id));
}
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
try {
return ResponseEntity.ok(scheduleRuleService.create(request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
try {
return ResponseEntity.ok(scheduleRuleService.update(id, request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> delete(@PathVariable("id") Long id) {
scheduleRuleService.archive(id);
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private ResponseEntity<Map<String, Object>> conflictResponse(ScheduleRuleConflictException exception) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", exception.getMessage());
if (exception.getConflictRule() != null) {
body.put("conflictRule", exception.getConflictRule());
}
body.put("conflictFields", exception.getConflictFields());
body.put("conflictReasons", exception.getConflictReasons());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
}

View File

@@ -0,0 +1,55 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/schedule/search")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleSearchController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class);
private final ScheduleQueryService scheduleQueryService;
public ScheduleSearchController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
public ResponseEntity<?> search(
@RequestParam(required = false) Long groupId,
@RequestParam(required = false) Long teacherId,
@RequestParam(required = false) Long classroomId,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long subjectId,
@RequestParam(required = false) Long lessonTypeId,
@RequestParam(required = false) Long timeSlotId,
@RequestParam(required = false) String parity,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
groupId, teacherId, classroomId, departmentId, startDate, endDate);
return ResponseEntity.ok(scheduleQueryService.search(
groupId,
teacherId,
classroomId,
departmentId,
subjectId,
lessonTypeId,
timeSlotId,
parity,
startDate,
endDate
));
}
}

View File

@@ -0,0 +1,293 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.dto.SpecialtyProfileDto;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/specialties")
@RequireRoles({Role.ADMIN})
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
public SpecialityController(SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository) {
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
s.getSpecialityName(),
s.getSpecialityCode()
))
.toList();
logger.info("Получено {} специальностей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createSpeciality(@RequestBody CreateSpecialityRequest request) {
logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode());
try {
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
String errorMessage = "Название специальности обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) {
String errorMessage = "Специальность с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) {
String errorMessage = "Специальность с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Speciality speciality = new Speciality();
speciality.setSpecialityName(request.getSpecialityName());
speciality.setSpecialityCode(request.getSpecialityCode());
specialtiesRepository.save(speciality);
SpecialtyProfile defaultProfile = new SpecialtyProfile();
defaultProfile.setSpeciality(speciality);
defaultProfile.setName("Без профиля");
specialtyProfileRepository.save(defaultProfile);
logger.info("Специальность успешно создана с ID: {}", speciality.getId());
return ResponseEntity.ok(
new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании специальности", e);
throw e;
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateSpeciality(@PathVariable Long id, @RequestBody CreateSpecialityRequest request) {
logger.info("Получен запрос на обновление специальности с ID: {}", id);
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
try {
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
String errorMessage = "Название специальности обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
String specialityName = request.getSpecialityName().trim();
String specialityCode = request.getSpecialityCode().trim();
if (specialtiesRepository.findBySpecialityName(specialityName)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Специальность с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityCode(specialityCode)
.filter(existing -> !existing.getId().equals(id))
.isPresent()) {
String errorMessage = "Специальность с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
speciality.setSpecialityName(specialityName);
speciality.setSpecialityCode(specialityCode);
specialtiesRepository.save(speciality);
logger.info("Специальность с ID - {} успешно обновлена", id);
return ResponseEntity.ok(new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
));
} catch (Exception e) {
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
throw e;
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
speciality.archive("Специальность архивирована");
specialtiesRepository.save(speciality);
logger.info("Специальность с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Специальность архивирована"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreSpeciality(@PathVariable Long id) {
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
return ResponseEntity.notFound().build();
}
speciality.restore();
specialtiesRepository.save(speciality);
return ResponseEntity.ok(new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
));
}
@GetMapping("/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
.filter(profile -> profile.getSpeciality().isActiveRecord())
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
.map(this::toProfileDto)
.toList();
}
@GetMapping("/{specialtyId}/profiles")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream()
.map(this::toProfileDto)
.toList());
}
@PostMapping("/{specialtyId}/profiles")
public ResponseEntity<?> createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) {
Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null);
if (speciality == null) {
return ResponseEntity.notFound().build();
}
String validationError = validateProfile(specialtyId, null, request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
SpecialtyProfile profile = new SpecialtyProfile();
profile.setSpeciality(speciality);
profile.setName(request.name().trim());
profile.setDescription(trimToNull(request.description()));
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
}
@PutMapping("/{specialtyId}/profiles/{profileId}")
public ResponseEntity<?> updateProfile(@PathVariable Long specialtyId,
@PathVariable Long profileId,
@RequestBody SpecialtyProfileDto request) {
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
String validationError = validateProfile(specialtyId, profileId, request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
profile.setName(request.name().trim());
profile.setDescription(trimToNull(request.description()));
return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile)));
}
@DeleteMapping("/{specialtyId}/profiles/{profileId}")
public ResponseEntity<?> deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) {
SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
return ResponseEntity.notFound().build();
}
specialtyProfileRepository.delete(profile);
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
}
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
if (request == null) {
return "Передайте данные профиля";
}
if (request.name() == null || request.name().isBlank()) {
return "Название профиля обязательно";
}
return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim())
.filter(existing -> !existing.getId().equals(profileId))
.map(existing -> "Профиль с таким названием уже существует")
.orElse(null);
}
private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) {
Speciality speciality = profile.getSpeciality();
return new SpecialtyProfileDto(
profile.getId(),
speciality.getId(),
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getName(),
profile.getDescription()
);
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,195 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.SubgroupDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
private final SubgroupRepository subgroupRepository;
private final GroupRepository groupRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public SubgroupController(SubgroupRepository subgroupRepository,
GroupRepository groupRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.subgroupRepository = subgroupRepository;
this.groupRepository = groupRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/api/groups/{groupId}/subgroups")
public ResponseEntity<?> getByGroup(@PathVariable Long groupId) {
if (!groupRepository.existsById(groupId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream()
.map(this::toDto)
.toList());
}
@PostMapping("/api/groups/{groupId}/subgroups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
String capacityError = validateGroupCapacity(group, null, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
Subgroup subgroup = new Subgroup();
subgroup.setStudentGroup(group);
subgroup.setName(request.name().trim());
subgroup.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
String newName = request.name().trim();
boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName)
&& !subgroup.getName().equalsIgnoreCase(newName);
if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
String capacityError = validateGroupCapacity(group, id, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
subgroup.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
if (groupRepository.findByIdForUpdate(groupId).isEmpty()) {
return ResponseEntity.notFound().build();
}
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
}
private String validate(SubgroupDto request) {
if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно";
}
if (request.studentCapacity() == null) {
return "Численность подгруппы обязательна";
}
if (request.studentCapacity() <= 0) {
return "Численность подгруппы должна быть больше 0";
}
return null;
}
private String validateGroupCapacity(StudentGroup group, Long excludedSubgroupId, Integer requestedCapacity) {
Long currentCapacity = excludedSubgroupId == null
? subgroupRepository.sumActiveStudentCapacityByGroupId(group.getId())
: subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(group.getId(), excludedSubgroupId);
long totalCapacity = currentCapacity + requestedCapacity.longValue();
if (totalCapacity > group.getGroupSize()) {
return "Сумма численностей подгрупп не может превышать численность группы (" + group.getGroupSize() + " чел.)";
}
return null;
}
private String validateDeleteKeepsCompleteSplit(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
long remainingCount = subgroupRepository.countActiveByGroupIdExcludingId(group.getId(), subgroup.getId());
if (remainingCount == 0) {
return null;
}
Long remainingCapacity = subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(
group.getId(),
subgroup.getId()
);
if (remainingCapacity < group.getGroupSize()) {
return "Нельзя удалить одну подгруппу из деления: измените количество подгрупп в форме настройки или отключите деление";
}
return null;
}
private SubgroupDto toDto(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
return new SubgroupDto(
subgroup.getId(),
group.getId(),
group.getName(),
subgroup.getName(),
subgroup.getStudentCapacity()
);
}
}

View File

@@ -1,7 +1,15 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -10,8 +18,11 @@ import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
@RequireRoles({Role.ADMIN})
public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) {
@@ -19,33 +30,128 @@ public class SubjectController {
}
@GetMapping
public List<Subject> getAllSubjects() {
return subjectRepository.findAll();
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> response = subjects.stream()
.map(s -> new Subject(
s.getId(),
s.getName(),
s.getCode(),
s.getDepartmentId()
))
.toList();
logger.info("Получено {} дисциплин", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{
List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Дисциплины для указанной кафедры не найдены");
}
logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId);
return ResponseEntity.ok(subjects);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка дисциплин");
}
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
}
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
request.getName(), request.getCode(), request.getDepartmentId());
Subject subject = new Subject();
subject.setName(name.trim());
subjectRepository.save(subject);
try {
if (request.getName() == null || request.getName().isBlank()) {
String errorMessage = "Название обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
String errorMessage = "Дисциплина с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getCode() == null || request.getCode().isBlank()) {
String errorMessage = "Код дисциплины обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
return ResponseEntity.ok(subject);
Subject subject = new Subject();
subject.setName(request.getName());
subject.setCode(request.getCode());
subject.setDepartmentId(request.getDepartmentId());
subjectRepository.save(subject);
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
return ResponseEntity.ok(
new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании дисциплины", e);
throw e;
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
if (!subjectRepository.existsById(id)) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
subject.archive("Дисциплина архивирована");
subjectRepository.save(subject);
logger.info("Дисциплина с ID - {} успешно архивирована", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована"));
}
@PostMapping("/{id}/restore")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> restoreSubject(@PathVariable Long id) {
Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
return ResponseEntity.notFound().build();
}
subject.restore();
subjectRepository.save(subject);
return ResponseEntity.ok(new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
));
}
}

View File

@@ -0,0 +1,193 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-requests")
@RequireRoles({Role.ADMIN})
public class TeacherCreationRequestController {
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder,
BusinessTimeService businessTime) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
this.businessTime = businessTime;
}
@GetMapping
public List<TeacherCreationRequestResponse> getRequests(@RequestParam(required = false) TeacherCreationRequestStatus status) {
return (status == null
? teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc()
: teacherCreationRequestRepository.findByStatusOrderByCreatedAtDesc(status))
.stream()
.map(this::toResponse)
.toList();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
if (review == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные одобрения обязательны"));
}
String username = firstNotBlank(review.getUsername(), request.getUsername());
String fullName = firstNotBlank(review.getFullName(), request.getFullName());
String jobTitle = firstNotBlank(review.getJobTitle(), request.getJobTitle(), "Не указано");
if (username.length() > 50 || fullName.length() > 255 || jobTitle.length() > 255) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей преподавателя"));
}
if (review.getPassword() == null || review.getPassword().length() < 8) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль преподавателя должен быть не короче 8 символов"));
}
if (userRepository.findByUsername(username).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь с таким логином уже существует"));
}
Long departmentId = review.getDepartmentId() == null ? request.getDepartment().getId() : review.getDepartmentId();
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = new User();
teacher.setUsername(username);
teacher.setPassword(passwordEncoder.encode(review.getPassword()));
teacher.setRole(Role.TEACHER);
teacher.setFullName(fullName);
teacher.setJobTitle(jobTitle);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(businessTime.today());
assignment.setCreatedAt(businessTime.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
request.setUsername(username);
request.setFullName(fullName);
request.setJobTitle(jobTitle);
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
return ResponseEntity.ok(Map.of(
"request", toResponse(request),
"teacher", toUserResponse(teacher, department)
));
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody(required = false) ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(businessTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}
private TeacherCreationRequestResponse toResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private UserResponse toUserResponse(User user, Department department) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
department.getDepartmentName()
);
response.setDepartmentId(department.getId());
response.setStatus(user.getStatus());
return response;
}
private String firstNotBlank(String... values) {
for (String value : values) {
String normalized = trimToNull(value);
if (normalized != null) {
return normalized;
}
}
return "";
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -1,11 +1,19 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherSubjectResponse;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -14,23 +22,40 @@ import java.util.Map;
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
private final UserRepository userRepository;
private final SubjectRepository subjectRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BusinessTimeService businessTime;
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository) {
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService) {
this(teacherSubjectRepository, userRepository, subjectRepository,
teacherDepartmentService, BusinessTimeService.systemDefault());
}
@Autowired
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.teacherSubjectRepository = teacherSubjectRepository;
this.userRepository = userRepository;
this.subjectRepository = subjectRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
@@ -48,12 +73,17 @@ public class TeacherSubjectController {
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
if (!userRepository.existsById(userId)) {
User user = userRepository.findById(userId).orElse(null);
if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (!subjectRepository.existsById(subjectId)) {
Subject subject = subjectRepository.findById(subjectId).orElse(null);
if (subject == null || subject.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
}
if (!canManage(user, subject)) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) {
@@ -76,10 +106,34 @@ public class TeacherSubjectController {
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) {
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
if (teacherSubject == null) {
return ResponseEntity.notFound().build();
}
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
}
teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
}
private boolean canManage(User teacher, Subject subject) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null || currentUser.role() == Role.ADMIN) {
return true;
}
if (currentUser.role() != Role.DEPARTMENT) {
return false;
}
Long departmentId = currentUser.departmentId();
return departmentId != null
&& teacher != null
&& subject != null
&& teacherDepartmentService.hasAssignmentAtDate(
teacher.getId(),
departmentId,
businessTime.today()
)
&& departmentId.equals(subject.getDepartmentId());
}
}

View File

@@ -0,0 +1,218 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.dto.TimeSlotScopeDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.service.TimeSlotService;
import com.magistr.app.service.EffectiveTimeSlotService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/time-slots")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class TimeSlotAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private static final String APPLY_MODE_MANUAL = "MANUAL";
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final TimeSlotService timeSlotService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository,
TimeSlotService timeSlotService,
EffectiveTimeSlotService effectiveTimeSlotService) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.timeSlotService = timeSlotService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
}
@GetMapping
public List<TimeSlotDto> getAll() {
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/effective")
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
return ResponseEntity.ok(effectiveTimeSlotService.findEffective(date).stream().map(this::toDto).toList());
}
@GetMapping("/scopes")
public List<TimeSlotScopeDto> getScopes() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(this::toScopeDto)
.toList();
}
@PostMapping("/scopes")
public ResponseEntity<?> createScope(@RequestBody TimeSlotScopeDto request) {
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
TimeSlotScope scope = new TimeSlotScope();
scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", ""));
scope.setName(request.name().trim());
scope.setApplyMode(APPLY_MODE_MANUAL);
scope.setDayOfWeek(null);
scope.setSystemScope(false);
scope.setDisplayOrder(nextManualDisplayOrder());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@PutMapping("/scopes/{id}")
public ResponseEntity<?> updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять"));
}
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
scope.setName(request.name().trim());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@DeleteMapping("/scopes/{id}")
public ResponseEntity<?> deleteScope(@PathVariable Long id) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить"));
}
timeSlotScopeRepository.delete(scope);
return ResponseEntity.ok(Map.of("message", "Сетка времени удалена"));
}
@GetMapping("/date-assignments")
public List<TimeSlotDateAssignmentDto> getDateAssignments(@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate) {
List<TimeSlotDateAssignment> assignments = startDate != null && endDate != null
? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate)
: dateAssignmentRepository.findAllByOrderByDateAsc();
return assignments.stream().map(this::toAssignmentDto).toList();
}
@PostMapping("/date-assignments")
public ResponseEntity<?> saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) {
if (request.date() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна"));
}
if (request.scopeId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна"));
}
TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null);
if (scope == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена"));
}
if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) {
return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки"));
}
TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date())
.orElseGet(TimeSlotDateAssignment::new);
assignment.setDate(request.date());
assignment.setTimeSlotScope(scope);
return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment)));
}
@DeleteMapping("/date-assignments/{id}")
public ResponseEntity<?> deleteDateAssignment(@PathVariable Long id) {
if (!dateAssignmentRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
dateAssignmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено"));
}
@PostMapping
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
timeSlotService.delete(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
}
private int nextManualDisplayOrder() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(TimeSlotScope::getDisplayOrder)
.filter(value -> value != null)
.max(Integer::compareTo)
.orElse(100) + 10;
}
private TimeSlotDto toDto(TimeSlot timeSlot) {
TimeSlotScope scope = timeSlot.getTimeSlotScope();
return new TimeSlotDto(
timeSlot.getId(),
timeSlot.getOrderNumber(),
scope == null ? null : scope.getId(),
scope == null ? null : scope.getName(),
scope == null ? null : scope.getApplyMode(),
scope == null ? null : scope.getDayOfWeek(),
timeSlot.getStartTime(),
timeSlot.getEndTime(),
timeSlot.getDurationMinutes()
);
}
private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) {
return new TimeSlotScopeDto(
scope.getId(),
scope.getCode(),
scope.getName(),
scope.getApplyMode(),
scope.getDayOfWeek(),
scope.getSystemScope(),
scope.getDisplayOrder()
);
}
private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) {
TimeSlotScope scope = assignment.getTimeSlotScope();
return new TimeSlotDateAssignmentDto(
assignment.getId(),
assignment.getDate(),
scope.getId(),
scope.getName()
);
}
}

View File

@@ -1,59 +1,171 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.TeacherDepartmentService;
import com.magistr.app.service.BusinessTimeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequireRoles({Role.ADMIN})
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
private final BCryptPasswordEncoder passwordEncoder;
private final BusinessTimeService businessTime;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this(userRepository, passwordEncoder, departmentRepository,
teacherDepartmentAssignmentRepository, teacherDepartmentService,
BusinessTimeService.systemDefault());
}
@Autowired
public UserController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherDepartmentService teacherDepartmentService,
BusinessTimeService businessTime) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
this.businessTime = businessTime;
}
@GetMapping
public List<UserResponse> getAllUsers() {
return userRepository.findAll().stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream()
.map(this::toUserResponse)
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
departmentId,
businessTime.today()
);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Преподаватели для указанной кафедры не найдены");
}
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
return ResponseEntity.ok(users.stream()
.map(user -> toUserResponse(user, departmentId))
.toList());
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
if (request == null) {
String errorMessage = "Данные пользователя обязательны";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
if (request.getUsername() == null || request.getUsername().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 8) {
String errorMessage = "Пароль минимум 8 символов";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
String errorMessage = "Пользователь уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getFullName() == null || request.getFullName().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
request.setJobTitle("Не указано");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department requestedDepartment = departmentRepository.findById(request.getDepartmentId()).orElse(null);
if (requestedDepartment == null || requestedDepartment.isArchivedRecord()) {
String errorMessage = "Активная кафедра не найдена";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (Exception e) {
} catch (IllegalArgumentException | NullPointerException e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
@@ -61,17 +173,144 @@ public class UserController {
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
user.setFullName(request.getFullName());
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(businessTime.today());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(toUserResponse(user));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
User user = userRepository.findById(id).orElse(null);
if (user == null) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
userRepository.save(user);
logger.info("Пользователь с ID - {} успешно архивирован", id);
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
}
@PostMapping("/{id}/restore")
public ResponseEntity<?> restoreUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
user.restore();
userRepository.save(user);
return ResponseEntity.ok(toUserResponse(user));
}
@GetMapping("/{id}/department-history")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
userRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentService.hasAssignmentAtDate(
id,
currentUser.departmentId(),
businessTime.today()
)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
.map(this::toAssignmentDto)
.toList());
}
@PostMapping("/{id}/department-transfer")
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
id,
request,
businessTime.today(),
AuthContext.currentUserId()
);
return ResponseEntity.ok(toAssignmentDto(assignment));
}
@GetMapping("/teachers/by-department/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? businessTime.today() : date;
return ResponseEntity.ok(teacherDepartmentService
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
.map(user -> toUserResponse(user, departmentId))
.toList());
}
private UserResponse toUserResponse(User user) {
Long departmentId = user.getRole() == Role.TEACHER
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
.orElse(user.getDepartmentId())
: user.getDepartmentId();
return toUserResponse(user, departmentId);
}
private UserResponse toUserResponse(User user, Long departmentId) {
String departmentName = departmentId == null
? null
: departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Неизвестно");
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
departmentName
);
response.setDepartmentId(departmentId);
response.setStatus(user.getStatus());
return response;
}
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
return new TeacherDepartmentAssignmentDto(
assignment.getId(),
assignment.getTeacher().getId(),
assignment.getTeacher().getFullName(),
assignment.getDepartment().getId(),
assignment.getDepartment().getDepartmentName(),
assignment.getValidFrom(),
assignment.getValidTo(),
assignment.getPrimaryAssignment(),
assignment.getComment()
);
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null
|| currentUser.role() != Role.DEPARTMENT
|| (departmentId != null && departmentId.equals(currentUser.departmentId()));
}
}

View File

@@ -0,0 +1,281 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.WorkloadSummaryDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.service.ScheduleQueryService;
import com.magistr.app.service.TeacherDepartmentService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/workload")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class WorkloadController {
private static final int ACADEMIC_HOURS_PER_LESSON = 2;
private final ScheduleQueryService scheduleQueryService;
private final ClassroomRepository classroomRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentService teacherDepartmentService;
public WorkloadController(ScheduleQueryService scheduleQueryService,
ClassroomRepository classroomRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleQueryService = scheduleQueryService;
this.classroomRepository = classroomRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentService = teacherDepartmentService;
}
@GetMapping("/teachers")
public List<WorkloadSummaryDto> teacherWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarizeTeacherWorkload(lessons, startDate, endDate);
}
@GetMapping("/classrooms")
public List<WorkloadSummaryDto> classroomWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
}
@GetMapping("/departments")
public List<WorkloadSummaryDto> departmentWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
lesson,
assignmentsByTeacher
).orElse(0L)));
return byDepartment.entrySet().stream()
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey(
lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
),
LinkedHashMap::new,
Collectors.toList()
));
return grouped.entrySet().stream()
.map(entry -> summary(
entry.getKey().teacherId(),
entry.getValue().get(0).teacherName(),
entry.getKey().departmentId(),
entry.getValue()
))
.sorted(Comparator
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
.thenComparing(
WorkloadSummaryDto::departmentId,
Comparator.nullsLast(Long::compareTo)
))
.toList();
}
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate
) {
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private Optional<Long> departmentIdAtLesson(
RenderedLessonDto lesson,
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
) {
if (lesson.teacherId() == null || lesson.date() == null) {
return Optional.empty();
}
return teacherDepartmentService.resolveDepartmentIdAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
lesson.date()
);
}
@GetMapping("/time-slots")
public List<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarize(lessons, RenderedLessonDto::timeSlotId,
lesson -> lesson.timeSlotOrder() == null
? "Пара"
: lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")",
ignored -> null);
}
@GetMapping("/free-classrooms")
public List<ClassroomResponse> freeClassrooms(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Long effectiveDepartmentId = effectiveDepartmentId(null);
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, timeSlotId, date, date
)
.stream()
.map(RenderedLessonDto::classroomId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
.map(this::toClassroomResponse)
.toList();
}
private List<WorkloadSummaryDto> summarize(List<RenderedLessonDto> lessons,
Function<RenderedLessonDto, Long> idFn,
Function<RenderedLessonDto, String> nameFn,
Function<Long, Long> departmentFn) {
Map<Long, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> idFn.apply(lesson) != null)
.collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream()
.map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue()))
.sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo)))
.toList();
}
private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List<RenderedLessonDto> lessons) {
return new WorkloadSummaryDto(
id,
name,
departmentId,
departmentName(departmentId),
lessons.size(),
lessons.size() * ACADEMIC_HOURS_PER_LESSON,
lessons.stream()
.map(lesson -> lesson.date() + ":" + lesson.timeSlotId())
.distinct()
.count()
);
}
private String departmentName(Long departmentId) {
if (departmentId == null) {
return null;
}
if (departmentId == 0L) {
return "Кафедра не указана";
}
return departmentRepository.findById(departmentId)
.map(Department::getDepartmentName)
.orElse("Кафедра не найдена");
}
private ClassroomResponse toClassroomResponse(Classroom classroom) {
return new ClassroomResponse(
classroom.getId(),
classroom.getName(),
classroom.getCapacity(),
classroom.getBuilding(),
classroom.getFloor(),
classroom.getIsAvailable(),
classroom.getStatus(),
classroom.getArchivedAt(),
classroom.getArchiveReason(),
new ArrayList<>(classroom.getEquipments())
);
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
if (currentUser == null) {
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Не удалось определить текущего пользователя"
);
}
if (currentUser.role() != Role.DEPARTMENT) {
return requestedDepartmentId;
}
Long ownDepartmentId = currentUser.departmentId();
if (ownDepartmentId == null) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Для пользователя кафедры не указана кафедра"
);
}
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Нельзя просматривать нагрузку другой кафедры"
);
}
return ownDepartmentId;
}
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarActivityTypeDto(
Long id,
String code,
String name,
Boolean allowSchedule,
String colorCode,
Integer displayOrder,
String description
) {
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record AcademicCalendarDto(
Long id,
String title,
Long academicYearId,
String academicYearTitle,
LocalDate academicYearStartDate,
LocalDate academicYearEndDate,
Long specialtyId,
String specialtyCode,
String specialtyName,
Long specialtyProfileId,
String specialtyProfileName,
Long studyFormId,
String studyFormName,
Integer courseCount
) {
}

View File

@@ -0,0 +1,17 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record AcademicCalendarGridDayDto(
Long id,
Long calendarId,
Integer courseNumber,
LocalDate date,
Integer weekNumber,
Integer dayOfWeek,
Long activityTypeId,
String activityCode,
String activityName,
Boolean allowSchedule
) {
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarSubjectDto(
Long id,
Long calendarId,
Integer semesterNumber,
Long subjectId,
String subjectName,
String subjectCode,
Long departmentId
) {
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record AcademicYearDto(
Long id,
String title,
LocalDate startDate,
LocalDate endDate,
List<SemesterDto> semesters
) {
}

View File

@@ -5,6 +5,8 @@ import java.util.List;
public class ClassroomRequest {
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private List<Long> equipmentIds;
@@ -24,6 +26,22 @@ public class ClassroomRequest {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -1,23 +1,41 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.Instant;
import java.util.List;
public class ClassroomResponse {
private Long id;
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private String status;
private Instant archivedAt;
private String archiveReason;
private List<Equipment> equipments;
public ClassroomResponse() {
}
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> equipments) {
this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments);
}
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, String status, Instant archivedAt,
String archiveReason, List<Equipment> equipments) {
this.id = id;
this.name = name;
this.capacity = capacity;
this.building = building;
this.floor = floor;
this.isAvailable = isAvailable;
this.status = status;
this.archivedAt = archivedAt;
this.archiveReason = archiveReason;
this.equipments = equipments;
}
@@ -45,6 +63,22 @@ public class ClassroomResponse {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}
@@ -53,6 +87,30 @@ public class ClassroomResponse {
this.isAvailable = isAvailable;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}
public String getArchiveReason() {
return archiveReason;
}
public void setArchiveReason(String archiveReason) {
this.archiveReason = archiveReason;
}
public List<Equipment> getEquipments() {
return equipments;
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.dto;
public class CreateDepartmentRequest {
private String departmentName;
private Long departmentCode;
public CreateDepartmentRequest() {}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

Some files were not shown because too many files have changed in this diff Show More