Add docker
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@
|
|||||||
build
|
build
|
||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
.kotlin
|
.kotlin
|
||||||
|
.DS_Store
|
||||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
The server is a Kotlin/Ktor app. Bootstrap code (`Application.kt`, `Routing.kt`, `Security.kt`, `Serialization.kt`) lives in `src/main/kotlin/app`. Business features sit inside `src/main/kotlin/modules/<domain>` and follow the repeating `Controller → Service → Repository → Entity` flow, reusing utilities from `src/main/kotlin/shared`. Configuration (`application.yaml`, `logback.xml`) and OpenAPI specs (`resources/openapi`) live in `src/main/resources`. Tests mirror the production tree under `src/test/kotlin` (see `ApplicationTest.kt` for the pattern). Generated artifacts stay inside `build/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `./gradlew run` — start the server for local iteration.
|
||||||
|
- `./gradlew test` — run the full unit/integration suite; required before every push.
|
||||||
|
- `./gradlew build` — compile sources, execute tests, and assemble deployable jars.
|
||||||
|
- `./gradlew buildFatJar` — emit `build/libs/*-all.jar` for standalone deployment.
|
||||||
|
- `./gradlew buildImage` / `publishImageToLocalRegistry` / `runDocker` — build, publish, and exercise the Docker image for parity checks.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Stick to Kotlin defaults: four-space indentation, braces on the same line, `camelCase` members, `PascalCase` types, lowercase packages. Keep controllers focused on HTTP + validation, services on business rules, and repositories on persistence. Prefer immutable DTOs/data classes, throw the shared exceptions instead of string literals, and wire routes declaratively inside `app/Routing.kt`. Name files after the primary type (`NewsController`, `LeadService`) for clarity.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Tests rely on the Ktor test engine and should shadow the production package path (e.g., `modules/news/ControllerTest`). Name tests with behavior phrases such as `shouldReturn401WhenTokenMissing`, covering success, validation, and authorization branches. Every feature or bug fix must ship with tests plus a local `./gradlew test` run; target coverage on every public service method.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Use short, imperative commit subjects consistent with history (`Init commit`, `Diploma-1 Main CRUD operations`). Reference tickets when relevant (`Diploma-42 Add news search`) and keep each commit scoped to one concern. Pull requests need a summary, linked issues, API evidence (screenshots or curl output), and confirmation that `./gradlew test` succeeded. Request review only after CI is green and OpenAPI docs reflect any contract changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Secrets such as JWT keys or database URIs must come from environment variables or an untracked overlay—never hard-code them in `application.yaml`. When touching authentication, revisit `Security.kt` so routes opt into the correct provider. Update `resources/openapi/documentation.yaml` whenever request/response schemas evolve so downstream clients stay synchronized.
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM gradle:8.7-jdk17 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
|
||||||
|
COPY gradle ./gradle
|
||||||
|
RUN chmod +x gradlew
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN ./gradlew --no-daemon clean buildFatJar
|
||||||
|
|
||||||
|
FROM eclipse-temurin:17-jre-jammy
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/build/libs/*-all.jar /app/app.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV JVM_OPTS=""
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "java $JVM_OPTS -jar /app/app.jar"]
|
||||||
@@ -20,6 +20,7 @@ dependencies {
|
|||||||
implementation(libs.ktor.server.swagger)
|
implementation(libs.ktor.server.swagger)
|
||||||
implementation(libs.ktor.server.default.headers)
|
implementation(libs.ktor.server.default.headers)
|
||||||
implementation(libs.ktor.server.cors)
|
implementation(libs.ktor.server.cors)
|
||||||
|
implementation(libs.ktor.server.status.pages)
|
||||||
implementation(libs.ktor.server.compression)
|
implementation(libs.ktor.server.compression)
|
||||||
implementation(libs.ktor.server.caching.headers)
|
implementation(libs.ktor.server.caching.headers)
|
||||||
implementation(libs.ktor.server.netty)
|
implementation(libs.ktor.server.netty)
|
||||||
@@ -30,6 +31,7 @@ dependencies {
|
|||||||
implementation(libs.bcrypt)
|
implementation(libs.bcrypt)
|
||||||
implementation(libs.ktorm)
|
implementation(libs.ktorm)
|
||||||
implementation(libs.ktor.server.config.yaml)
|
implementation(libs.ktor.server.config.yaml)
|
||||||
|
implementation(libs.psql.support)
|
||||||
testImplementation(libs.ktor.server.test.host)
|
testImplementation(libs.ktor.server.test.host)
|
||||||
testImplementation(libs.kotlin.test.junit)
|
testImplementation(libs.kotlin.test.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
12
db/init-db.sh
Executable file
12
db/init-db.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Restoring database from seed.dump..."
|
||||||
|
pg_restore \
|
||||||
|
--clean \
|
||||||
|
--if-exists \
|
||||||
|
--no-acl \
|
||||||
|
--no-owner \
|
||||||
|
-U "${POSTGRES_USER}" \
|
||||||
|
-d "${POSTGRES_DB}" \
|
||||||
|
"/docker-entrypoint-initdb.d/seed.dump"
|
||||||
BIN
db/seed.dump
Normal file
BIN
db/seed.dump
Normal file
Binary file not shown.
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: diploma-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME:-diploma_db}
|
||||||
|
POSTGRES_USER: ${DB_USER:-diploma_admin}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./db/seed.dump:/docker-entrypoint-initdb.d/seed.dump:ro
|
||||||
|
- ./db/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-diploma_admin} -d ${DB_NAME:-diploma_db}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: diploma-app
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
102
docs/architecture.md
Normal file
102
docs/architecture.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Архитектура и структура сервиса
|
||||||
|
|
||||||
|
Документ описывает как организован сервер дипломного проекта: какие слои присутствуют, как они взаимодействуют и где искать ключевые модули.
|
||||||
|
|
||||||
|
## Общий обзор
|
||||||
|
|
||||||
|
Сервис написан на Kotlin с использованием Ktor и разворачивается как Netty-приложение (`Application.kt`). Приложение поднимает инфраструктурные плагины (HTTP, сериализация, безопасность) и регистрирует доменные маршруты. Все функции сгруппированы в повторяющийся паттерн `Controller → Service → Repository → Entity`, что делает поддержку CRUD-операций симметричной во всех доменных областях.
|
||||||
|
|
||||||
|
Высокоуровневый поток запросов:
|
||||||
|
|
||||||
|
1. HTTP-вызов попадает в `Routing.kt`, где маршруты разделены на публичную и админскую зоны (`/api/v1/...`).
|
||||||
|
2. Контроллер модуля занимается десериализацией запроса, базовой валидацией и формированием ответа.
|
||||||
|
3. Сервис инкапсулирует бизнес-правила: публикация материалов, фильтрация, проверка прав, подготовка DTO.
|
||||||
|
4. Репозиторий обращается к PostgreSQL через Ktorm, используя HikariCP-пул соединений.
|
||||||
|
5. Исключения (`ValidationException`, `NotFoundException`) перехватывает `StatusPages`, что обеспечивает единый контракт ошибок.
|
||||||
|
|
||||||
|
Графическая схема слоев расположена в `docs/crud-model.svg`.
|
||||||
|
|
||||||
|
## Структура каталогов
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/kotlin
|
||||||
|
├── app # Bootstrap: Application, Routing, Security, HTTP, Database
|
||||||
|
├── shared # Переиспользуемые DTO, пагинация, ошибки
|
||||||
|
└── modules # Доменные модули (admin, news, service, serviceCategory, lead)
|
||||||
|
src/main/resources
|
||||||
|
├── application.yaml # Конфигурация Ktor
|
||||||
|
└── openapi # Спецификации API
|
||||||
|
src/test/kotlin # Зеркало production-пакетов для тестов
|
||||||
|
docs/
|
||||||
|
├── crud-model.svg # Диаграмма последовательности CRUD
|
||||||
|
└── architecture.md # Текущий документ
|
||||||
|
```
|
||||||
|
|
||||||
|
## Инфраструктурный слой (`app/*`)
|
||||||
|
|
||||||
|
- **Application.module** – точка входа, выстраивает последовательность `configureDatabase → configureHTTP → configureSerialization → configureSecurity → configureRouting`.
|
||||||
|
- **Database.kt** – инициализация пула HikariCP (настройки берутся из `.env`), регистрация `Database` в `Application.attributes`.
|
||||||
|
- **Http.kt** – подключает плагины Ktor (CORS, Compression, CachingHeaders, StatusPages, Swagger UI). Здесь же централизованно обрабатываются исключения.
|
||||||
|
- **Serialization.kt** – `ContentNegotiation` с `kotlinx.serialization`.
|
||||||
|
- **Security.kt** – загрузка параметров JWT из окружения, настройка схемы `admin-auth`, валидация `JWTPrincipal`.
|
||||||
|
- **Routing.kt** – собирает зависимости модулей, подключает публичные маршруты и оборачивает админские роуты в `authenticate("admin-auth")`.
|
||||||
|
|
||||||
|
## Доменные модули (`modules/*`)
|
||||||
|
|
||||||
|
Каждый модуль состоит из четырех файлов:
|
||||||
|
|
||||||
|
- `Controller.kt` – набор Ktor-маршрутов для публичной и/или админской зоны.
|
||||||
|
- `Service.kt` – бизнес-логика. Например, `NewsService` отвечает за публикацию, пагинацию и преобразование сущностей в DTO, а `ServiceService` поддерживает фильтры по статусу, категории и ценовому диапазону.
|
||||||
|
- `Repository.kt` – слой доступа к данным на Ktorm: построение SQL, пагинация, CRUD-операции, трансформации в `Entity`.
|
||||||
|
- `Entity.kt` / DTO – описания таблиц, модели для сериализации и транспортировки данных.
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
Отвечает за регистрацию, авторизацию и управление администраторами. Использует `BcryptPasswordHasher` для хранения паролей и `TokenService` для выпуска JWT. Публичная часть (`/admin/login`) возвращает токен; защищенная (`/admin/*`) требует `admin-auth`.
|
||||||
|
|
||||||
|
### News
|
||||||
|
|
||||||
|
Два набора маршрутов: публичные (`/news`, `/news/{slug}`) и админские (`/admin/news`). Сервис применяет правила публикации и формирует страницы (`Page<T>`). Репозиторий обрабатывает SQL для списков и выборок по `slug`.
|
||||||
|
|
||||||
|
### Services и Service Categories
|
||||||
|
|
||||||
|
`ServiceController` поддерживает сложные фильтры (категория, диапазон цен, статус). `ServiceService` связывает услуги с категориями, обеспечивая получение вложенных DTO. `ServiceCategory*` модули обслуживают справочник категорий и проверяют уникальность `slug`.
|
||||||
|
|
||||||
|
### Leads
|
||||||
|
|
||||||
|
`publicLeadRoutes` принимает заявки с сайта (`POST /leads`), `adminLeadRoutes` предоставляет CRUD для операторов. Сервис выполняет базовую валидацию и поиск по строке `q`.
|
||||||
|
|
||||||
|
## Общие компоненты (`shared/*`)
|
||||||
|
|
||||||
|
- `shared.pagination.Page` – универсальный ответ для списков.
|
||||||
|
- `shared.errors.ValidationException/NotFoundException` – типы, которые перехватываются `StatusPages` и автоматически конвертируются в HTTP-ответ.
|
||||||
|
|
||||||
|
## Работа с БД
|
||||||
|
|
||||||
|
- **База** – PostgreSQL, подключение через Ktorm + HikariCP (`jdbc:postgresql://...`).
|
||||||
|
- **Пулы** – настраиваются через переменные окружения (`DB_HOST`, `DB_POOL_MAX` и т.д.).
|
||||||
|
- **Репозитории** – реализованы вручную на SQL Builder, что дает контроль над запросами и связями между таблицами (`services` ↔ `service_categories`, `news`, `leads`, `admins`).
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Единственная схемa аутентификации – JWT `admin-auth`.
|
||||||
|
- Конфигурация (`JWT_SECRET`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_EXPIRES_MIN`) читается из `.env` или переменных окружения.
|
||||||
|
- Public маршруты доступны без токена, все `admin*Routes` подключены внутри `authenticate`.
|
||||||
|
|
||||||
|
## Сборка и эксплуатация
|
||||||
|
|
||||||
|
- Основные команды Gradle:
|
||||||
|
- `./gradlew run` – локальный сервер.
|
||||||
|
- `./gradlew test` – прогон тестов (обязателен перед пушем).
|
||||||
|
- `./gradlew buildFatJar` – сборка standalone JAR.
|
||||||
|
- `./gradlew buildImage` / `runDocker` – сборка и запуск docker-образа.
|
||||||
|
- Конфигурация HTTP-порта, логирования и OpenAPI лежит в `src/main/resources/application.yaml` и `resources/openapi`.
|
||||||
|
- Для обновления документации добавляйте артефакты в `docs/` (например, текущую архитектурную схему и диаграмму CRUD).
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Тесты зеркалируют production-пакеты (`src/test/kotlin/...`). При добавлении новой функциональности рекомендуется:
|
||||||
|
|
||||||
|
1. Класть тесты в соответствующий пакет модуля (например, `modules/news/NewsServiceTest.kt`).
|
||||||
|
2. Проверять ветки успеха, валидации и авторизации.
|
||||||
|
3. Запускать `./gradlew test` перед коммитом, чтобы убедиться в корректной работе всего набора.
|
||||||
147
docs/crud-model.svg
Normal file
147
docs/crud-model.svg
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<svg width="1200" height="950" viewBox="0 0 1200 950" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.bg { fill: #f8fafc; }
|
||||||
|
.card { fill: #ffffff; stroke: #0f172a; stroke-width: 1.5; }
|
||||||
|
.title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 32px; font-weight: 600; fill: #0f172a; }
|
||||||
|
.subtitle { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 16px; fill: #475569; }
|
||||||
|
.card-title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 17px; font-weight: 600; fill: #0f172a; }
|
||||||
|
.card-body { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 13px; fill: #334155; }
|
||||||
|
.note { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 13px; fill: #475569; }
|
||||||
|
.arrow { stroke: #0f172a; stroke-width: 1.8; fill: none; }
|
||||||
|
.dashed { stroke-dasharray: 6 6; }
|
||||||
|
</style>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0f172a" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect class="bg" x="0" y="0" width="1200" height="950" rx="32" />
|
||||||
|
|
||||||
|
<text x="600" y="45" text-anchor="middle" class="title">CRUD-модель сервера Diploma</text>
|
||||||
|
<text x="600" y="75" text-anchor="middle" class="subtitle">Ktor обрабатывает HTTP-запросы через Controllers, Services, Repositories и таблицы PostgreSQL.</text>
|
||||||
|
|
||||||
|
<!-- Client surfaces -->
|
||||||
|
<rect class="card" x="150" y="90" width="220" height="100" rx="14" />
|
||||||
|
<text x="260" y="120" text-anchor="middle" class="card-title">Публичные каналы</text>
|
||||||
|
<text x="260" y="150" text-anchor="middle" class="card-body">Маркетинговый сайт</text>
|
||||||
|
<text x="260" y="170" text-anchor="middle" class="card-body">Лендинги и формы</text>
|
||||||
|
|
||||||
|
<rect class="card" x="830" y="90" width="220" height="100" rx="14" />
|
||||||
|
<text x="940" y="120" text-anchor="middle" class="card-title">Админ-консоль</text>
|
||||||
|
<text x="940" y="150" text-anchor="middle" class="card-body">Внутренний кабинет</text>
|
||||||
|
<text x="940" y="170" text-anchor="middle" class="card-body">CMS, отчеты</text>
|
||||||
|
|
||||||
|
<!-- Ktor layer -->
|
||||||
|
<rect class="card" x="420" y="200" width="360" height="140" rx="18" />
|
||||||
|
<text x="600" y="235" text-anchor="middle" class="card-title">Ktor HTTP-слой</text>
|
||||||
|
<text x="600" y="260" text-anchor="middle" class="card-body">Routing.kt · Serialization.kt · Security.kt</text>
|
||||||
|
<text x="600" y="285" text-anchor="middle" class="card-body">Проверка JWT, JSON-парсинг,</text>
|
||||||
|
<text x="600" y="305" text-anchor="middle" class="card-body">маршрутизация по модулям</text>
|
||||||
|
|
||||||
|
<!-- Shared utilities -->
|
||||||
|
<rect class="card" x="1000" y="230" width="150" height="120" rx="14" />
|
||||||
|
<text x="1075" y="260" text-anchor="middle" class="card-title">shared/*</text>
|
||||||
|
<text x="1075" y="285" text-anchor="middle" class="card-body">Пагинация</text>
|
||||||
|
<text x="1075" y="305" text-anchor="middle" class="card-body">Ошибки и DTO</text>
|
||||||
|
|
||||||
|
<!-- Controllers row -->
|
||||||
|
<rect class="card" x="80" y="360" width="220" height="150" rx="16" />
|
||||||
|
<text x="190" y="390" text-anchor="middle" class="card-title">NewsController</text>
|
||||||
|
<text x="190" y="415" text-anchor="middle" class="card-body">GET /news и /news/{slug}</text>
|
||||||
|
<text x="190" y="435" text-anchor="middle" class="card-body">Админ: список и создание</text>
|
||||||
|
<text x="190" y="455" text-anchor="middle" class="card-body">обновление, публикация, удаление</text>
|
||||||
|
|
||||||
|
<rect class="card" x="340" y="360" width="220" height="150" rx="16" />
|
||||||
|
<text x="450" y="390" text-anchor="middle" class="card-title">ServiceController</text>
|
||||||
|
<text x="450" y="415" text-anchor="middle" class="card-body">Публичный список/просмотр</text>
|
||||||
|
<text x="450" y="435" text-anchor="middle" class="card-body">Фильтры: категория, цена</text>
|
||||||
|
<text x="450" y="455" text-anchor="middle" class="card-body">Админ: CRUD и статус</text>
|
||||||
|
|
||||||
|
<rect class="card" x="600" y="360" width="220" height="150" rx="16" />
|
||||||
|
<text x="710" y="390" text-anchor="middle" class="card-title">ServiceCategoryController</text>
|
||||||
|
<text x="710" y="415" text-anchor="middle" class="card-body">Публичный список и slug</text>
|
||||||
|
<text x="710" y="435" text-anchor="middle" class="card-body">Админ: список, создание</text>
|
||||||
|
<text x="710" y="455" text-anchor="middle" class="card-body">обновление, удаление</text>
|
||||||
|
|
||||||
|
<rect class="card" x="860" y="360" width="220" height="150" rx="16" />
|
||||||
|
<text x="970" y="390" text-anchor="middle" class="card-title">LeadController</text>
|
||||||
|
<text x="970" y="415" text-anchor="middle" class="card-body">POST /leads с сайта</text>
|
||||||
|
<text x="970" y="435" text-anchor="middle" class="card-body">Админ: список и чтение</text>
|
||||||
|
<text x="970" y="455" text-anchor="middle" class="card-body">Удаление заявок</text>
|
||||||
|
|
||||||
|
<!-- Services row -->
|
||||||
|
<rect class="card" x="80" y="530" width="220" height="120" rx="16" />
|
||||||
|
<text x="190" y="560" text-anchor="middle" class="card-title">NewsService</text>
|
||||||
|
<text x="190" y="585" text-anchor="middle" class="card-body">Правила публикации, DTO</text>
|
||||||
|
<text x="190" y="605" text-anchor="middle" class="card-body">Страницы и статусы</text>
|
||||||
|
|
||||||
|
<rect class="card" x="340" y="530" width="220" height="120" rx="16" />
|
||||||
|
<text x="450" y="560" text-anchor="middle" class="card-title">ServiceService</text>
|
||||||
|
<text x="450" y="585" text-anchor="middle" class="card-body">Фильтры по статусу/категории</text>
|
||||||
|
<text x="450" y="605" text-anchor="middle" class="card-body">Контроль цены, связи</text>
|
||||||
|
|
||||||
|
<rect class="card" x="600" y="530" width="220" height="120" rx="16" />
|
||||||
|
<text x="710" y="560" text-anchor="middle" class="card-title">ServiceCategoryService</text>
|
||||||
|
<text x="710" y="585" text-anchor="middle" class="card-body">Таксономия и slug</text>
|
||||||
|
<text x="710" y="605" text-anchor="middle" class="card-body">CRUD категорий</text>
|
||||||
|
|
||||||
|
<rect class="card" x="860" y="530" width="220" height="120" rx="16" />
|
||||||
|
<text x="970" y="560" text-anchor="middle" class="card-title">LeadService</text>
|
||||||
|
<text x="970" y="585" text-anchor="middle" class="card-body">Валидация контактов</text>
|
||||||
|
<text x="970" y="605" text-anchor="middle" class="card-body">Поиск и пагинация</text>
|
||||||
|
|
||||||
|
<!-- Repository row -->
|
||||||
|
<rect class="card" x="80" y="680" width="220" height="120" rx="16" />
|
||||||
|
<text x="190" y="710" text-anchor="middle" class="card-title">NewsRepository</text>
|
||||||
|
<text x="190" y="735" text-anchor="middle" class="card-body">SQL-запросы для news</text>
|
||||||
|
<text x="190" y="755" text-anchor="middle" class="card-body">Админ и публичные выборки</text>
|
||||||
|
|
||||||
|
<rect class="card" x="340" y="680" width="220" height="120" rx="16" />
|
||||||
|
<text x="450" y="710" text-anchor="middle" class="card-title">ServiceRepository</text>
|
||||||
|
<text x="450" y="735" text-anchor="middle" class="card-body">Джоины услуг и категорий</text>
|
||||||
|
<text x="450" y="755" text-anchor="middle" class="card-body">Фильтры по цене</text>
|
||||||
|
|
||||||
|
<rect class="card" x="600" y="680" width="220" height="120" rx="16" />
|
||||||
|
<text x="710" y="710" text-anchor="middle" class="card-title">ServiceCategoryRepository</text>
|
||||||
|
<text x="710" y="735" text-anchor="middle" class="card-body">Метаданные категорий</text>
|
||||||
|
<text x="710" y="755" text-anchor="middle" class="card-body">Хранение slug</text>
|
||||||
|
|
||||||
|
<rect class="card" x="860" y="680" width="220" height="120" rx="16" />
|
||||||
|
<text x="970" y="710" text-anchor="middle" class="card-title">LeadRepository</text>
|
||||||
|
<text x="970" y="735" text-anchor="middle" class="card-body">Прием заявок</text>
|
||||||
|
<text x="970" y="755" text-anchor="middle" class="card-body">Админские запросы</text>
|
||||||
|
|
||||||
|
<!-- Database layer -->
|
||||||
|
<rect class="card" x="100" y="830" width="1000" height="110" rx="20" />
|
||||||
|
<text x="600" y="860" text-anchor="middle" class="card-title">Схема PostgreSQL</text>
|
||||||
|
<text x="600" y="885" text-anchor="middle" class="card-body">news, services, service_categories, leads</text>
|
||||||
|
<text x="600" y="905" text-anchor="middle" class="card-body">Временные метки, статусы, связи</text>
|
||||||
|
|
||||||
|
<!-- Arrows -->
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 260 190 L 600 200" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 940 190 L 600 200" />
|
||||||
|
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 190 360" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 450 360" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 710 360" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 970 360" />
|
||||||
|
|
||||||
|
<!-- Vertical flows -->
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 190 510 L 190 530" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 450 510 L 450 530" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 710 510 L 710 530" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 970 510 L 970 530" />
|
||||||
|
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 190 650 L 190 680" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 450 650 L 450 680" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 710 650 L 710 680" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 970 650 L 970 680" />
|
||||||
|
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 190 800 L 190 830" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 450 800 L 450 830" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 710 800 L 710 830" />
|
||||||
|
<path class="arrow" marker-end="url(#arrow)" d="M 970 800 L 970 830" />
|
||||||
|
|
||||||
|
<text x="600" y="925" text-anchor="middle" class="note">Каждый модуль следует паттерну Controller → Service → Repository и покрывает CRUD.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.9 KiB |
119
docs/db-structure.md
Normal file
119
docs/db-structure.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Структура БД и модель данных
|
||||||
|
|
||||||
|
Документ описывает схемы PostgreSQL, которые используются приложением, а также слой абстракции на базе Ktorm ORM.
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
- **PostgreSQL** – основная СУБД. Подключение выполняется через HikariCP (см. `app/Database.kt`), параметры берутся из переменных окружения `DB_*`.
|
||||||
|
- **Ktorm ORM** – легковесная ORM, которая:
|
||||||
|
- предоставляет интерфейсы `Entity` для объектного представления строк;
|
||||||
|
- описывает таблицы через `object Table<T>` (например, `object NewsT : Table<News>("t_news")`);
|
||||||
|
- дает `Database.sequenceOf(Table)` для CRUD-операций и построения SQL через DSL.
|
||||||
|
|
||||||
|
Каждая доменная сущность имеет:
|
||||||
|
|
||||||
|
1. `interface <Entity> : Entity<<Entity>>` – декларация полей.
|
||||||
|
2. `object <Table> : Table<<Entity>>("table_name")` – описание колонок/ключей и связей через `bindTo`/`references`.
|
||||||
|
3. DTO для сериализации в API.
|
||||||
|
|
||||||
|
## Таблицы
|
||||||
|
|
||||||
|
### `t_admins`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|----------------|------------------|---------------------------------------|
|
||||||
|
| `id` | `bigint` (PK) | Идентификатор администратора |
|
||||||
|
| `username` | `varchar` | Уникальное имя пользователя |
|
||||||
|
| `password_hash`| `varchar` | Хеш пароля (bcrypt) |
|
||||||
|
| `created_at` | `timestamp` | Дата регистрации |
|
||||||
|
| `last_login_at`| `timestamp` nul. | Время последней авторизации |
|
||||||
|
|
||||||
|
Сущность: `AdminEntity`, таблица: `AdminUsers`. Используется модулем `admin` для регистрации, логина, смены паролей и удаления аккаунтов.
|
||||||
|
|
||||||
|
### `t_news`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|----------------|------------------|--------------------------------------------------|
|
||||||
|
| `id` | `bigint` (PK) | Идентификатор новости |
|
||||||
|
| `title` | `varchar` | Заголовок |
|
||||||
|
| `slug` | `varchar` | Уникальный slug для ссылок |
|
||||||
|
| `summary` | `varchar` | Краткое описание |
|
||||||
|
| `content` | `text` | Основной текст |
|
||||||
|
| `status` | `varchar` | `DRAFT` \| `PUBLISHED` \| `ARCHIVED` |
|
||||||
|
| `published_at` | `timestamp` nul. | Дата публикации |
|
||||||
|
| `image_url` | `varchar` nul. | Ссылка на изображение |
|
||||||
|
| `created_at` | `timestamp` | Дата создания |
|
||||||
|
| `updated_at` | `timestamp` | Дата последнего обновления |
|
||||||
|
|
||||||
|
Сущность: `News`, таблица: `NewsT`. Репозиторий использует фильтры по статусу и slug для публичных и админских запросов.
|
||||||
|
|
||||||
|
### `t_service_categories`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|---------------|-------------------------|
|
||||||
|
| `id` | `bigint` (PK) | Идентификатор категории |
|
||||||
|
| `name` | `varchar` | Название |
|
||||||
|
| `slug` | `varchar` | Уникальный slug |
|
||||||
|
|
||||||
|
Сущность: `ServiceCategoryEntity`, таблица: `ServiceCategories`. Используется как справочник категорий услуг.
|
||||||
|
|
||||||
|
### `t_services`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|--------------|-------------------|----------------------------------------------------|
|
||||||
|
| `id` | `bigint` (PK) | Идентификатор услуги |
|
||||||
|
| `title` | `varchar` | Название |
|
||||||
|
| `slug` | `varchar` | Уникальный slug |
|
||||||
|
| `description`| `text` | Подробное описание |
|
||||||
|
| `price_from` | `decimal` nul. | Нижняя граница стоимости |
|
||||||
|
| `image_url` | `varchar` nul. | Изображение |
|
||||||
|
| `status` | `varchar` | `PUBLISHED` \| `DRAFT` \| `ARCHIVED` |
|
||||||
|
| `category_id`| `bigint` FK | Ссылка на `t_service_categories.id` (может быть `NULL`) |
|
||||||
|
| `created_at` | `timestamp` | Дата создания |
|
||||||
|
| `updated_at` | `timestamp` | Дата обновления |
|
||||||
|
|
||||||
|
Сущность: `ServiceEntity`, таблица: `Services`. Поле `category` смоделировано через `references(ServiceCategories)` и возвращает `ServiceCategoryEntity?`. Сервис объединяет услуги и категории для публичного и админского API.
|
||||||
|
|
||||||
|
### `t_users` (лиды)
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|-------------|-----------------|------------------------------------------|
|
||||||
|
| `id` | `bigint` (PK) | Идентификатор лида |
|
||||||
|
| `full_name` | `varchar` | Имя и фамилия |
|
||||||
|
| `email` | `varchar` | Контактный email |
|
||||||
|
| `phone` | `varchar` nul. | Телефон |
|
||||||
|
| `created_at`| `timestamp` | Дата поступления заявки |
|
||||||
|
|
||||||
|
Сущность: `LeadEntity`, таблица: `Leads`. Несмотря на название таблицы `t_users`, фактически хранит только заявки. Админский модуль использует пагинацию и поиск по `full_name`/`email`.
|
||||||
|
|
||||||
|
## Связи между сущностями
|
||||||
|
|
||||||
|
- **Service → ServiceCategory (многие к одному)**: `Services.category_id` ссылается на `ServiceCategories.id`. Ktorm позволяет навигировать через `ServiceEntity.category`. При выборе услуг можно жадно загружать категорию и формировать вложенный DTO.
|
||||||
|
- **Admin, News, Leads** – независимые сущности без внешних ключей на другие таблицы (в текущей версии).
|
||||||
|
- **Status/enum поля** находятся в бизнес-логике: нет отдельных таблиц для статусов, их значения валидируются сервисами.
|
||||||
|
|
||||||
|
## Использование Ktorm
|
||||||
|
|
||||||
|
- **Entity интерфейсы** – объявляют свойства и их типы. Экземпляры создаются через `Entity.Factory`.
|
||||||
|
- **Table объекты** – задают имя таблицы, колонки и связи. Пример:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object Services : Table<ServiceEntity>("t_services") {
|
||||||
|
val title = varchar("title").bindTo { it.title }
|
||||||
|
val category = long("category_id").references(ServiceCategories) { it.category }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Расширения Database** – в каждом модуле есть свой `val Database.<entities>` (например, `Database.news`) для получения `sequenceOf(Table)`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val Database.news get() = this.sequenceOf(NewsT)
|
||||||
|
```
|
||||||
|
|
||||||
|
Это скрывает детали доступа к данным и дает типобезопасные операции (`filter`, `sortedBy`, `take`, `drop`, `insert`, `update` и т.д.).
|
||||||
|
|
||||||
|
- **DTO слой** – отделяет Ktorm-entity от сериализуемых объектов (например, `NewsDTO`, `ServiceDTO`). Преобразование выполняется в сервисах.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
База данных состоит из пяти основных таблиц, объединенных единым пулом соединений и общими практиками (таймстемпы, статусы, slug). Слабое связывание между сущностями делает схему гибкой, а Ktorm обеспечивает лаконичный и типобезопасный доступ к данным без тяжелых ORM-схем. Диаграмма CRUD-слоя (`docs/crud-model.svg`) дополняет данное описание визуальным представлением потока данных.
|
||||||
69
docs/interaction-diagram.svg
Normal file
69
docs/interaction-diagram.svg
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<svg width="1200" height="800" viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.bg { fill: #ffffff; }
|
||||||
|
.title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 26px; font-weight: 600; fill: #1f2937; }
|
||||||
|
.node { fill: #f8fafc; stroke: #1f2937; stroke-width: 2; rx: 16; }
|
||||||
|
.node-title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 20px; font-weight: 600; fill: #111827; }
|
||||||
|
.node-body { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 15px; fill: #374151; }
|
||||||
|
.arrow { stroke: #1f2937; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
|
||||||
|
.arrow-label { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 15px; fill: #1f2937; }
|
||||||
|
.db { fill: #f8fafc; stroke: #1f2937; stroke-width: 2; }
|
||||||
|
</style>
|
||||||
|
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1f2937" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect class="bg" x="0" y="0" width="1200" height="800" />
|
||||||
|
<text x="600" y="60" text-anchor="middle" class="title">Общая схема взаимодействия: Клиент → Ktor Server → PostgreSQL</text>
|
||||||
|
|
||||||
|
<!-- Client node -->
|
||||||
|
<rect class="node" x="20" y="180" width="300" height="420" />
|
||||||
|
<text x="180" y="220" text-anchor="middle" class="node-title">Клиент (React SPA)</text>
|
||||||
|
<text x="60" y="270" class="node-body">• UI Components</text>
|
||||||
|
<text x="60" y="305" class="node-body">• MobX Stores</text>
|
||||||
|
<text x="60" y="340" class="node-body">• fetch API</text>
|
||||||
|
<text x="60" y="375" class="node-body">• Браузерное приложение</text>
|
||||||
|
|
||||||
|
<!-- Server node -->
|
||||||
|
<rect class="node" x="460" y="140" width="320" height="500" />
|
||||||
|
<text x="620" y="180" text-anchor="middle" class="node-title">Ktor Server (Kotlin)</text>
|
||||||
|
<text x="490" y="230" class="node-body">• Routing</text>
|
||||||
|
<text x="490" y="265" class="node-body">• Controllers</text>
|
||||||
|
<text x="490" y="300" class="node-body">• Services</text>
|
||||||
|
<text x="490" y="335" class="node-body">• Repositories</text>
|
||||||
|
<text x="490" y="370" class="node-body">• Ktorm ORM</text>
|
||||||
|
<text x="490" y="405" class="node-body">• JWT Authentication</text>
|
||||||
|
<text x="490" y="440" class="node-body">• Запуск на Netty runtime</text>
|
||||||
|
|
||||||
|
<!-- Database cylinder -->
|
||||||
|
<g transform="translate(900,200)">
|
||||||
|
<ellipse class="db" cx="155" cy="0" rx="140" ry="25" />
|
||||||
|
<rect class="db" x="15" y="0" width="280" height="360" ry="0" />
|
||||||
|
<ellipse class="db" cx="155" cy="360" rx="140" ry="25" />
|
||||||
|
<text x="155" y="40" text-anchor="middle" class="node-title">База данных PostgreSQL</text>
|
||||||
|
<text x="50" y="90" class="node-body">• t_admins</text>
|
||||||
|
<text x="50" y="125" class="node-body">• t_news</text>
|
||||||
|
<text x="50" y="160" class="node-body">• t_services</text>
|
||||||
|
<text x="50" y="195" class="node-body">• t_service_categories</text>
|
||||||
|
<text x="50" y="230" class="node-body">• t_users (leads)</text>
|
||||||
|
<text x="50" y="265" class="node-body">• Транзакционность (ACID)</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Arrows -->
|
||||||
|
<path class="arrow" d="M 320 320 L 460 320" />
|
||||||
|
<text x="390" y="300" text-anchor="middle" class="arrow-label">REST API /api/v1/*</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M 780 320 L 915 320" />
|
||||||
|
<text x="850" y="290" text-anchor="middle" class="arrow-label">
|
||||||
|
<tspan>SQL-запросы</tspan>
|
||||||
|
<tspan x="845" dy="1.2em">через Ktorm</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M 915 400 L 780 400" />
|
||||||
|
<text x="850" y="385" text-anchor="middle" class="arrow-label">JSON-ответы</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M 460 400 L 320 400" />
|
||||||
|
<text x="395" y="385" text-anchor="middle" class="arrow-label">JSON-ответы</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
311
docs/rest-api.md
Normal file
311
docs/rest-api.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# REST API (Swagger-Style)
|
||||||
|
|
||||||
|
## Общая информация
|
||||||
|
- **Базовый URL**: `http://0.0.0.0:8080/api/v1`
|
||||||
|
- **Форматы**: все конечные точки принимают и возвращают `application/json`, если не указан иной `Content-Type`.
|
||||||
|
- **Часовой формат**: даты/время сериализуются в `ISO_LOCAL_DATE_TIME`, например `2024-05-12T13:45:00`.
|
||||||
|
- **JWT-аутентификация**: приватные маршруты располагаются под `/api/v1/admin/**` и требуют заголовок `Authorization: Bearer <token>` из `POST /admin/login`.
|
||||||
|
- **Ошибки**: при валидационных/авторизационных ошибках сервер возвращает код `4xx` с телом `{"error": "описание"}`.
|
||||||
|
|
||||||
|
## Базовые схемы
|
||||||
|
| Схема | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| **Page<T>** | Обёртка пагинации: `items` (список сущностей `T`), `total` (общее количество), `limit` (число элементов на странице), `offset` (смещение). |
|
||||||
|
| **ServiceCategoryDTO** | `{ id: number, name: string, slug: string }`. |
|
||||||
|
| **ServiceDTO** | `{ id, title, slug, description, priceFrom: number\|null, imageUrl: string\|null, status: "PUBLISHED"\|"DRAFT"\|"ARCHIVED", category: ServiceCategoryDTO\|null, createdAt, updatedAt }`. |
|
||||||
|
| **NewsDTO** | `{ id, title, slug, summary, content, status: "draft"\|"published"\|"archived", imageUrl: string\|null, publishedAt: string\|null }`. |
|
||||||
|
| **LeadDTO** | `{ id, fullName, email, phone: string\|null, createdAt }`. |
|
||||||
|
| **AdminDTO** | `{ id, username, createdAt }`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Администраторы и аутентификация
|
||||||
|
| Метод | Путь | Требуется JWT | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| POST | `/admin/login` | Нет | Получение JWT токена. |
|
||||||
|
| GET | `/admin/password_hash` | Нет | Вспомогательный эндпойнт для генерации bcrypt-хэша. |
|
||||||
|
| GET | `/admin` | Да | Получить профиль текущего администратора. |
|
||||||
|
| POST | `/admin` | Да | Создать нового администратора. |
|
||||||
|
| PUT | `/admin/{id}/password` | Да | Сменить пароль администратора. |
|
||||||
|
| DELETE | `/admin/{id}` | Да | Удалить администратора. |
|
||||||
|
|
||||||
|
### POST /api/v1/admin/login
|
||||||
|
**Тело запроса**
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `username` | string | да | Мин. 3 символа (`[A-Za-z0-9_.-]`). |
|
||||||
|
| `password` | string | да | Мин. 8 символов. |
|
||||||
|
|
||||||
|
**Ответ 200**
|
||||||
|
|
||||||
|
| Поле | Тип | Примечание |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | number | Идентификатор администратора. |
|
||||||
|
| `username` | string | Введённое имя. |
|
||||||
|
| `token` | string | JWT access token. |
|
||||||
|
| `tokenType` | string | Всегда `Bearer`. |
|
||||||
|
| `expiresInMinutes` | number | Время жизни токена (минуты). |
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"token": "eyJhbGciOi...",
|
||||||
|
"tokenType": "Bearer",
|
||||||
|
"expiresInMinutes": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/admin/password_hash
|
||||||
|
**Query-параметры**
|
||||||
|
|
||||||
|
| Параметр | Тип | Обязательно | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `password` | string | нет | Исходный пароль. По умолчанию `admin123`. |
|
||||||
|
|
||||||
|
**Ответ 200**: `{ "pass": "<bcrypt-hash>" }`.
|
||||||
|
|
||||||
|
### GET /api/v1/admin
|
||||||
|
Возвращает `AdminDTO` текущего пользователя по subject токена.
|
||||||
|
|
||||||
|
### POST /api/v1/admin
|
||||||
|
**Тело запроса** – `AdminRegisterRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `username` | string | да | Уникальное имя (регулярное выражение как при логине). |
|
||||||
|
| `password` | string | да | Мин. 8 символов. |
|
||||||
|
|
||||||
|
**Ответ 201** – `AdminRegisterResponse`:
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | number |
|
||||||
|
| `username` | string |
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/{id}/password
|
||||||
|
**Параметры пути**: `id` – numeric ID.
|
||||||
|
|
||||||
|
**Тело запроса** – `ChangePasswordRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `currentPassword` | string | да | Текущий пароль (проверяется через bcrypt). |
|
||||||
|
| `newPassword` | string | да | Новый пароль, минимум 8 символов. |
|
||||||
|
|
||||||
|
**Ответ 200**: `{ "updated": true }`.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/{id}
|
||||||
|
Удаляет администратора. Ответ `200 OK`: `{ "deleted": true }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Лиды (заявки)
|
||||||
|
| Метод | Путь | JWT | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| POST | `/leads` | Нет | Оставить заявку. |
|
||||||
|
| GET | `/admin/leads` | Да | Список лидов с фильтром. |
|
||||||
|
| GET | `/admin/leads/{id}` | Да | Получить лид по ID. |
|
||||||
|
| DELETE | `/admin/leads/{id}` | Да | Удалить лид. |
|
||||||
|
|
||||||
|
### POST /api/v1/leads
|
||||||
|
**Тело запроса** – `LeadCreateRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `fullName` | string | да | Имя клиента. |
|
||||||
|
| `email` | string | да | Проверяется регуляркой `^[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}$`. |
|
||||||
|
| `phone` | string | нет | Любой формат, сохраняется как строка. |
|
||||||
|
|
||||||
|
**Ответ 201**: `{ "id": 42 }`.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/leads
|
||||||
|
**Query-параметры**
|
||||||
|
|
||||||
|
| Имя | Тип | По умолчанию | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | integer | 50 | Размер страницы. |
|
||||||
|
| `page` | integer | 1 | Должна быть ≥ 1, иначе `400`. |
|
||||||
|
| `q` | string | null | Поиск по `fullName`, `email`, `phone`. |
|
||||||
|
|
||||||
|
**Ответ 200**: `Page<LeadDTO>`.
|
||||||
|
Пример элемента: `{ "id": 7, "fullName": "Иван Иванов", "email": "ivan@example.com", "phone": "+79001234567", "createdAt": "2024-05-05T10:34:00" }`.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/leads/{id}
|
||||||
|
Параметр `id` (long). Ответ – `LeadDTO`.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/leads/{id}
|
||||||
|
Ответ `{ "deleted": true }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новости
|
||||||
|
| Метод | Путь | JWT | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/news` | Нет | Пагинированный список опубликованных новостей. |
|
||||||
|
| GET | `/news/{slug}` | Нет | Получение новости по `slug`. |
|
||||||
|
| GET | `/admin/news` | Да | Список всех новостей. |
|
||||||
|
| POST | `/admin/news` | Да | Создать новость. |
|
||||||
|
| PUT | `/admin/news/{slug}` | Да | Обновить новость. |
|
||||||
|
| POST | `/admin/news/{slug}/publish` | Да | Публикация новости. |
|
||||||
|
| DELETE | `/admin/news/{slug}` | Да | Удалить новость. |
|
||||||
|
|
||||||
|
### GET /api/v1/news
|
||||||
|
**Query-параметры**: `limit` (default 20), `page` (default 1, ≥1).
|
||||||
|
**Ответ**: `Page<NewsDTO>`, где `items` отсортированы по `publishedAt` у опубликованных записей.
|
||||||
|
|
||||||
|
### GET /api/v1/news/{slug}
|
||||||
|
Возвращает `NewsDTO` опубликованной новости (если slug неактивен — `404`).
|
||||||
|
|
||||||
|
### GET /api/v1/admin/news
|
||||||
|
**Query-параметры**: `limit` (50), `page` (1, ≥1).
|
||||||
|
**Ответ**: `Page<NewsDTO>` (включая черновики).
|
||||||
|
|
||||||
|
### POST /api/v1/admin/news
|
||||||
|
**Тело запроса** – `NewsCreate`:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `title` | string | да | Заголовок. |
|
||||||
|
| `slug` | string | да | Уникальный slug (валидация – на уровне базы). |
|
||||||
|
| `summary` | string | да | Краткое описание. |
|
||||||
|
| `content` | string | да | Полный текст (HTML/Markdown). |
|
||||||
|
| `status` | string | нет | `draft` (по умолчанию) \| `published` \| `archived`. |
|
||||||
|
| `imageUrl` | string | нет | Ссылка на обложку. |
|
||||||
|
|
||||||
|
**Ответ**: `{ "id": <number> }`.
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/news/{slug}
|
||||||
|
**Тело запроса** – `NewsUpdateRequest` (все поля опциональны, как в `NewsCreate`). Ответ `{ "updated": true }`.
|
||||||
|
|
||||||
|
### POST /api/v1/admin/news/{slug}/publish
|
||||||
|
Без тела. Устанавливает `status = "published"`, `publishedAt = now`. Ответ `{ "published": true }`.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/news/{slug}
|
||||||
|
Ответ `{ "deleted": true }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Категории услуг
|
||||||
|
| Метод | Путь | JWT | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/service-categories` | Нет | Публичный список категорий. |
|
||||||
|
| GET | `/service-categories/{slug}` | Нет | Получить категорию по slug. |
|
||||||
|
| GET | `/admin/service-categories` | Да | Пагинация категорий. |
|
||||||
|
| POST | `/admin/service-categories` | Да | Создать категорию. |
|
||||||
|
| PUT | `/admin/service-categories/{id}` | Да | Обновить категорию. |
|
||||||
|
| DELETE | `/admin/service-categories/{id}` | Да | Удалить категорию. |
|
||||||
|
|
||||||
|
### GET /api/v1/service-categories
|
||||||
|
Возвращает массив `ServiceCategoryDTO`.
|
||||||
|
|
||||||
|
### GET /api/v1/service-categories/{slug}
|
||||||
|
Ответ – `ServiceCategoryDTO`.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/service-categories
|
||||||
|
**Query-параметры**: `limit` (100), `offset` (0).
|
||||||
|
Ответ: список `ServiceCategoryDTO` (без обёртки).
|
||||||
|
|
||||||
|
### POST /api/v1/admin/service-categories
|
||||||
|
**Тело запроса** – `CategoryCreateRequest` (`name`, `slug` – обязательные). Ответ `{ "id": <number> }`.
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/service-categories/{id}
|
||||||
|
Тело – `CategoryUpdateRequest` (оба поля опциональны). Ответ `{ "updated": true }`.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/service-categories/{id}
|
||||||
|
Ответ `{ "deleted": true }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Услуги
|
||||||
|
| Метод | Путь | JWT | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/services` | Нет | Список опубликованных услуг с фильтрами. |
|
||||||
|
| GET | `/services/{slug}` | Нет | Одна услуга по slug. |
|
||||||
|
| GET | `/admin/services` | Да | Список всех услуг. |
|
||||||
|
| POST | `/admin/services` | Да | Создать услугу. |
|
||||||
|
| PUT | `/admin/services/{id}` | Да | Обновить услугу. |
|
||||||
|
| PUT | `/admin/services/{id}/status` | Да | Изменить статус. |
|
||||||
|
| DELETE | `/admin/services/{id}` | Да | Удалить услугу. |
|
||||||
|
|
||||||
|
### GET /api/v1/services
|
||||||
|
**Query-параметры**
|
||||||
|
|
||||||
|
| Имя | Тип | По умолчанию | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | integer | 20 | Размер страницы. |
|
||||||
|
| `page` | integer | 1 | ≥ 1. |
|
||||||
|
| `q` | string | null | Поиск по `title` и `description` (case-insensitive). |
|
||||||
|
| `category` | string | null | `slug` категории. |
|
||||||
|
| `minPrice` | number | null | Минимальная цена (decimal). |
|
||||||
|
| `maxPrice` | number | null | Максимальная цена (decimal). |
|
||||||
|
|
||||||
|
**Ответ**: `Page<ServiceDTO>`.
|
||||||
|
Пример элемента:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "Разработка мобильного приложения",
|
||||||
|
"slug": "mobile-dev",
|
||||||
|
"description": "Полный цикл разработки",
|
||||||
|
"priceFrom": 150000.0,
|
||||||
|
"imageUrl": "https://cdn.example/app.jpg",
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"category": { "id": 2, "name": "Разработка", "slug": "development" },
|
||||||
|
"createdAt": "2024-04-01T09:15:00",
|
||||||
|
"updatedAt": "2024-05-05T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/services/{slug}
|
||||||
|
Ответ – `ServiceDTO` (включая `category`). Ошибка `404`, если slug не найден или услуга скрыта.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/services
|
||||||
|
**Query-параметры**
|
||||||
|
|
||||||
|
| Имя | Тип | По умолчанию | Описание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | integer | 50 | Размер страницы. |
|
||||||
|
| `page` | integer | 1 | ≥ 1. |
|
||||||
|
| `q` | string | null | Фильтр по названию/описанию. |
|
||||||
|
| `status` | string | null | `PUBLISHED` \| `DRAFT` \| `ARCHIVED`. |
|
||||||
|
|
||||||
|
Ответ: `Page<ServiceDTO>` (включая черновики).
|
||||||
|
|
||||||
|
### POST /api/v1/admin/services
|
||||||
|
**Тело запроса** – `ServiceCreateRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Примечание |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `title` | string | да | Название услуги. |
|
||||||
|
| `slug` | string | да | Уникальный slug (`^[a-z0-9-]{3,}$`). |
|
||||||
|
| `description` | string | да | Детальное описание. |
|
||||||
|
| `priceFrom` | string | нет | Десятичное число, например `"1499.99"`. |
|
||||||
|
| `imageUrl` | string | нет | URL изображения. |
|
||||||
|
| `status` | string | нет | По умолчанию `PUBLISHED`. |
|
||||||
|
| `categoryId` | number | нет | ID существующей категории. |
|
||||||
|
|
||||||
|
**Ответ 201**: `{ "id": <number> }`.
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/services/{id}
|
||||||
|
Тело – `ServiceUpdateRequest` (все поля опциональны, формат как в `ServiceCreateRequest`). Ответ `{ "updated": true }`.
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/services/{id}/status
|
||||||
|
**Тело запроса** – `StatusRequest` с полем `status` (`PUBLISHED`\|`DRAFT`\|`ARCHIVED`). Ответ `{ "updated": true }`.
|
||||||
|
|
||||||
|
### DELETE /api/v1/admin/services/{id}
|
||||||
|
Ответ `{ "deleted": true }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример структуры Page
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [ /* сущности */ ],
|
||||||
|
"total": 125,
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -19,6 +19,7 @@ ktor-server-default-headers = { module = "io.ktor:ktor-server-default-headers",
|
|||||||
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
||||||
ktor-server-compression = { module = "io.ktor:ktor-server-compression", version.ref = "ktor" }
|
ktor-server-compression = { module = "io.ktor:ktor-server-compression", version.ref = "ktor" }
|
||||||
ktor-server-caching-headers = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor" }
|
ktor-server-caching-headers = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor" }
|
||||||
|
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
|
||||||
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
|
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
|
||||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }
|
ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }
|
||||||
@@ -27,6 +28,7 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version
|
|||||||
ktorm = { module = "org.ktorm:ktorm-core", version.ref = "ktorm" }
|
ktorm = { module = "org.ktorm:ktorm-core", version.ref = "ktorm" }
|
||||||
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
|
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
|
||||||
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
|
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
|
||||||
|
psql-support = { module = "org.ktorm:ktorm-support-postgresql", version.ref = "ktorm" }
|
||||||
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
|
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
|
||||||
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }
|
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ fun main(args: Array<String>) {
|
|||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
val jwtCfg = loadJwtConfig()
|
val jwtCfg = loadJwtConfig()
|
||||||
configureSerialization()
|
|
||||||
configureSecurity(jwtCfg)
|
|
||||||
configureDatabase()
|
configureDatabase()
|
||||||
configureHTTP()
|
configureHTTP()
|
||||||
|
configureSerialization()
|
||||||
|
configureSecurity(jwtCfg)
|
||||||
configureRouting(jwtCfg)
|
configureRouting(jwtCfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package cc.essaenko.app
|
package cc.essaenko.app
|
||||||
|
|
||||||
|
import cc.essaenko.shared.errors.NotFoundException
|
||||||
|
import cc.essaenko.shared.errors.ValidationException
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.http.content.*
|
import io.ktor.http.content.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.plugins.BadRequestException
|
||||||
import io.ktor.server.plugins.cachingheaders.*
|
import io.ktor.server.plugins.cachingheaders.*
|
||||||
import io.ktor.server.plugins.compression.*
|
import io.ktor.server.plugins.compression.*
|
||||||
import io.ktor.server.plugins.cors.routing.*
|
import io.ktor.server.plugins.cors.routing.*
|
||||||
import io.ktor.server.plugins.defaultheaders.*
|
import io.ktor.server.plugins.defaultheaders.*
|
||||||
|
import io.ktor.server.plugins.statuspages.*
|
||||||
import io.ktor.server.plugins.swagger.*
|
import io.ktor.server.plugins.swagger.*
|
||||||
|
import io.ktor.server.request.ContentTransformationException
|
||||||
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
fun Application.configureHTTP() {
|
fun Application.configureHTTP() {
|
||||||
@@ -19,13 +25,43 @@ fun Application.configureHTTP() {
|
|||||||
}
|
}
|
||||||
install(CORS) {
|
install(CORS) {
|
||||||
allowMethod(HttpMethod.Options)
|
allowMethod(HttpMethod.Options)
|
||||||
|
allowMethod(HttpMethod.Post)
|
||||||
allowMethod(HttpMethod.Put)
|
allowMethod(HttpMethod.Put)
|
||||||
allowMethod(HttpMethod.Delete)
|
allowMethod(HttpMethod.Delete)
|
||||||
allowMethod(HttpMethod.Patch)
|
allowMethod(HttpMethod.Patch)
|
||||||
|
|
||||||
allowHeader(HttpHeaders.Authorization)
|
allowHeader(HttpHeaders.Authorization)
|
||||||
allowHeader("MyCustomHeader")
|
allowHeader(HttpHeaders.ContentType)
|
||||||
|
allowHeader(HttpHeaders.Accept)
|
||||||
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
|
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
|
||||||
}
|
}
|
||||||
|
install(StatusPages) {
|
||||||
|
exception<ContentTransformationException> { call, cause ->
|
||||||
|
val msg = cause.message ?: "Invalid request payload"
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to msg)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exception<BadRequestException> { call, cause ->
|
||||||
|
val msg = cause.message ?: "Bad request"
|
||||||
|
println(cause.message ?: "Bad request")
|
||||||
|
cause.stackTrace.forEach { println(it) }
|
||||||
|
call.respond(HttpStatusCode.BadRequest, mapOf("error" to msg))
|
||||||
|
}
|
||||||
|
exception<ValidationException> { call, cause ->
|
||||||
|
val message = cause.message ?: "Validation failed"
|
||||||
|
val status = if (message.equals("Invalid credentials", ignoreCase = true)) {
|
||||||
|
HttpStatusCode.Unauthorized
|
||||||
|
} else {
|
||||||
|
HttpStatusCode.BadRequest
|
||||||
|
}
|
||||||
|
call.respond(status, mapOf("error" to message))
|
||||||
|
}
|
||||||
|
exception<NotFoundException> { call, cause ->
|
||||||
|
call.respond(HttpStatusCode.NotFound, mapOf("error" to (cause.message ?: "Not found")))
|
||||||
|
}
|
||||||
|
}
|
||||||
install(Compression)
|
install(Compression)
|
||||||
install(CachingHeaders) {
|
install(CachingHeaders) {
|
||||||
options { call, outgoingContent ->
|
options { call, outgoingContent ->
|
||||||
|
|||||||
@@ -44,18 +44,20 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
|
|||||||
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
|
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
publicNewsRoutes(newsSvc)
|
route("/api/v1") {
|
||||||
publicLeadRoutes(leadSvc)
|
publicNewsRoutes(newsSvc)
|
||||||
publicServiceCategoryRoutes(serviceCategorySvc)
|
publicLeadRoutes(leadSvc)
|
||||||
publicServiceRoutes(serviceSvc)
|
publicServiceCategoryRoutes(serviceCategorySvc)
|
||||||
publicAdminRoutes(adminSvc)
|
publicServiceRoutes(serviceSvc)
|
||||||
|
publicAdminRoutes(adminSvc)
|
||||||
|
|
||||||
authenticate("admin-auth") {
|
authenticate("admin-auth") {
|
||||||
adminNewsRoutes(newsSvc)
|
adminNewsRoutes(newsSvc)
|
||||||
adminRoutes(adminSvc)
|
adminRoutes(adminSvc)
|
||||||
adminLeadRoutes(leadSvc)
|
adminLeadRoutes(leadSvc)
|
||||||
adminServiceCategoryRoutes(serviceCategorySvc)
|
adminServiceCategoryRoutes(serviceCategorySvc)
|
||||||
adminServiceRoutes(serviceSvc)
|
adminServiceRoutes(serviceSvc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package cc.essaenko.app
|
|||||||
import com.auth0.jwt.JWT
|
import com.auth0.jwt.JWT
|
||||||
import com.auth0.jwt.algorithms.Algorithm
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
import io.github.cdimascio.dotenv.dotenv
|
import io.github.cdimascio.dotenv.dotenv
|
||||||
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.auth.jwt.*
|
import io.ktor.server.auth.jwt.*
|
||||||
|
import io.ktor.server.request.httpMethod
|
||||||
|
|
||||||
|
|
||||||
data class JwtConfig(
|
data class JwtConfig(
|
||||||
@@ -32,6 +34,7 @@ fun Application.configureSecurity(jwt: JwtConfig) {
|
|||||||
install(Authentication) {
|
install(Authentication) {
|
||||||
jwt("admin-auth") {
|
jwt("admin-auth") {
|
||||||
realm = jwt.realm
|
realm = jwt.realm
|
||||||
|
skipWhen { call -> call.request.httpMethod == HttpMethod.Options }
|
||||||
verifier(
|
verifier(
|
||||||
JWT
|
JWT
|
||||||
.require(algorithm)
|
.require(algorithm)
|
||||||
|
|||||||
23
src/main/kotlin/app/serialization/LocalDateTime.kt
Normal file
23
src/main/kotlin/app/serialization/LocalDateTime.kt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
object JavaLocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||||
|
private val fmt = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
|
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("JavaLocalDateTime", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: LocalDateTime) {
|
||||||
|
encoder.encodeString(fmt.format(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||||
|
return LocalDateTime.parse(decoder.decodeString(), fmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package cc.essaenko.modules.admin
|
package cc.essaenko.modules.admin
|
||||||
|
|
||||||
import io.ktor.server.routing.*
|
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.request.*
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import io.ktor.server.auth.*
|
||||||
|
import io.ktor.server.auth.jwt.*
|
||||||
|
import io.ktor.server.request.*
|
||||||
|
import io.ktor.server.response.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class AdminRegisterRequest(val username: String, val password: String)
|
data class AdminRegisterRequest(val username: String, val password: String)
|
||||||
@@ -12,52 +14,70 @@ data class AdminRegisterRequest(val username: String, val password: String)
|
|||||||
data class AdminLoginRequest(val username: String, val password: String)
|
data class AdminLoginRequest(val username: String, val password: String)
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class ChangePasswordRequest(val password: String)
|
data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class AdminLoginResponse(
|
||||||
|
val id: Long,
|
||||||
|
val username: String,
|
||||||
|
val token: String,
|
||||||
|
val tokenType: String = "Bearer",
|
||||||
|
val expiresInMinutes: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class AdminRegisterResponse(val id: Long, val username: String)
|
||||||
|
|
||||||
fun Route.publicAdminRoutes(svc: AdminService) = route("/admin") {
|
fun Route.publicAdminRoutes(svc: AdminService) = route("/admin") {
|
||||||
// Логин
|
|
||||||
post("/login") {
|
post("/login") {
|
||||||
val body = call.receive<AdminLoginRequest>()
|
val body = call.receive<AdminLoginRequest>()
|
||||||
val auth = svc.login(body.username, body.password)
|
val auth = svc.login(body.username, body.password)
|
||||||
|
call.respond(
|
||||||
|
AdminLoginResponse(
|
||||||
|
id = auth.id,
|
||||||
|
username = auth.username,
|
||||||
|
token = auth.token,
|
||||||
|
tokenType = "Bearer",
|
||||||
|
expiresInMinutes = auth.expiresInMinutes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/password_hash") {
|
||||||
|
val raw = call.request.queryParameters["password"] ?: "admin123";
|
||||||
call.respond(
|
call.respond(
|
||||||
mapOf(
|
mapOf(
|
||||||
"id" to auth.id,
|
"pass" to svc.getPasswordHash(raw)
|
||||||
"username" to auth.username,
|
|
||||||
"token" to auth.token,
|
|
||||||
"tokenType" to "Bearer",
|
|
||||||
"expiresInMinutes" to auth.expiresInMinutes
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Route.adminRoutes(svc: AdminService) = route("/admin") {
|
fun Route.adminRoutes(svc: AdminService) = route("/admin") {
|
||||||
|
|
||||||
// Список админов (id, username, createdAt, lastLoginAt)
|
|
||||||
get {
|
get {
|
||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
val principal = call.principal<JWTPrincipal>() ?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
val adminId = principal.subject?.toLongOrNull()
|
||||||
call.respond(svc.list(limit, offset))
|
?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||||
|
call.respond(svc.current(adminId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Регистрация нового админа
|
|
||||||
post {
|
post {
|
||||||
val body = call.receive<AdminRegisterRequest>()
|
val body = call.receive<AdminRegisterRequest>()
|
||||||
val id = svc.register(body.username, body.password)
|
val id = svc.register(body.username, body.password)
|
||||||
call.respond(HttpStatusCode.Created, mapOf("id" to id, "username" to body.username))
|
call.respond(HttpStatusCode.Created, AdminRegisterResponse(id, body.username))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Смена пароля
|
|
||||||
put("{id}/password") {
|
put("{id}/password") {
|
||||||
val id = call.parameters["id"]?.toLongOrNull() ?: return@put call.respond(HttpStatusCode.BadRequest)
|
val id = call.parameters["id"]?.toLongOrNull()
|
||||||
|
?: return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid admin id"))
|
||||||
val body = call.receive<ChangePasswordRequest>()
|
val body = call.receive<ChangePasswordRequest>()
|
||||||
svc.changePassword(id, body.password)
|
svc.changePassword(id, body.currentPassword, body.newPassword)
|
||||||
call.respond(mapOf("updated" to true))
|
call.respond(mapOf("updated" to true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление админа
|
|
||||||
delete("{id}") {
|
delete("{id}") {
|
||||||
val id = call.parameters["id"]?.toLongOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest)
|
val id = call.parameters["id"]?.toLongOrNull()
|
||||||
|
?: return@delete call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid admin id"))
|
||||||
svc.remove(id)
|
svc.remove(id)
|
||||||
call.respond(mapOf("deleted" to true))
|
call.respond(mapOf("deleted" to true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.admin
|
package cc.essaenko.modules.admin
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.entity.Entity
|
import org.ktorm.entity.Entity
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -13,7 +14,15 @@ interface AdminEntity : Entity<AdminEntity> {
|
|||||||
var lastLoginAt: LocalDateTime?
|
var lastLoginAt: LocalDateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
object AdminUsers : Table<AdminEntity>("admin_user") {
|
@Serializable
|
||||||
|
data class AdminDTO(
|
||||||
|
val id: Long,
|
||||||
|
val username: String,
|
||||||
|
@Serializable(with = JavaLocalDateTimeSerializer::class)
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
object AdminUsers : Table<AdminEntity>("t_admins") {
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
val username = varchar("username").bindTo { it.username }
|
val username = varchar("username").bindTo { it.username }
|
||||||
val password = varchar("password_hash").bindTo { it.password }
|
val password = varchar("password_hash").bindTo { it.password }
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import org.ktorm.entity.*
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class AdminCreate(val username: String, val password: String)
|
data class AdminCreate(val username: String, val password: String)
|
||||||
data class AdminView(val id: Long, val username: String, val createdAt: LocalDateTime, val lastLoginAt: LocalDateTime?)
|
|
||||||
|
|
||||||
interface AdminRepository {
|
interface AdminRepository {
|
||||||
fun list(limit: Int = 50, offset: Int = 0): List<AdminView>
|
fun list(limit: Int = 50, offset: Int = 0): List<AdminDTO>
|
||||||
fun findById(id: Long): AdminEntity?
|
fun findById(id: Long): AdminEntity?
|
||||||
fun findByUsername(username: String): AdminEntity?
|
fun findByUsername(username: String): AdminEntity?
|
||||||
fun create(cmd: AdminCreate): Long
|
fun create(cmd: AdminCreate): Long
|
||||||
@@ -22,9 +21,9 @@ class AdminRepositoryImpl(private val db: Database) : AdminRepository {
|
|||||||
|
|
||||||
private val admins get() = db.sequenceOf(AdminUsers)
|
private val admins get() = db.sequenceOf(AdminUsers)
|
||||||
|
|
||||||
override fun list(limit: Int, offset: Int): List<AdminView> =
|
override fun list(limit: Int, offset: Int): List<AdminDTO> =
|
||||||
admins.sortedBy { it.id }.drop(offset).take(limit).toList()
|
admins.sortedBy { it.id }.drop(offset).take(limit).toList()
|
||||||
.map { AdminView(it.id, it.username, it.createdAt, it.lastLoginAt) }
|
.map { AdminDTO(it.id, it.username, it.createdAt) }
|
||||||
|
|
||||||
override fun findById(id: Long): AdminEntity? =
|
override fun findById(id: Long): AdminEntity? =
|
||||||
admins.firstOrNull { it.id eq id }
|
admins.firstOrNull { it.id eq id }
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ class AdminService(
|
|||||||
private val hasher: PasswordHasher,
|
private val hasher: PasswordHasher,
|
||||||
private val tokens: TokenService
|
private val tokens: TokenService
|
||||||
) {
|
) {
|
||||||
|
fun getPasswordHash(raw: String): String {
|
||||||
|
return hasher.hash(raw)
|
||||||
|
}
|
||||||
fun list(limit: Int = 50, offset: Int = 0) = repo.list(limit, offset)
|
fun list(limit: Int = 50, offset: Int = 0) = repo.list(limit, offset)
|
||||||
|
|
||||||
|
fun current(id: Long): AdminDTO {
|
||||||
|
val admin = repo.findById(id) ?: throw NotFoundException("Admin not found")
|
||||||
|
return AdminDTO(admin.id, admin.username, admin.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
fun register(username: String, rawPassword: String): Long {
|
fun register(username: String, rawPassword: String): Long {
|
||||||
require(username.matches(Regex("^[a-zA-Z0-9_.-]{3,}$"))) { "Invalid username" }
|
require(username.matches(Regex("^[a-zA-Z0-9_.-]{3,}$"))) { "Invalid username" }
|
||||||
require(rawPassword.length >= 8) { "Password must be at least 8 characters" }
|
require(rawPassword.length >= 8) { "Password must be at least 8 characters" }
|
||||||
@@ -48,8 +56,12 @@ class AdminService(
|
|||||||
return AuthResult(admin.id, admin.username, token, /*экспорт*/ 60)
|
return AuthResult(admin.id, admin.username, token, /*экспорт*/ 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changePassword(id: Long, newPassword: String) {
|
fun changePassword(id: Long, currentPassword: String, newPassword: String) {
|
||||||
require(newPassword.length >= 8) { "Password must be at least 8 characters" }
|
require(newPassword.length >= 8) { "Password must be at least 8 characters" }
|
||||||
|
val admin = repo.findById(id) ?: throw NotFoundException("Admin not found")
|
||||||
|
if (!hasher.verify(currentPassword, admin.password)) {
|
||||||
|
throw ValidationException("Current password is invalid")
|
||||||
|
}
|
||||||
val ok = repo.updatePassword(id, hasher.hash(newPassword))
|
val ok = repo.updatePassword(id, hasher.hash(newPassword))
|
||||||
if (!ok) throw NotFoundException("Admin not found")
|
if (!ok) throw NotFoundException("Admin not found")
|
||||||
}
|
}
|
||||||
@@ -75,4 +87,4 @@ class TokenService(private val cfg: JwtConfig) {
|
|||||||
.withExpiresAt(exp)
|
.withExpiresAt(exp)
|
||||||
.sign(algorithm)
|
.sign(algorithm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package cc.essaenko.modules.lead
|
package cc.essaenko.modules.lead
|
||||||
|
|
||||||
import io.ktor.server.routing.*
|
import cc.essaenko.shared.pagination.Page
|
||||||
|
import io.ktor.http.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.http.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class LeadCreateRequest(
|
data class LeadCreateRequest(
|
||||||
@@ -12,7 +13,6 @@ data class LeadCreateRequest(
|
|||||||
val phone: String? = null,
|
val phone: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Публичный эндпоинт формы обратной связи */
|
|
||||||
fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
|
fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
|
||||||
post {
|
post {
|
||||||
val body = call.receive<LeadCreateRequest>()
|
val body = call.receive<LeadCreateRequest>()
|
||||||
@@ -27,15 +27,28 @@ fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Админские эндпоинты для просмотра/удаления лидов */
|
|
||||||
fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
|
fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
|
||||||
|
|
||||||
get {
|
get {
|
||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
|
||||||
|
if (page < 1) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "page must be greater than 0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val offset = (page - 1) * limit
|
||||||
val q = call.request.queryParameters["q"]
|
val q = call.request.queryParameters["q"]
|
||||||
val page = svc.list(limit, offset, q)
|
val data = svc.list(limit, offset, q)
|
||||||
call.respond(page)
|
call.respond(
|
||||||
|
Page(
|
||||||
|
items = data.items,
|
||||||
|
total = data.total,
|
||||||
|
limit = data.limit,
|
||||||
|
offset = data.offset
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get("{id}") {
|
get("{id}") {
|
||||||
@@ -43,12 +56,12 @@ fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
|
|||||||
?: return@get call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
|
?: return@get call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
|
||||||
val lead = svc.get(id)
|
val lead = svc.get(id)
|
||||||
call.respond(
|
call.respond(
|
||||||
mapOf(
|
LeadDTO(
|
||||||
"id" to lead.id,
|
id = lead.id,
|
||||||
"fullName" to lead.fullName,
|
createdAt = lead.createdAt,
|
||||||
"email" to lead.email,
|
email = lead.email,
|
||||||
"phone" to lead.phone,
|
fullName = lead.fullName,
|
||||||
"createdAt" to lead.createdAt
|
phone = lead.phone,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -59,4 +72,4 @@ fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
|
|||||||
svc.delete(id)
|
svc.delete(id)
|
||||||
call.respond(mapOf("deleted" to true))
|
call.respond(mapOf("deleted" to true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.lead
|
package cc.essaenko.modules.lead
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.entity.Entity
|
import org.ktorm.entity.Entity
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -13,7 +14,17 @@ interface LeadEntity : Entity<LeadEntity> {
|
|||||||
var createdAt: LocalDateTime
|
var createdAt: LocalDateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
object Leads : Table<LeadEntity>("lead") {
|
@Serializable
|
||||||
|
data class LeadDTO(
|
||||||
|
val id: Long,
|
||||||
|
val fullName: String,
|
||||||
|
val email: String,
|
||||||
|
val phone: String?,
|
||||||
|
@Serializable(with = JavaLocalDateTimeSerializer::class)
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
object Leads : Table<LeadEntity>("t_users") {
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
val fullName = varchar("full_name").bindTo { it.fullName }
|
val fullName = varchar("full_name").bindTo { it.fullName }
|
||||||
val email = varchar("email").bindTo { it.email }
|
val email = varchar("email").bindTo { it.email }
|
||||||
|
|||||||
@@ -11,17 +11,10 @@ data class LeadCreate(
|
|||||||
val phone: String? = null,
|
val phone: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LeadView(
|
|
||||||
val id: Long,
|
|
||||||
val fullName: String,
|
|
||||||
val email: String,
|
|
||||||
val phone: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface LeadRepository {
|
interface LeadRepository {
|
||||||
fun create(cmd: LeadCreate): Long
|
fun create(cmd: LeadCreate): Long
|
||||||
fun getById(id: Long): LeadEntity?
|
fun getById(id: Long): LeadEntity?
|
||||||
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): List<LeadView>
|
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): List<LeadDTO>
|
||||||
fun delete(id: Long): Boolean
|
fun delete(id: Long): Boolean
|
||||||
fun count(q: String? = null): Int
|
fun count(q: String? = null): Int
|
||||||
}
|
}
|
||||||
@@ -44,7 +37,7 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
|
|||||||
override fun getById(id: Long): LeadEntity? =
|
override fun getById(id: Long): LeadEntity? =
|
||||||
leads.firstOrNull { it.id eq id }
|
leads.firstOrNull { it.id eq id }
|
||||||
|
|
||||||
override fun list(limit: Int, offset: Int, q: String?): List<LeadView> {
|
override fun list(limit: Int, offset: Int, q: String?): List<LeadDTO> {
|
||||||
var seq: EntitySequence<LeadEntity, Leads> = leads
|
var seq: EntitySequence<LeadEntity, Leads> = leads
|
||||||
if (!q.isNullOrBlank()) {
|
if (!q.isNullOrBlank()) {
|
||||||
val like = "%${q.lowercase()}%"
|
val like = "%${q.lowercase()}%"
|
||||||
@@ -60,11 +53,12 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
|
|||||||
.take(limit)
|
.take(limit)
|
||||||
.toList()
|
.toList()
|
||||||
.map {
|
.map {
|
||||||
LeadView(
|
LeadDTO(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
fullName = it.fullName,
|
fullName = it.fullName,
|
||||||
email = it.email,
|
email = it.email,
|
||||||
phone = it.phone,
|
phone = it.phone,
|
||||||
|
createdAt = it.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cc.essaenko.modules.lead
|
|||||||
|
|
||||||
import cc.essaenko.shared.errors.NotFoundException
|
import cc.essaenko.shared.errors.NotFoundException
|
||||||
import cc.essaenko.shared.errors.ValidationException
|
import cc.essaenko.shared.errors.ValidationException
|
||||||
|
import cc.essaenko.shared.pagination.Page
|
||||||
|
|
||||||
class LeadService(private val repo: LeadRepository) {
|
class LeadService(private val repo: LeadRepository) {
|
||||||
fun create(cmd: LeadCreate): Long {
|
fun create(cmd: LeadCreate): Long {
|
||||||
@@ -15,9 +16,7 @@ class LeadService(private val repo: LeadRepository) {
|
|||||||
|
|
||||||
fun get(id: Long) = repo.getById(id) ?: throw NotFoundException("lead $id not found")
|
fun get(id: Long) = repo.getById(id) ?: throw NotFoundException("lead $id not found")
|
||||||
|
|
||||||
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
|
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page<LeadDTO> {
|
||||||
|
|
||||||
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page<LeadView> {
|
|
||||||
val items = repo.list(limit, offset, q)
|
val items = repo.list(limit, offset, q)
|
||||||
val total = repo.count(q)
|
val total = repo.count(q)
|
||||||
return Page(items, total, limit, offset)
|
return Page(items, total, limit, offset)
|
||||||
@@ -27,4 +26,4 @@ class LeadService(private val repo: LeadRepository) {
|
|||||||
val ok = repo.delete(id)
|
val ok = repo.delete(id)
|
||||||
if (!ok) throw NotFoundException("lead $id not found")
|
if (!ok) throw NotFoundException("lead $id not found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
package cc.essaenko.modules.news
|
package cc.essaenko.modules.news
|
||||||
|
|
||||||
|
import io.ktor.http.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import cc.essaenko.shared.pagination.Page
|
||||||
|
|
||||||
fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
|
@Serializable
|
||||||
|
data class NewsUpdateRequest(
|
||||||
|
val title: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val content: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Route.adminNewsRoutes(svc: NewsService) = route("/admin/news") {
|
||||||
|
get {
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||||
|
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
|
||||||
|
if (page < 1) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "page must be greater than 0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val offset = (page - 1) * limit
|
||||||
|
val pageData = svc.listAdmin(limit, offset)
|
||||||
|
call.respond(
|
||||||
|
Page(
|
||||||
|
items = pageData.items.map { it.toDto() },
|
||||||
|
total = pageData.total,
|
||||||
|
limit = pageData.limit,
|
||||||
|
offset = pageData.offset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
post {
|
post {
|
||||||
val payload = call.receive<NewsCreate>()
|
val payload = call.receive<NewsCreate>()
|
||||||
val id = svc.create(payload)
|
val id = svc.create(payload)
|
||||||
@@ -12,8 +45,8 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
|
|||||||
}
|
}
|
||||||
put("{slug}") {
|
put("{slug}") {
|
||||||
val slug = call.parameters["slug"]!!
|
val slug = call.parameters["slug"]!!
|
||||||
val body = call.receive<Map<String, String>>()
|
val body = call.receive<NewsUpdateRequest>()
|
||||||
val ok = svc.update(slug, body["summary"].orEmpty(), body["content"].orEmpty())
|
val ok = svc.update(slug, body.toDomain())
|
||||||
call.respond(mapOf("updated" to ok))
|
call.respond(mapOf("updated" to ok))
|
||||||
}
|
}
|
||||||
post("{slug}/publish") {
|
post("{slug}/publish") {
|
||||||
@@ -31,16 +64,48 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
|
|||||||
|
|
||||||
fun Route.publicNewsRoutes(svc: NewsService) = route("/news") {
|
fun Route.publicNewsRoutes(svc: NewsService) = route("/news") {
|
||||||
get {
|
get {
|
||||||
val items = svc.list()
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
|
||||||
call.respond(items.map {
|
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
|
||||||
// можно вернуть Entity напрямую (оно сериализуемо, если поля простые),
|
if (page < 1) {
|
||||||
// но лучше собрать DTO — показываю минимально:
|
return@get call.respond(
|
||||||
mapOf("id" to it.id, "title" to it.title, "slug" to it.slug, "summary" to it.summary)
|
HttpStatusCode.BadRequest,
|
||||||
})
|
mapOf("error" to "page must be greater than 0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val offset = (page - 1) * limit
|
||||||
|
val pageData = svc.list(limit, offset)
|
||||||
|
call.respond(
|
||||||
|
Page(
|
||||||
|
items = pageData.items.map { it.toDto() },
|
||||||
|
total = pageData.total,
|
||||||
|
limit = pageData.limit,
|
||||||
|
offset = pageData.offset
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
get("{slug}") {
|
get("{slug}") {
|
||||||
val slug = call.parameters["slug"]!!
|
val slug = call.parameters["slug"]!!
|
||||||
val item = svc.get(slug)
|
val item = svc.get(slug)
|
||||||
call.respond(item)
|
call.respond(item.toDto())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun NewsUpdateRequest.toDomain() = NewsUpdate(
|
||||||
|
title = title,
|
||||||
|
slug = slug,
|
||||||
|
summary = summary,
|
||||||
|
content = content,
|
||||||
|
status = status,
|
||||||
|
imageUrl = imageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun News.toDto() = NewsDTO(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
slug = slug,
|
||||||
|
summary = summary,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
status = status,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.news
|
package cc.essaenko.modules.news
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.database.Database
|
import org.ktorm.database.Database
|
||||||
import org.ktorm.entity.Entity
|
import org.ktorm.entity.Entity
|
||||||
import org.ktorm.entity.sequenceOf
|
import org.ktorm.entity.sequenceOf
|
||||||
@@ -20,6 +21,19 @@ interface News : Entity<News> {
|
|||||||
var updatedAt: LocalDateTime
|
var updatedAt: LocalDateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewsDTO(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val slug: String,
|
||||||
|
val summary: String,
|
||||||
|
val content: String,
|
||||||
|
val status: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
@Serializable(with = JavaLocalDateTimeSerializer::class)
|
||||||
|
val publishedAt: LocalDateTime?,
|
||||||
|
)
|
||||||
|
|
||||||
object NewsT : Table<News>("t_news") {
|
object NewsT : Table<News>("t_news") {
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
val title = varchar("title").bindTo { it.title }
|
val title = varchar("title").bindTo { it.title }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.news
|
package cc.essaenko.modules.news
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.database.Database
|
import org.ktorm.database.Database
|
||||||
import org.ktorm.dsl.eq
|
import org.ktorm.dsl.eq
|
||||||
import org.ktorm.dsl.lessEq
|
import org.ktorm.dsl.lessEq
|
||||||
@@ -8,20 +9,34 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
interface NewsRepository {
|
interface NewsRepository {
|
||||||
fun listPublished(limit: Int = 20, offset: Int = 0): List<News>
|
fun listPublished(limit: Int = 20, offset: Int = 0): List<News>
|
||||||
|
fun countPublished(): Int
|
||||||
|
fun listAll(limit: Int = 50, offset: Int = 0): List<News>
|
||||||
|
fun countAll(): Int
|
||||||
fun getBySlug(slug: String): News?
|
fun getBySlug(slug: String): News?
|
||||||
fun create(cmd: NewsCreate): Long
|
fun create(cmd: NewsCreate): Long
|
||||||
fun updateContent(slug: String, summary: String, content: String): Boolean
|
fun update(slug: String, patch: NewsUpdate): Boolean
|
||||||
fun publish(slug: String, at: LocalDateTime = LocalDateTime.now()): Boolean
|
fun publish(slug: String, at: LocalDateTime = LocalDateTime.now()): Boolean
|
||||||
fun delete(slug: String): Boolean
|
fun delete(slug: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class NewsCreate(
|
data class NewsCreate(
|
||||||
val title: String,
|
val title: String,
|
||||||
val slug: String,
|
val slug: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val status: String = "DRAFT",
|
val status: String = "draft",
|
||||||
val imageUrl: String?,
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewsUpdate(
|
||||||
|
val title: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val content: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
||||||
@@ -29,13 +44,29 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
|||||||
|
|
||||||
override fun listPublished(limit: Int, offset: Int): List<News> =
|
override fun listPublished(limit: Int, offset: Int): List<News> =
|
||||||
news
|
news
|
||||||
.filter { it.status eq "PUBLISHED" }
|
.filter { it.status eq "published" }
|
||||||
.filter { it.publishedAt lessEq LocalDateTime.now() }
|
.filter { it.publishedAt lessEq LocalDateTime.now() }
|
||||||
.sortedByDescending { it.publishedAt }
|
.sortedByDescending { it.publishedAt }
|
||||||
.drop(offset)
|
.drop(offset)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
|
override fun countPublished(): Int =
|
||||||
|
news
|
||||||
|
.filter { it.status eq "published" }
|
||||||
|
.filter { it.publishedAt lessEq LocalDateTime.now() }
|
||||||
|
.count()
|
||||||
|
|
||||||
|
override fun listAll(limit: Int, offset: Int): List<News> =
|
||||||
|
news
|
||||||
|
.sortedByDescending { it.createdAt }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
override fun countAll(): Int =
|
||||||
|
news.count()
|
||||||
|
|
||||||
override fun getBySlug(slug: String): News? =
|
override fun getBySlug(slug: String): News? =
|
||||||
news.firstOrNull { it.slug eq slug }
|
news.firstOrNull { it.slug eq slug }
|
||||||
|
|
||||||
@@ -47,28 +78,38 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
|||||||
summary = cmd.summary
|
summary = cmd.summary
|
||||||
content = cmd.content
|
content = cmd.content
|
||||||
status = cmd.status
|
status = cmd.status
|
||||||
publishedAt = if (cmd.status == "PUBLISHED") now else null
|
publishedAt = if (cmd.status == "published") now else null
|
||||||
imageUrl = cmd.imageUrl
|
imageUrl = cmd.imageUrl
|
||||||
createdAt = now
|
createdAt = now
|
||||||
updatedAt = now
|
updatedAt = now
|
||||||
}
|
}
|
||||||
// add(...) вернёт количество затронутых строк, ключ читаем через свойство после вставки
|
|
||||||
news.add(entity)
|
news.add(entity)
|
||||||
return entity.id
|
return entity.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateContent(slug: String, summary: String, content: String): Boolean {
|
override fun update(slug: String, patch: NewsUpdate): Boolean {
|
||||||
val e = getBySlug(slug) ?: return false
|
val e = getBySlug(slug) ?: return false
|
||||||
e.summary = summary
|
patch.title?.let { e.title = it }
|
||||||
e.content = content
|
patch.slug?.let { e.slug = it }
|
||||||
|
patch.summary?.let { e.summary = it }
|
||||||
|
patch.content?.let { e.content = it }
|
||||||
|
patch.status?.let {
|
||||||
|
e.status = it
|
||||||
|
if (it.equals("published", ignoreCase = true)) {
|
||||||
|
e.publishedAt = e.publishedAt ?: LocalDateTime.now()
|
||||||
|
} else {
|
||||||
|
e.publishedAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.imageUrl?.let { e.imageUrl = it }
|
||||||
e.updatedAt = LocalDateTime.now()
|
e.updatedAt = LocalDateTime.now()
|
||||||
e.flushChanges() // применит UPDATE по изменённым полям
|
e.flushChanges()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun publish(slug: String, at: LocalDateTime): Boolean {
|
override fun publish(slug: String, at: LocalDateTime): Boolean {
|
||||||
val e = getBySlug(slug) ?: return false
|
val e = getBySlug(slug) ?: return false
|
||||||
e.status = "PUBLISHED"
|
e.status = "published"
|
||||||
e.publishedAt = at
|
e.publishedAt = at
|
||||||
e.updatedAt = LocalDateTime.now()
|
e.updatedAt = LocalDateTime.now()
|
||||||
e.flushChanges()
|
e.flushChanges()
|
||||||
@@ -78,4 +119,3 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
|||||||
override fun delete(slug: String): Boolean =
|
override fun delete(slug: String): Boolean =
|
||||||
news.removeIf { it.slug eq slug } > 0
|
news.removeIf { it.slug eq slug } > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,42 @@
|
|||||||
package cc.essaenko.modules.news
|
package cc.essaenko.modules.news
|
||||||
|
|
||||||
import cc.essaenko.shared.errors.NotFoundException
|
import cc.essaenko.shared.errors.NotFoundException
|
||||||
|
import cc.essaenko.shared.pagination.Page
|
||||||
|
|
||||||
class NewsService (private val repo: NewsRepository) {
|
class NewsService (private val repo: NewsRepository) {
|
||||||
fun list(limit: Int = 20, offset: Int = 0) = repo.listPublished(limit, offset)
|
private val slugRegex = Regex("^[a-z0-9-]{3,}$")
|
||||||
|
|
||||||
|
fun list(limit: Int = 20, offset: Int = 0): Page<News> =
|
||||||
|
Page(
|
||||||
|
items = repo.listPublished(limit, offset),
|
||||||
|
total = repo.countPublished(),
|
||||||
|
limit = limit,
|
||||||
|
offset = offset
|
||||||
|
)
|
||||||
|
|
||||||
|
fun listAdmin(limit: Int = 50, offset: Int = 0): Page<News> =
|
||||||
|
Page(
|
||||||
|
items = repo.listAll(limit, offset),
|
||||||
|
total = repo.countAll(),
|
||||||
|
limit = limit,
|
||||||
|
offset = offset
|
||||||
|
)
|
||||||
|
|
||||||
fun get(slug: String) = repo.getBySlug(slug) ?: throw NotFoundException("news '$slug' not found")
|
fun get(slug: String) = repo.getBySlug(slug) ?: throw NotFoundException("news '$slug' not found")
|
||||||
|
|
||||||
fun create(cmd: NewsCreate): Long {
|
fun create(cmd: NewsCreate): Long {
|
||||||
require(cmd.title.isNotBlank()) { "title is required" }
|
validateTitle(cmd.title)
|
||||||
require(cmd.slug.matches(Regex("^[a-z0-9-]{3,}$"))) { "slug invalid" }
|
validateSlug(cmd.slug)
|
||||||
return repo.create(cmd)
|
return repo.create(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(slug: String, summary: String, content: String) =
|
fun update(slug: String, patch: NewsUpdate): Boolean {
|
||||||
repo.updateContent(slug, summary, content).also {
|
patch.slug?.let { validateSlug(it) }
|
||||||
require(it) { "news '$slug' not found" }
|
patch.title?.let { validateTitle(it) }
|
||||||
}
|
val ok = repo.update(slug, patch)
|
||||||
|
require(ok) { "news '$slug' not found" }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
fun publish(slug: String) =
|
fun publish(slug: String) =
|
||||||
repo.publish(slug).also {
|
repo.publish(slug).also {
|
||||||
@@ -27,4 +47,12 @@ class NewsService (private val repo: NewsRepository) {
|
|||||||
repo.delete(slug).also {
|
repo.delete(slug).also {
|
||||||
require(it) { "news '$slug' not found" }
|
require(it) { "news '$slug' not found" }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun validateTitle(title: String) {
|
||||||
|
require(title.isNotBlank()) { "title is required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateSlug(slug: String) {
|
||||||
|
require(slug.matches(slugRegex)) { "slug invalid" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package cc.essaenko.modules.service
|
package cc.essaenko.modules.service
|
||||||
|
|
||||||
import io.ktor.server.routing.*
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
|
||||||
|
import cc.essaenko.shared.pagination.Page
|
||||||
|
import io.ktor.http.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.http.*
|
import io.ktor.server.routing.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
@@ -36,41 +38,65 @@ private fun String?.toBigDecOrNull() = this?.let { runCatching { BigDecimal(it)
|
|||||||
fun Route.publicServiceRoutes(svc: ServiceService) = route("/services") {
|
fun Route.publicServiceRoutes(svc: ServiceService) = route("/services") {
|
||||||
get {
|
get {
|
||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
|
||||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
|
||||||
|
if (page < 1) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "page must be greater than 0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val offset = (page - 1) * limit
|
||||||
val q = call.request.queryParameters["q"]
|
val q = call.request.queryParameters["q"]
|
||||||
val categorySlug = call.request.queryParameters["category"]
|
val categorySlug = call.request.queryParameters["category"]
|
||||||
val minPrice = call.request.queryParameters["minPrice"].toBigDecOrNull()
|
val minPrice = call.request.queryParameters["minPrice"].toBigDecOrNull()
|
||||||
val maxPrice = call.request.queryParameters["maxPrice"].toBigDecOrNull()
|
val maxPrice = call.request.queryParameters["maxPrice"].toBigDecOrNull()
|
||||||
|
|
||||||
val page = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
|
val res = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
|
||||||
call.respond(page)
|
call.respond(
|
||||||
|
Page(
|
||||||
|
items = res.items,
|
||||||
|
total = res.total,
|
||||||
|
limit = res.limit,
|
||||||
|
offset = res.offset
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get("{slug}") {
|
get("{slug}") {
|
||||||
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
|
val slug = call.parameters["slug"]
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "slug parameter is required")
|
||||||
|
)
|
||||||
val item = svc.getBySlug(slug)
|
val item = svc.getBySlug(slug)
|
||||||
call.respond(
|
call.respond(
|
||||||
mapOf(
|
ServiceDTO(
|
||||||
"id" to item.id,
|
id = item.id,
|
||||||
"title" to item.title,
|
title = item.title,
|
||||||
"slug" to item.slug,
|
slug = item.slug,
|
||||||
"description" to item.description,
|
description = item.description,
|
||||||
"priceFrom" to item.priceFrom,
|
priceFrom = item.priceFrom?.toFloat(),
|
||||||
"imageUrl" to item.imageUrl,
|
imageUrl = item.imageUrl,
|
||||||
"status" to item.status,
|
status = item.status,
|
||||||
"categoryId" to item.category?.id,
|
createdAt = item.createdAt,
|
||||||
"createdAt" to item.createdAt,
|
updatedAt = item.updatedAt,
|
||||||
"updatedAt" to item.updatedAt
|
category = item.category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Route.adminServiceRoutes(svc: ServiceService) = route("/admin/services") {
|
fun Route.adminServiceRoutes(svc: ServiceService) = route("/admin/services") {
|
||||||
|
|
||||||
get {
|
get {
|
||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
|
||||||
|
if (page < 1) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "page must be greater than 0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val offset = (page - 1) * limit
|
||||||
val q = call.request.queryParameters["q"]
|
val q = call.request.queryParameters["q"]
|
||||||
val status = call.request.queryParameters["status"]
|
val status = call.request.queryParameters["status"]
|
||||||
call.respond(svc.listAdmin(limit, offset, q, status))
|
call.respond(svc.listAdmin(limit, offset, q, status))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cc.essaenko.modules.service
|
|||||||
|
|
||||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
|
||||||
import cc.essaenko.modules.serviceCategory.ServiceCategories
|
import cc.essaenko.modules.serviceCategory.ServiceCategories
|
||||||
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.entity.Entity
|
import org.ktorm.entity.Entity
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -9,6 +11,7 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
interface ServiceEntity : Entity<ServiceEntity> {
|
interface ServiceEntity : Entity<ServiceEntity> {
|
||||||
companion object : Entity.Factory<ServiceEntity>()
|
companion object : Entity.Factory<ServiceEntity>()
|
||||||
|
|
||||||
var id: Long
|
var id: Long
|
||||||
var title: String
|
var title: String
|
||||||
var slug: String
|
var slug: String
|
||||||
@@ -21,17 +24,33 @@ interface ServiceEntity : Entity<ServiceEntity> {
|
|||||||
var updatedAt: LocalDateTime
|
var updatedAt: LocalDateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
object Services : Table<ServiceEntity>("service") {
|
@Serializable
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
data class ServiceDTO(
|
||||||
val title = varchar("title").bindTo { it.title }
|
var id: Long,
|
||||||
val slug = varchar("slug").bindTo { it.slug }
|
var title: String,
|
||||||
|
var slug: String,
|
||||||
|
var description: String,
|
||||||
|
var priceFrom: Float?,
|
||||||
|
var imageUrl: String?,
|
||||||
|
var status: String,
|
||||||
|
var category: ServiceCategoryDTO?,
|
||||||
|
@Serializable(with = JavaLocalDateTimeSerializer::class)
|
||||||
|
var createdAt: LocalDateTime,
|
||||||
|
@Serializable(with = JavaLocalDateTimeSerializer::class)
|
||||||
|
var updatedAt: LocalDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
object Services : Table<ServiceEntity>("t_services") {
|
||||||
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
|
val title = varchar("title").bindTo { it.title }
|
||||||
|
val slug = varchar("slug").bindTo { it.slug }
|
||||||
val description = text("description").bindTo { it.description }
|
val description = text("description").bindTo { it.description }
|
||||||
val priceFrom = decimal("price_from").bindTo { it.priceFrom }
|
val priceFrom = decimal("price_from").bindTo { it.priceFrom }
|
||||||
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
|
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
|
||||||
val status = varchar("status").bindTo { it.status }
|
val status = varchar("status").bindTo { it.status }
|
||||||
|
|
||||||
val category = long("category_id").references(ServiceCategories) { it.category }
|
val category = long("category_id").references(ServiceCategories) { it.category }
|
||||||
|
|
||||||
val createdAt = datetime("created_at").bindTo { it.createdAt }
|
val createdAt = datetime("created_at").bindTo { it.createdAt }
|
||||||
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
|
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.service
|
package cc.essaenko.modules.service
|
||||||
|
|
||||||
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
|
||||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
|
||||||
import org.ktorm.database.Database
|
import org.ktorm.database.Database
|
||||||
import org.ktorm.entity.*
|
import org.ktorm.entity.*
|
||||||
@@ -27,19 +28,6 @@ data class ServiceUpdate(
|
|||||||
val categoryId: Long? = null
|
val categoryId: Long? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ServiceView(
|
|
||||||
val id: Long,
|
|
||||||
val title: String,
|
|
||||||
val slug: String,
|
|
||||||
val description: String,
|
|
||||||
val priceFrom: BigDecimal?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val status: String,
|
|
||||||
val categoryId: Long?,
|
|
||||||
val createdAt: LocalDateTime,
|
|
||||||
val updatedAt: LocalDateTime
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ServiceRepository {
|
interface ServiceRepository {
|
||||||
fun listPublic(
|
fun listPublic(
|
||||||
limit: Int = 20,
|
limit: Int = 20,
|
||||||
@@ -48,11 +36,12 @@ interface ServiceRepository {
|
|||||||
categoryId: Long? = null,
|
categoryId: Long? = null,
|
||||||
minPrice: BigDecimal? = null,
|
minPrice: BigDecimal? = null,
|
||||||
maxPrice: BigDecimal? = null
|
maxPrice: BigDecimal? = null
|
||||||
): List<ServiceView>
|
): List<ServiceDTO>
|
||||||
|
|
||||||
fun countPublic(q: String? = null, categoryId: Long? = null, minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null): Int
|
fun countPublic(q: String? = null, categoryId: Long? = null, minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null): Int
|
||||||
|
|
||||||
fun listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null): List<ServiceView>
|
fun listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null): List<ServiceDTO>
|
||||||
|
fun listAll(limit: Int = 50, offset: Int = 0, q: String? = null): List<ServiceDTO>
|
||||||
fun countAdmin(q: String? = null, status: String? = null): Int
|
fun countAdmin(q: String? = null, status: String? = null): Int
|
||||||
|
|
||||||
fun getBySlug(slug: String): ServiceEntity?
|
fun getBySlug(slug: String): ServiceEntity?
|
||||||
@@ -68,22 +57,22 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
|||||||
|
|
||||||
private val services get() = db.sequenceOf(Services)
|
private val services get() = db.sequenceOf(Services)
|
||||||
|
|
||||||
private fun ServiceEntity.toView() = ServiceView(
|
private fun ServiceEntity.toView() = ServiceDTO(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
description = description,
|
description = description,
|
||||||
priceFrom = priceFrom,
|
priceFrom = priceFrom?.toFloat(),
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
status = status,
|
status = status,
|
||||||
categoryId = category?.id,
|
category = category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt
|
updatedAt = updatedAt
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun listPublic(
|
override fun listPublic(
|
||||||
limit: Int, offset: Int, q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?
|
limit: Int, offset: Int, q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?
|
||||||
): List<ServiceView> {
|
): List<ServiceDTO> {
|
||||||
var seq: EntitySequence<ServiceEntity, Services> = services
|
var seq: EntitySequence<ServiceEntity, Services> = services
|
||||||
.filter { it.status eq "PUBLISHED" }
|
.filter { it.status eq "PUBLISHED" }
|
||||||
|
|
||||||
@@ -105,7 +94,6 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun countPublic(q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?): Int {
|
override fun countPublic(q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?): Int {
|
||||||
// Для подсчёта используем DSL, чтобы не тащить сущности
|
|
||||||
var expr = db.from(Services).select(count())
|
var expr = db.from(Services).select(count())
|
||||||
.where { Services.status eq "PUBLISHED" }
|
.where { Services.status eq "PUBLISHED" }
|
||||||
|
|
||||||
@@ -120,7 +108,7 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
|||||||
return expr.totalRecordsInAllPages
|
return expr.totalRecordsInAllPages
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List<ServiceView> {
|
override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List<ServiceDTO> {
|
||||||
var seq: EntitySequence<ServiceEntity, Services> = services
|
var seq: EntitySequence<ServiceEntity, Services> = services
|
||||||
|
|
||||||
if (!q.isNullOrBlank()) {
|
if (!q.isNullOrBlank()) {
|
||||||
@@ -134,8 +122,17 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
|||||||
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
|
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun listAll(limit: Int, offset: Int, q: String?): List<ServiceDTO> {
|
||||||
|
var seq: EntitySequence<ServiceEntity, Services> = services
|
||||||
|
if (!q.isNullOrBlank()) {
|
||||||
|
val like = "%${q.lowercase()}%"
|
||||||
|
seq = seq.filter { (it.title like like) or (it.description like like) }
|
||||||
|
}
|
||||||
|
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
|
||||||
|
}
|
||||||
|
|
||||||
override fun countAdmin(q: String?, status: String?): Int {
|
override fun countAdmin(q: String?, status: String?): Int {
|
||||||
var expr = db.from(Services).select(count())
|
var expr = services.query
|
||||||
if (!q.isNullOrBlank()) {
|
if (!q.isNullOrBlank()) {
|
||||||
val like = "%${q.lowercase()}%"
|
val like = "%${q.lowercase()}%"
|
||||||
expr = expr.where { (Services.title like like) or (Services.description like like) }
|
expr = expr.where { (Services.title like like) or (Services.description like like) }
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ package cc.essaenko.modules.service
|
|||||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryRepository
|
import cc.essaenko.modules.serviceCategory.ServiceCategoryRepository
|
||||||
import cc.essaenko.shared.errors.NotFoundException
|
import cc.essaenko.shared.errors.NotFoundException
|
||||||
import cc.essaenko.shared.errors.ValidationException
|
import cc.essaenko.shared.errors.ValidationException
|
||||||
|
import cc.essaenko.shared.pagination.Page
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
class ServiceService(
|
class ServiceService(
|
||||||
private val repo: ServiceRepository,
|
private val repo: ServiceRepository,
|
||||||
private val categoryRepo: ServiceCategoryRepository
|
private val categoryRepo: ServiceCategoryRepository
|
||||||
) {
|
) {
|
||||||
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
|
|
||||||
|
|
||||||
fun listPublic(
|
fun listPublic(
|
||||||
limit: Int = 20, offset: Int = 0, q: String? = null, categorySlug: String? = null,
|
limit: Int = 20, offset: Int = 0, q: String? = null, categorySlug: String? = null,
|
||||||
minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null
|
minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null
|
||||||
): Page<ServiceView> {
|
): Page<ServiceDTO> {
|
||||||
val catId = categorySlug?.let { categoryRepo.findBySlug(it)?.id }
|
val catId = categorySlug?.let { categoryRepo.findBySlug(it)?.id }
|
||||||
val items = repo.listPublic(limit, offset, q, catId, minPrice, maxPrice)
|
val items = repo.listPublic(limit, offset, q, catId, minPrice, maxPrice)
|
||||||
val total = repo.countPublic(q, catId, minPrice, maxPrice)
|
val total = repo.countPublic(q, catId, minPrice, maxPrice)
|
||||||
|
|||||||
@@ -14,19 +14,30 @@ data class CategoryUpdateRequest(val name: String? = null, val slug: String? = n
|
|||||||
fun Route.publicServiceCategoryRoutes(svc: ServiceCategoryService) = route("/service-categories") {
|
fun Route.publicServiceCategoryRoutes(svc: ServiceCategoryService) = route("/service-categories") {
|
||||||
get {
|
get {
|
||||||
val items = svc.listPublic().map { c ->
|
val items = svc.listPublic().map { c ->
|
||||||
mapOf("id" to c.id, "name" to c.name, "slug" to c.slug)
|
ServiceCategoryDTO(
|
||||||
|
id=c.id,
|
||||||
|
slug = c.slug,
|
||||||
|
name = c.name
|
||||||
|
)
|
||||||
}
|
}
|
||||||
call.respond(items)
|
call.respond(items)
|
||||||
}
|
}
|
||||||
get("{slug}") {
|
get("{slug}") {
|
||||||
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
|
val slug = call.parameters["slug"]
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
mapOf("error" to "slug parameter is required")
|
||||||
|
)
|
||||||
val c = svc.getBySlug(slug)
|
val c = svc.getBySlug(slug)
|
||||||
call.respond(mapOf("id" to c.id, "name" to c.name, "slug" to c.slug))
|
call.respond(ServiceCategoryDTO(
|
||||||
|
id=c.id,
|
||||||
|
slug = c.slug,
|
||||||
|
name = c.name
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Route.adminServiceCategoryRoutes(svc: ServiceCategoryService) = route("/admin/service-categories") {
|
fun Route.adminServiceCategoryRoutes(svc: ServiceCategoryService) = route("/admin/service-categories") {
|
||||||
|
|
||||||
get {
|
get {
|
||||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
|
||||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko.modules.serviceCategory
|
package cc.essaenko.modules.serviceCategory
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.ktorm.entity.Entity
|
import org.ktorm.entity.Entity
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
|
|
||||||
@@ -10,7 +11,14 @@ interface ServiceCategoryEntity : Entity<ServiceCategoryEntity> {
|
|||||||
var slug: String
|
var slug: String
|
||||||
}
|
}
|
||||||
|
|
||||||
object ServiceCategories : Table<ServiceCategoryEntity>("service_category") {
|
@Serializable
|
||||||
|
data class ServiceCategoryDTO(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val slug: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ServiceCategories : Table<ServiceCategoryEntity>("t_service_categories") {
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
val name = varchar("name").bindTo { it.name }
|
val name = varchar("name").bindTo { it.name }
|
||||||
val slug = varchar("slug").bindTo { it.slug }
|
val slug = varchar("slug").bindTo { it.slug }
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ class ServiceCategoryService(private val repo: ServiceCategoryRepository) {
|
|||||||
|
|
||||||
fun listPublic() = repo.listPublic()
|
fun listPublic() = repo.listPublic()
|
||||||
|
|
||||||
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
|
fun listAdmin(limit: Int = 100, offset: Int = 0): List<ServiceCategoryDTO> =
|
||||||
fun listAdmin(limit: Int = 100, offset: Int = 0) =
|
repo.listAdmin(limit, offset).map {
|
||||||
Page(repo.listAdmin(limit, offset), repo.count(), limit, offset)
|
ServiceCategoryDTO(
|
||||||
|
id = it.id,
|
||||||
|
slug = it.slug,
|
||||||
|
name = it.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getBySlug(slug: String) = repo.findBySlug(slug)
|
fun getBySlug(slug: String) = repo.findBySlug(slug)
|
||||||
?: throw NotFoundException("category '$slug' not found")
|
?: throw NotFoundException("category '$slug' not found")
|
||||||
|
|||||||
11
src/main/kotlin/shared/pagination/Page.kt
Normal file
11
src/main/kotlin/shared/pagination/Page.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package cc.essaenko.shared.pagination
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Page<T>(
|
||||||
|
val items: List<T>,
|
||||||
|
val total: Int,
|
||||||
|
val limit: Int,
|
||||||
|
val offset: Int
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user