Add docker
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ build
|
||||
.env
|
||||
.idea
|
||||
.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.default.headers)
|
||||
implementation(libs.ktor.server.cors)
|
||||
implementation(libs.ktor.server.status.pages)
|
||||
implementation(libs.ktor.server.compression)
|
||||
implementation(libs.ktor.server.caching.headers)
|
||||
implementation(libs.ktor.server.netty)
|
||||
@@ -30,6 +31,7 @@ dependencies {
|
||||
implementation(libs.bcrypt)
|
||||
implementation(libs.ktorm)
|
||||
implementation(libs.ktor.server.config.yaml)
|
||||
implementation(libs.psql.support)
|
||||
testImplementation(libs.ktor.server.test.host)
|
||||
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-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-status-pages = { module = "io.ktor:ktor-server-status-pages", 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" }
|
||||
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" }
|
||||
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
|
||||
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"}
|
||||
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ fun main(args: Array<String>) {
|
||||
|
||||
fun Application.module() {
|
||||
val jwtCfg = loadJwtConfig()
|
||||
configureSerialization()
|
||||
configureSecurity(jwtCfg)
|
||||
configureDatabase()
|
||||
configureHTTP()
|
||||
configureSerialization()
|
||||
configureSecurity(jwtCfg)
|
||||
configureRouting(jwtCfg)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package cc.essaenko.app
|
||||
|
||||
import cc.essaenko.shared.errors.NotFoundException
|
||||
import cc.essaenko.shared.errors.ValidationException
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.plugins.cachingheaders.*
|
||||
import io.ktor.server.plugins.compression.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.plugins.defaultheaders.*
|
||||
import io.ktor.server.plugins.statuspages.*
|
||||
import io.ktor.server.plugins.swagger.*
|
||||
import io.ktor.server.request.ContentTransformationException
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Application.configureHTTP() {
|
||||
@@ -19,13 +25,43 @@ fun Application.configureHTTP() {
|
||||
}
|
||||
install(CORS) {
|
||||
allowMethod(HttpMethod.Options)
|
||||
allowMethod(HttpMethod.Post)
|
||||
allowMethod(HttpMethod.Put)
|
||||
allowMethod(HttpMethod.Delete)
|
||||
allowMethod(HttpMethod.Patch)
|
||||
|
||||
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.
|
||||
}
|
||||
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(CachingHeaders) {
|
||||
options { call, outgoingContent ->
|
||||
|
||||
@@ -44,6 +44,7 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
|
||||
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
|
||||
|
||||
routing {
|
||||
route("/api/v1") {
|
||||
publicNewsRoutes(newsSvc)
|
||||
publicLeadRoutes(leadSvc)
|
||||
publicServiceCategoryRoutes(serviceCategorySvc)
|
||||
@@ -59,3 +60,4 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package cc.essaenko.app
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import io.github.cdimascio.dotenv.dotenv
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import io.ktor.server.request.httpMethod
|
||||
|
||||
|
||||
data class JwtConfig(
|
||||
@@ -32,6 +34,7 @@ fun Application.configureSecurity(jwt: JwtConfig) {
|
||||
install(Authentication) {
|
||||
jwt("admin-auth") {
|
||||
realm = jwt.realm
|
||||
skipWhen { call -> call.request.httpMethod == HttpMethod.Options }
|
||||
verifier(
|
||||
JWT
|
||||
.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
|
||||
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.request.*
|
||||
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
|
||||
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)
|
||||
|
||||
@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") {
|
||||
// Логин
|
||||
post("/login") {
|
||||
val body = call.receive<AdminLoginRequest>()
|
||||
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(
|
||||
mapOf(
|
||||
"id" to auth.id,
|
||||
"username" to auth.username,
|
||||
"token" to auth.token,
|
||||
"tokenType" to "Bearer",
|
||||
"expiresInMinutes" to auth.expiresInMinutes
|
||||
"pass" to svc.getPasswordHash(raw)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.adminRoutes(svc: AdminService) = route("/admin") {
|
||||
|
||||
// Список админов (id, username, createdAt, lastLoginAt)
|
||||
get {
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||
call.respond(svc.list(limit, offset))
|
||||
val principal = call.principal<JWTPrincipal>() ?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||
val adminId = principal.subject?.toLongOrNull()
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||
call.respond(svc.current(adminId))
|
||||
}
|
||||
|
||||
// Регистрация нового админа
|
||||
post {
|
||||
val body = call.receive<AdminRegisterRequest>()
|
||||
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") {
|
||||
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>()
|
||||
svc.changePassword(id, body.password)
|
||||
svc.changePassword(id, body.currentPassword, body.newPassword)
|
||||
call.respond(mapOf("updated" to true))
|
||||
}
|
||||
|
||||
// Удаление админа
|
||||
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)
|
||||
call.respond(mapOf("deleted" to true))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.essaenko.modules.admin
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.entity.Entity
|
||||
import org.ktorm.schema.*
|
||||
import java.time.LocalDateTime
|
||||
@@ -13,7 +14,15 @@ interface AdminEntity : Entity<AdminEntity> {
|
||||
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 username = varchar("username").bindTo { it.username }
|
||||
val password = varchar("password_hash").bindTo { it.password }
|
||||
|
||||
@@ -6,10 +6,9 @@ import org.ktorm.entity.*
|
||||
import java.time.LocalDateTime
|
||||
|
||||
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 {
|
||||
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 findByUsername(username: String): AdminEntity?
|
||||
fun create(cmd: AdminCreate): Long
|
||||
@@ -22,9 +21,9 @@ class AdminRepositoryImpl(private val db: Database) : AdminRepository {
|
||||
|
||||
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()
|
||||
.map { AdminView(it.id, it.username, it.createdAt, it.lastLoginAt) }
|
||||
.map { AdminDTO(it.id, it.username, it.createdAt) }
|
||||
|
||||
override fun findById(id: Long): AdminEntity? =
|
||||
admins.firstOrNull { it.id eq id }
|
||||
|
||||
@@ -25,8 +25,16 @@ class AdminService(
|
||||
private val hasher: PasswordHasher,
|
||||
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 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 {
|
||||
require(username.matches(Regex("^[a-zA-Z0-9_.-]{3,}$"))) { "Invalid username" }
|
||||
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)
|
||||
}
|
||||
|
||||
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" }
|
||||
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))
|
||||
if (!ok) throw NotFoundException("Admin not found")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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.response.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class LeadCreateRequest(
|
||||
@@ -12,7 +13,6 @@ data class LeadCreateRequest(
|
||||
val phone: String? = null,
|
||||
)
|
||||
|
||||
/** Публичный эндпоинт формы обратной связи */
|
||||
fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
|
||||
post {
|
||||
val body = call.receive<LeadCreateRequest>()
|
||||
@@ -27,15 +27,28 @@ fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
|
||||
}
|
||||
}
|
||||
|
||||
/** Админские эндпоинты для просмотра/удаления лидов */
|
||||
fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
|
||||
|
||||
get {
|
||||
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 page = svc.list(limit, offset, q)
|
||||
call.respond(page)
|
||||
val data = svc.list(limit, offset, q)
|
||||
call.respond(
|
||||
Page(
|
||||
items = data.items,
|
||||
total = data.total,
|
||||
limit = data.limit,
|
||||
offset = data.offset
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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"))
|
||||
val lead = svc.get(id)
|
||||
call.respond(
|
||||
mapOf(
|
||||
"id" to lead.id,
|
||||
"fullName" to lead.fullName,
|
||||
"email" to lead.email,
|
||||
"phone" to lead.phone,
|
||||
"createdAt" to lead.createdAt
|
||||
LeadDTO(
|
||||
id = lead.id,
|
||||
createdAt = lead.createdAt,
|
||||
email = lead.email,
|
||||
fullName = lead.fullName,
|
||||
phone = lead.phone,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.essaenko.modules.lead
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.entity.Entity
|
||||
import org.ktorm.schema.*
|
||||
import java.time.LocalDateTime
|
||||
@@ -13,7 +14,17 @@ interface LeadEntity : Entity<LeadEntity> {
|
||||
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 fullName = varchar("full_name").bindTo { it.fullName }
|
||||
val email = varchar("email").bindTo { it.email }
|
||||
|
||||
@@ -11,17 +11,10 @@ data class LeadCreate(
|
||||
val phone: String? = null,
|
||||
)
|
||||
|
||||
data class LeadView(
|
||||
val id: Long,
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val phone: String?,
|
||||
)
|
||||
|
||||
interface LeadRepository {
|
||||
fun create(cmd: LeadCreate): Long
|
||||
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 count(q: String? = null): Int
|
||||
}
|
||||
@@ -44,7 +37,7 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
|
||||
override fun getById(id: Long): LeadEntity? =
|
||||
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
|
||||
if (!q.isNullOrBlank()) {
|
||||
val like = "%${q.lowercase()}%"
|
||||
@@ -60,11 +53,12 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
|
||||
.take(limit)
|
||||
.toList()
|
||||
.map {
|
||||
LeadView(
|
||||
LeadDTO(
|
||||
id = it.id,
|
||||
fullName = it.fullName,
|
||||
email = it.email,
|
||||
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.ValidationException
|
||||
import cc.essaenko.shared.pagination.Page
|
||||
|
||||
class LeadService(private val repo: LeadRepository) {
|
||||
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")
|
||||
|
||||
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<LeadView> {
|
||||
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page<LeadDTO> {
|
||||
val items = repo.list(limit, offset, q)
|
||||
val total = repo.count(q)
|
||||
return Page(items, total, limit, offset)
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
package cc.essaenko.modules.news
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
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 {
|
||||
val payload = call.receive<NewsCreate>()
|
||||
val id = svc.create(payload)
|
||||
@@ -12,8 +45,8 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
|
||||
}
|
||||
put("{slug}") {
|
||||
val slug = call.parameters["slug"]!!
|
||||
val body = call.receive<Map<String, String>>()
|
||||
val ok = svc.update(slug, body["summary"].orEmpty(), body["content"].orEmpty())
|
||||
val body = call.receive<NewsUpdateRequest>()
|
||||
val ok = svc.update(slug, body.toDomain())
|
||||
call.respond(mapOf("updated" to ok))
|
||||
}
|
||||
post("{slug}/publish") {
|
||||
@@ -31,16 +64,48 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
|
||||
|
||||
fun Route.publicNewsRoutes(svc: NewsService) = route("/news") {
|
||||
get {
|
||||
val items = svc.list()
|
||||
call.respond(items.map {
|
||||
// можно вернуть Entity напрямую (оно сериализуемо, если поля простые),
|
||||
// но лучше собрать DTO — показываю минимально:
|
||||
mapOf("id" to it.id, "title" to it.title, "slug" to it.slug, "summary" to it.summary)
|
||||
})
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
|
||||
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.list(limit, offset)
|
||||
call.respond(
|
||||
Page(
|
||||
items = pageData.items.map { it.toDto() },
|
||||
total = pageData.total,
|
||||
limit = pageData.limit,
|
||||
offset = pageData.offset
|
||||
)
|
||||
)
|
||||
}
|
||||
get("{slug}") {
|
||||
val slug = call.parameters["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
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.database.Database
|
||||
import org.ktorm.entity.Entity
|
||||
import org.ktorm.entity.sequenceOf
|
||||
@@ -20,6 +21,19 @@ interface News : Entity<News> {
|
||||
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") {
|
||||
val id = long("id").primaryKey().bindTo { it.id }
|
||||
val title = varchar("title").bindTo { it.title }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.essaenko.modules.news
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.database.Database
|
||||
import org.ktorm.dsl.eq
|
||||
import org.ktorm.dsl.lessEq
|
||||
@@ -8,20 +9,34 @@ import java.time.LocalDateTime
|
||||
|
||||
interface NewsRepository {
|
||||
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 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 delete(slug: String): Boolean
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NewsCreate(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val summary: String,
|
||||
val content: String,
|
||||
val status: String = "DRAFT",
|
||||
val imageUrl: String?,
|
||||
val status: String = "draft",
|
||||
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 {
|
||||
@@ -29,13 +44,29 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
||||
|
||||
override fun listPublished(limit: Int, offset: Int): List<News> =
|
||||
news
|
||||
.filter { it.status eq "PUBLISHED" }
|
||||
.filter { it.status eq "published" }
|
||||
.filter { it.publishedAt lessEq LocalDateTime.now() }
|
||||
.sortedByDescending { it.publishedAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
.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? =
|
||||
news.firstOrNull { it.slug eq slug }
|
||||
|
||||
@@ -47,28 +78,38 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
||||
summary = cmd.summary
|
||||
content = cmd.content
|
||||
status = cmd.status
|
||||
publishedAt = if (cmd.status == "PUBLISHED") now else null
|
||||
publishedAt = if (cmd.status == "published") now else null
|
||||
imageUrl = cmd.imageUrl
|
||||
createdAt = now
|
||||
updatedAt = now
|
||||
}
|
||||
// add(...) вернёт количество затронутых строк, ключ читаем через свойство после вставки
|
||||
news.add(entity)
|
||||
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
|
||||
e.summary = summary
|
||||
e.content = content
|
||||
patch.title?.let { e.title = it }
|
||||
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.flushChanges() // применит UPDATE по изменённым полям
|
||||
e.flushChanges()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun publish(slug: String, at: LocalDateTime): Boolean {
|
||||
val e = getBySlug(slug) ?: return false
|
||||
e.status = "PUBLISHED"
|
||||
e.status = "published"
|
||||
e.publishedAt = at
|
||||
e.updatedAt = LocalDateTime.now()
|
||||
e.flushChanges()
|
||||
@@ -78,4 +119,3 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
|
||||
override fun delete(slug: String): Boolean =
|
||||
news.removeIf { it.slug eq slug } > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
package cc.essaenko.modules.news
|
||||
|
||||
import cc.essaenko.shared.errors.NotFoundException
|
||||
import cc.essaenko.shared.pagination.Page
|
||||
|
||||
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 create(cmd: NewsCreate): Long {
|
||||
require(cmd.title.isNotBlank()) { "title is required" }
|
||||
require(cmd.slug.matches(Regex("^[a-z0-9-]{3,}$"))) { "slug invalid" }
|
||||
validateTitle(cmd.title)
|
||||
validateSlug(cmd.slug)
|
||||
return repo.create(cmd)
|
||||
}
|
||||
|
||||
fun update(slug: String, summary: String, content: String) =
|
||||
repo.updateContent(slug, summary, content).also {
|
||||
require(it) { "news '$slug' not found" }
|
||||
fun update(slug: String, patch: NewsUpdate): Boolean {
|
||||
patch.slug?.let { validateSlug(it) }
|
||||
patch.title?.let { validateTitle(it) }
|
||||
val ok = repo.update(slug, patch)
|
||||
require(ok) { "news '$slug' not found" }
|
||||
return ok
|
||||
}
|
||||
|
||||
fun publish(slug: String) =
|
||||
@@ -27,4 +47,12 @@ class NewsService (private val repo: NewsRepository) {
|
||||
repo.delete(slug).also {
|
||||
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
|
||||
|
||||
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.response.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.routing.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -36,41 +38,65 @@ private fun String?.toBigDecOrNull() = this?.let { runCatching { BigDecimal(it)
|
||||
fun Route.publicServiceRoutes(svc: ServiceService) = route("/services") {
|
||||
get {
|
||||
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 categorySlug = call.request.queryParameters["category"]
|
||||
val minPrice = call.request.queryParameters["minPrice"].toBigDecOrNull()
|
||||
val maxPrice = call.request.queryParameters["maxPrice"].toBigDecOrNull()
|
||||
|
||||
val page = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
|
||||
call.respond(page)
|
||||
val res = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
|
||||
call.respond(
|
||||
Page(
|
||||
items = res.items,
|
||||
total = res.total,
|
||||
limit = res.limit,
|
||||
offset = res.offset
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
call.respond(
|
||||
mapOf(
|
||||
"id" to item.id,
|
||||
"title" to item.title,
|
||||
"slug" to item.slug,
|
||||
"description" to item.description,
|
||||
"priceFrom" to item.priceFrom,
|
||||
"imageUrl" to item.imageUrl,
|
||||
"status" to item.status,
|
||||
"categoryId" to item.category?.id,
|
||||
"createdAt" to item.createdAt,
|
||||
"updatedAt" to item.updatedAt
|
||||
ServiceDTO(
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
slug = item.slug,
|
||||
description = item.description,
|
||||
priceFrom = item.priceFrom?.toFloat(),
|
||||
imageUrl = item.imageUrl,
|
||||
status = item.status,
|
||||
createdAt = item.createdAt,
|
||||
updatedAt = item.updatedAt,
|
||||
category = item.category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.adminServiceRoutes(svc: ServiceService) = route("/admin/services") {
|
||||
|
||||
get {
|
||||
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 status = call.request.queryParameters["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.ServiceCategories
|
||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.entity.Entity
|
||||
import org.ktorm.schema.*
|
||||
import java.math.BigDecimal
|
||||
@@ -9,6 +11,7 @@ import java.time.LocalDateTime
|
||||
|
||||
interface ServiceEntity : Entity<ServiceEntity> {
|
||||
companion object : Entity.Factory<ServiceEntity>()
|
||||
|
||||
var id: Long
|
||||
var title: String
|
||||
var slug: String
|
||||
@@ -21,7 +24,23 @@ interface ServiceEntity : Entity<ServiceEntity> {
|
||||
var updatedAt: LocalDateTime
|
||||
}
|
||||
|
||||
object Services : Table<ServiceEntity>("service") {
|
||||
@Serializable
|
||||
data class ServiceDTO(
|
||||
var id: Long,
|
||||
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 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.essaenko.modules.service
|
||||
|
||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
|
||||
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
|
||||
import org.ktorm.database.Database
|
||||
import org.ktorm.entity.*
|
||||
@@ -27,19 +28,6 @@ data class ServiceUpdate(
|
||||
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 {
|
||||
fun listPublic(
|
||||
limit: Int = 20,
|
||||
@@ -48,11 +36,12 @@ interface ServiceRepository {
|
||||
categoryId: Long? = null,
|
||||
minPrice: 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 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 getBySlug(slug: String): ServiceEntity?
|
||||
@@ -68,22 +57,22 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
||||
|
||||
private val services get() = db.sequenceOf(Services)
|
||||
|
||||
private fun ServiceEntity.toView() = ServiceView(
|
||||
private fun ServiceEntity.toView() = ServiceDTO(
|
||||
id = id,
|
||||
title = title,
|
||||
slug = slug,
|
||||
description = description,
|
||||
priceFrom = priceFrom,
|
||||
priceFrom = priceFrom?.toFloat(),
|
||||
imageUrl = imageUrl,
|
||||
status = status,
|
||||
categoryId = category?.id,
|
||||
category = category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
|
||||
override fun listPublic(
|
||||
limit: Int, offset: Int, q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?
|
||||
): List<ServiceView> {
|
||||
): List<ServiceDTO> {
|
||||
var seq: EntitySequence<ServiceEntity, Services> = services
|
||||
.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 {
|
||||
// Для подсчёта используем DSL, чтобы не тащить сущности
|
||||
var expr = db.from(Services).select(count())
|
||||
.where { Services.status eq "PUBLISHED" }
|
||||
|
||||
@@ -120,7 +108,7 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
|
||||
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
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
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 {
|
||||
var expr = db.from(Services).select(count())
|
||||
var expr = services.query
|
||||
if (!q.isNullOrBlank()) {
|
||||
val like = "%${q.lowercase()}%"
|
||||
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.shared.errors.NotFoundException
|
||||
import cc.essaenko.shared.errors.ValidationException
|
||||
import cc.essaenko.shared.pagination.Page
|
||||
import java.math.BigDecimal
|
||||
|
||||
class ServiceService(
|
||||
private val repo: ServiceRepository,
|
||||
private val categoryRepo: ServiceCategoryRepository
|
||||
) {
|
||||
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
|
||||
|
||||
fun listPublic(
|
||||
limit: Int = 20, offset: Int = 0, q: String? = null, categorySlug: String? = null,
|
||||
minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null
|
||||
): Page<ServiceView> {
|
||||
): Page<ServiceDTO> {
|
||||
val catId = categorySlug?.let { categoryRepo.findBySlug(it)?.id }
|
||||
val items = repo.listPublic(limit, offset, 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") {
|
||||
get {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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") {
|
||||
|
||||
get {
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
|
||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.essaenko.modules.serviceCategory
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.ktorm.entity.Entity
|
||||
import org.ktorm.schema.*
|
||||
|
||||
@@ -10,7 +11,14 @@ interface ServiceCategoryEntity : Entity<ServiceCategoryEntity> {
|
||||
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 name = varchar("name").bindTo { it.name }
|
||||
val slug = varchar("slug").bindTo { it.slug }
|
||||
|
||||
@@ -7,9 +7,14 @@ class ServiceCategoryService(private val repo: ServiceCategoryRepository) {
|
||||
|
||||
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) =
|
||||
Page(repo.listAdmin(limit, offset), repo.count(), limit, offset)
|
||||
fun listAdmin(limit: Int = 100, offset: Int = 0): List<ServiceCategoryDTO> =
|
||||
repo.listAdmin(limit, offset).map {
|
||||
ServiceCategoryDTO(
|
||||
id = it.id,
|
||||
slug = it.slug,
|
||||
name = it.name
|
||||
)
|
||||
}
|
||||
|
||||
fun getBySlug(slug: String) = repo.findBySlug(slug)
|
||||
?: 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