Compare commits

..

14 Commits

Author SHA1 Message Date
a406af54bd fix 2026-05-04 15:10:26 +03:00
1ca4c90b88 up 2026-05-04 15:08:03 +03:00
a61c527ef9 up 2026-05-04 13:22:25 +03:00
f39d9ff11e up 2026-05-01 19:11:08 +03:00
c801783779 up, add OLAP columns page 2026-05-01 19:04:18 +03:00
50d4ea10c6 fix 2026-04-29 01:10:35 +03:00
0836f8e9e9 up 2026-04-29 01:03:11 +03:00
e7f135e8c1 up 2026-04-28 19:26:24 +03:00
664092f415 fix 2026-04-28 15:07:14 +03:00
38cc75a688 add Rate Limiter & fix 2026-04-28 15:00:21 +03:00
7a60bb15fe fix 2026-04-27 16:11:54 +03:00
43b57bdb0f up package.json 2026-04-27 15:48:22 +03:00
05076eb367 fix and refactor code 2026-04-27 15:45:06 +03:00
316d06b1d2 updated dependencies 2026-04-27 14:29:59 +03:00
65 changed files with 2374 additions and 666 deletions

View File

@@ -1,2 +1,5 @@
# iiko-connector
* `Числовые``Агрегация`
* `Категории``Группировка {ROW / COLUMN}`
* `Фильтры``Фильтрация`

View File

@@ -37,6 +37,7 @@ dependencies {
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
implementation("io.vertx:vertx-launcher-application")
implementation("io.vertx:vertx-web-client")
implementation("io.vertx:vertx-web-proxy")
implementation("io.vertx:vertx-config")
implementation("io.vertx:vertx-sql-client-templates")
implementation("io.vertx:vertx-health-check")
@@ -48,17 +49,26 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind")
// https://mvnrepository.com/artifact/org.mindrot/jbcrypt
// Source: https://mvnrepository.com/artifact/org.mindrot/jbcrypt
implementation("org.mindrot:jbcrypt:0.4")
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
// Source: https://mvnrepository.com/artifact/org.slf4j/slf4j-api
implementation("org.slf4j:slf4j-api:2.0.17")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
implementation("org.apache.logging.log4j:log4j-core:2.25.4")
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
implementation("org.apache.logging.log4j:log4j-api:2.25.4")
implementation("io.vertx:vertx-jdbc-client")
// Source: https://mvnrepository.com/artifact/com.clickhouse/clickhouse-jdbc
implementation("com.clickhouse:clickhouse-jdbc:0.9.8")
// Source: https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
implementation("com.mysql:mysql-connector-j:9.7.0")
// Source: https://mvnrepository.com/artifact/org.postgresql/postgresql
implementation("org.postgresql:postgresql:42.7.11")
}
java {

View File

@@ -34,11 +34,13 @@ services:
environment:
PMA_HOST: iiko-db
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: DVjXT_kew508
UPLOAD_LIMIT: 10M
PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
TZ: Europe/Moscow
ports:
- "7102:80"
# ports:
# - "7102:80"
iiko-redis:
image: redis:latest
@@ -75,5 +77,9 @@ services:
REDIS__HOST: iiko-redis
REDIS__PORT: 6379
SERVER__PORT: 7104
PMA__ENABLED: true
PMA__BASE_PATH: /phpmyadmin
PMA__UPSTREAM: http://iiko-pma:80/
volumes:
- $PWD/app/logs:/app/logs

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -13,7 +13,7 @@
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-i18n": "^9.14.5",
"vue-i18n": "^11.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -23,7 +23,7 @@
"postcss": "^8.5.9",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^7.3.2",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {

View File

@@ -22,7 +22,7 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useSettingsStore } from './stores/settings'
import { useSettingsStore } from '@/stores/settings'
const settings = useSettingsStore()

View File

@@ -34,7 +34,7 @@
to="/dashboard"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.dashboard') : ''"
@@ -50,7 +50,7 @@
to="/users"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.users') : ''"
@@ -65,7 +65,7 @@
to="/restaurants"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.restaurants') : ''"
@@ -76,12 +76,45 @@
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/olap-columns"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.olapColumns') : ''"
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/database-connections"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/database-connections' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('dbConnections.pageName') : ''"
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('dbConnections.pageName') }}</span>
</router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/settings"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
$route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.settings') : ''"
@@ -92,30 +125,6 @@
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</router-link>
<!-- PhpMyAdmin - только для администраторов -->
<a
v-if="userStore.role === 'admin'"
href="/phpmyadmin"
target="_self"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors no-router-link"
:class="[
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3',
$route.path === '/phpmyadmin' ? 'bg-primary-50 text-primary-700' : 'text-gray-700'
]"
:title="sidebarCollapsed ? t('app.database') : ''"
>
<!-- Иконка базы данных -->
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 7v10c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3V7c0-2-1.5-3-3-3H7c-1.5 0-3 1-3 3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 7c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 12c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.database') }}</span>
</a>
</nav>
<!-- User Info (collapsed aware) -->
@@ -176,6 +185,17 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</router-link>
<a
href="/phpmyadmin/"
v-if="userStore.role === 'admin'"
target="_self"
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
:title="t('app.database')"
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</a>
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
@@ -209,12 +229,12 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { useNotification } from '../../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
const { notification } = useNotification()
const { notification, showNotification } = useNotification()
const settings = useSettingsStore()
const userStore = useUserStore()
const route = useRoute()

View File

@@ -6,6 +6,7 @@
"users": "Users",
"restaurants": "Restaurants",
"settings": "Settings",
"olapColumns": "OLAP Fields",
"profile": "Profile",
"logout": "Logout",
"language": "Language",
@@ -21,7 +22,9 @@
"edit": "Edit",
"add": "Add",
"reset": "Reset",
"loading": "Loading..."
"loading": "Loading...",
"all": "all",
"confirm": "Confirm"
},
"common": {
"id": "ID",
@@ -50,7 +53,7 @@
},
"dashboard": {
"totalUsers": "Total Users",
"activeSessions": "Active Sessions",
"totalRestaurants": "Total Restaurants",
"systemHealth": "System Health",
"uptime": "Uptime",
"vsLastMonth": "vs last month",
@@ -193,5 +196,69 @@
"minLength": "Must be at least {min} characters",
"email": "Please enter a valid email address",
"passwordMismatch": "Passwords do not match"
},
"olap": {
"columnsTitle": "OLAP Reports Structure",
"initialize": "Initialize",
"filterFieldKey": "Field key",
"filterFieldKeyPlaceholder": "search by key...",
"filterReportType": "Report type",
"filterTag": "Tag",
"fieldKey": "Field (key)",
"reportTypes": "Report types",
"type": "Type",
"tags": "Tags",
"aggregation": "Aggregation",
"grouping": "Grouping",
"filtering": "Filtering",
"noColumnsFound": "No fields match the filters",
"selectRestaurant": "Select a restaurant to load the structure",
"loadError": "Error loading report structure",
"initSuccess": "Structure initialized successfully",
"initError": "Initialization error: {error}",
"selectRestaurantFirst": "Please select a restaurant",
"refreshStructure": "Refresh structure",
"refreshWarningTitle": "Full structure replacement",
"refreshWarningMessage": "You selected restaurant «{restaurant}». All existing OLAP fields data will be permanently deleted and replaced with data from this restaurant.",
"refreshWarningConfirm": "This action is irreversible. Continue?",
"searchRestaurant": "Search restaurant...",
"noRestaurantsFound": "No restaurants found",
"initializingData": "Initializing OLAP fields structure",
"refreshingData": "Refreshing OLAP fields structure",
"waitMessage": "Please wait. This operation may take a while...",
"editField": "Edit Field",
"displayType": "Display Type",
"updateSuccess": "Field updated successfully",
"updateError": "Error updating field",
"deleteSuccess": "Field deleted successfully",
"deleteError": "Error deleting field",
"deleteField": "Delete Field",
"deleteFieldConfirm": "Are you sure you want to delete this field? This action cannot be undone."
},
"dbConnections": {
"pageName": "Databases",
"add": "Add Connection",
"edit": "Edit Connection",
"delete": "Delete Connection",
"deleteConfirmation": "Are you sure you want to delete this database connection? This action cannot be undone.",
"type": "Type",
"host": "Host",
"port": "Port",
"database": "Database",
"user": "User",
"test": "Test connection",
"noConnections": "No database connections found. Click 'Add Connection' to create one.",
"loadError": "Failed to load database connections.",
"testSuccess": "Connection successful! Latency: {latency} ms",
"testError": "Connection failed: {error}",
"testNetworkError": "Network error while testing connection: {error}",
"testUnknownError": "Unknown error",
"passwordRequired": "Password is required for new connection.",
"createSuccess": "Database connection created successfully.",
"updateSuccess": "Database connection updated successfully.",
"createError": "Failed to create database connection.",
"updateError": "Failed to update database connection.",
"deleteSuccess": "Database connection deleted successfully.",
"deleteError": "Failed to delete database connection."
}
}

View File

@@ -2,10 +2,11 @@
"app": {
"title": "Панель администратора",
"dashboard": "Панель управления",
"database": "База Данных",
"database": "База данных",
"users": "Пользователи",
"restaurants": "Рестораны",
"settings": "Настройки",
"olapColumns": "OLAP поля",
"profile": "Профиль",
"logout": "Выйти",
"language": "Язык",
@@ -21,7 +22,9 @@
"edit": "Редактировать",
"add": "Добавить",
"reset": "Сбросить",
"loading": "Загрузка..."
"loading": "Загрузка...",
"all": "все",
"confirm": "Подтвердить"
},
"common": {
"id": "ID",
@@ -50,7 +53,7 @@
},
"dashboard": {
"totalUsers": "Всего пользователей",
"activeSessions": "Активных сессий",
"totalRestaurants": "Всего ресторанов",
"systemHealth": "Здоровье системы",
"uptime": "Время работы",
"vsLastMonth": "по сравнению с прошлым месяцем",
@@ -193,5 +196,69 @@
"minLength": "Должно быть не менее {min} символов",
"email": "Введите корректный email адрес",
"passwordMismatch": "Пароли не совпадают"
},
"olap": {
"columnsTitle": "Структура OLAP-отчётов",
"initialize": "Инициализировать",
"filterFieldKey": "Ключ поля",
"filterFieldKeyPlaceholder": "поиск по ключу...",
"filterReportType": "Тип отчёта",
"filterTag": "Тег",
"fieldKey": "Поле (ключ)",
"reportTypes": "Типы отчётов",
"type": "Тип",
"tags": "Теги",
"aggregation": "Агрегация",
"grouping": "Группировка",
"filtering": "Фильтрация",
"noColumnsFound": "Нет полей, соответствующих фильтрам",
"selectRestaurant": "Выберите ресторан для загрузки структуры",
"loadError": "Ошибка загрузки структуры отчётов",
"initSuccess": "Структура успешно инициализирована",
"initError": "Ошибка инициализации: {error}",
"selectRestaurantFirst": "Пожалуйста, выберите ресторан",
"refreshStructure": "Обновить структуру",
"refreshWarningTitle": "Полная замена структуры",
"refreshWarningMessage": "Вы выбрали ресторан «{restaurant}». Все текущие данные о полях OLAP-отчётов будут полностью удалены и заменены данными из этого ресторана.",
"refreshWarningConfirm": "Это действие необратимо. Продолжить?",
"searchRestaurant": "Поиск ресторана...",
"noRestaurantsFound": "Рестораны не найдены",
"initializingData": "Инициализация структуры OLAP-полей",
"refreshingData": "Обновление структуры OLAP-полей",
"waitMessage": "Пожалуйста, подождите. Операция может занять некоторое время...",
"editField": "Редактирование поля",
"displayType": "Тип отображения",
"updateSuccess": "Поле успешно обновлено",
"updateError": "Ошибка при обновлении поля",
"deleteSuccess": "Поле успешно удалено",
"deleteError": "Ошибка при удалении поля",
"deleteField": "Удаление поля",
"deleteFieldConfirm": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
},
"dbConnections": {
"pageName": "Базы данных",
"add": "Добавить подключение",
"edit": "Редактировать подключение",
"delete": "Удалить подключение",
"deleteConfirmation": "Вы уверены, что хотите удалить это подключение к базе данных? Действие необратимо.",
"type": "Тип",
"host": "Хост",
"port": "Порт",
"database": "База данных",
"user": "Пользователь",
"test": "Проверить подключение",
"noConnections": "Подключения к базам данных не найдены. Нажмите «Добавить подключение», чтобы создать.",
"loadError": "Не удалось загрузить список подключений.",
"testSuccess": "Подключение успешно! Задержка: {latency} мс",
"testError": "Ошибка подключения: {error}",
"testNetworkError": "Сетевая ошибка при проверке подключения: {error}",
"testUnknownError": "Неизвестная ошибка",
"passwordRequired": "Пароль обязателен для нового подключения.",
"createSuccess": "Подключение к БД успешно создано.",
"updateSuccess": "Подключение к БД успешно обновлено.",
"createError": "Не удалось создать подключение к БД.",
"updateError": "Не удалось обновить подключение к БД.",
"deleteSuccess": "Подключение к БД успешно удалено.",
"deleteError": "Не удалось удалить подключение к БД."
}
}

View File

@@ -3,12 +3,12 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
import '@/style.css'
import { useSettingsStore } from './stores/settings'
import { useUserStore } from './stores/user'
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import ru from './locales/ru.json'
import en from '@/locales/en.json'
import ru from '@/locales/ru.json'
// Функция определения языка браузера
function getBrowserLocale(): string {

View File

@@ -1,27 +1,79 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useSettingsStore } from '../stores/settings'
import Login from '../views/auth/Login.vue'
import Setup from '../views/auth/Setup.vue'
import Register from '../views/auth/Register.vue'
import Dashboard from '../views/Dashboard.vue'
import Users from '../views/Users.vue'
import Restaurants from '../views/Restaurants.vue'
import AdminSettings from '../views/AdminSettings.vue'
import Profile from '../views/Profile.vue'
import NotFound from '../views/NotFound.vue'
import { useUserStore } from '@/stores/user'
import { useSettingsStore } from '@/stores/settings'
import Login from '@/views/auth/Login.vue'
import Setup from '@/views/auth/Setup.vue'
import Register from '@/views/auth/Register.vue'
import Dashboard from '@/views/Dashboard.vue'
import Users from '@/views/Users.vue'
import Restaurants from '@/views/Restaurants.vue'
import OlapColumnsView from '@/views/OlapColumnsView.vue'
import DBConnections from '@/views/DBConnections.vue'
import AdminSettings from '@/views/AdminSettings.vue'
import Profile from '@/views/Profile.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } },
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } },
{ path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } },
{ path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } },
{ path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
{
path: '/login',
component: Login,
meta: { title: 'Login', requiresAuth: false }
},
{
path: '/register',
component: Register,
meta: { title: 'Register', requiresAuth: false }
},
{
path: '/setup',
component: Setup,
meta: { title: 'Setup', requiresAuth: false }
},
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/users',
component: Users,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
},
{
path: '/restaurants',
component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' }
},
{
path: '/olap-columns',
component: OlapColumnsView,
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
},
{
path: '/database-connections',
component: DBConnections,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
},
{
path: '/settings',
component: AdminSettings,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
},
{
path: '/profile',
component: Profile,
meta: { requiresAuth: true, title: 'Profile' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: 'Page Not Found', requiresAuth: false }
}
]
const router = createRouter({ history: createWebHistory(), routes })

View File

@@ -10,7 +10,7 @@ export const useUserStore = defineStore('user', () => {
async function fetchProfile() {
try {
const res = await fetch('/api/admin/profile')
const res = await fetch('/api/profile')
if (res.ok) {
const data = await res.json()
id.value = data.id
@@ -27,7 +27,7 @@ export const useUserStore = defineStore('user', () => {
}
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
const res = await fetch('/api/admin/profile', {
const res = await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)

View File

@@ -58,22 +58,18 @@
<button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
</div>
</form>
<div v-if="message" class="mt-4 p-3 rounded-lg" :class="messageClass">
{{ message }}
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
import { useNotification } from '../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
const { showNotification } = useNotification()
const { t, locale } = useI18n()
const { t } = useI18n()
interface FieldMeta {
key: string;
label: string;
@@ -86,11 +82,9 @@ interface FieldMeta {
const meta = ref<FieldMeta[]>([]);
const values = ref<Record<string, string>>({});
const message = ref('');
const messageClass = ref('');
async function loadMeta() {
const res = await fetch('/api/settings/meta');
const res = await fetch('/api/admin/settings/meta');
if (res.ok) {
meta.value = await res.json();
} else {
@@ -99,7 +93,7 @@ async function loadMeta() {
}
async function loadValues() {
const res = await fetch('/api/settings/all');
const res = await fetch('/api/admin/settings');
if (res.ok) {
values.value = await res.json();
} else {
@@ -128,13 +122,5 @@ async function saveSettings() {
}
}
function showMessage(text: string, cssClass: string) {
message.value = text;
messageClass.value = cssClass;
setTimeout(() => {
message.value = '';
}, 3000);
}
onMounted(loadData);
</script>

View File

@@ -0,0 +1,384 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('dbConnections.add') }}
</button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
{{ getTypeLabel(conn.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3">
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
{{ conn.testResult }}
</span>
</div>
</td>
</tr>
<tr v-if="connections.length === 0">
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно создания/редактирования -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
<input v-model="form.name" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
<select v-model="form.type" required class="input-field">
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="clickhouse">ClickHouse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
<input v-model="form.user" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модальное окно подтверждения удаления -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
type Connection = {
id: number;
name: string;
type: 'mysql' | 'postgres' | 'clickhouse';
host: string;
port: number;
database: string;
user: string;
created: string;
testing?: boolean;
testResult?: string | null;
};
const connections = ref<Connection[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({
id: null as number | null,
name: '',
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
host: '',
port: 3306,
database: '',
user: '',
password: ''
});
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null as number | null });
// Загрузка списка подключений
async function loadConnections() {
try {
const res = await fetch('/api/admin/database-connections');
if (!res.ok) throw new Error();
const data = await res.json();
connections.value = data.map((c: any) => ({
...c,
testing: false,
testResult: null
}));
} catch (e) {
showNotification('dbConnections.loadError', 'error');
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// Тестирование соединения
async function testConnection(conn: Connection) {
conn.testing = true;
conn.testResult = null;
try {
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
conn.testResult = `${data.latency_ms} ms`;
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
} else {
const errorText = data.error || t('dbConnections.testUnknownError');
showNotification('dbConnections.testError', 'error', { error: errorText });
}
} catch (error: any) {
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
} finally {
conn.testing = false;
}
}
// Вспомогательные функции для отображения типа
function getTypeLabel(type: string) {
const labels: Record<string, string> = {
mysql: 'MySQL',
postgres: 'PostgreSQL',
clickhouse: 'ClickHouse'
};
return labels[type] || type;
}
function getTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
mysql: 'bg-blue-100 text-blue-800',
postgres: 'bg-indigo-100 text-indigo-800',
clickhouse: 'bg-amber-100 text-amber-800'
};
return classes[type] || 'bg-gray-100 text-gray-800';
}
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = {
id: null,
name: '',
type: 'mysql',
host: '',
port: 3306,
database: '',
user: '',
password: ''
};
modalTitle.value = t('dbConnections.add');
} else if (conn) {
form.value = {
id: conn.id,
name: conn.name,
type: conn.type,
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.user,
password: ''
};
modalTitle.value = t('dbConnections.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitConnection() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification('dbConnections.passwordRequired', 'error');
return;
}
try {
const payload: any = {
name: form.value.name,
type: form.value.type,
host: form.value.host,
port: form.value.port,
database: form.value.database,
user: form.value.user,
};
if (form.value.password) {
payload.password = form.value.password;
}
if (modalMode.value === 'create') {
const res = await fetch('/api/admin/database-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.createSuccess', 'success');
} else {
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.updateSuccess', 'success');
}
await loadConnections();
closeModal();
} catch (e) {
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
}
}
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteConnection(id: number) {
try {
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('dbConnections.deleteSuccess', 'success');
await loadConnections();
} catch (e) {
showNotification('dbConnections.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
}
onMounted(loadConnections);
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -23,8 +23,8 @@
<div class="card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.activeSessions') }}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p>
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalRestaurants') }}</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalRestaurants }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -175,13 +175,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import { useNotification } from '../composables/useNotification'
import { useNotification } from '@/composables/useNotification'
const { showNotification } = useNotification()
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
const stats = ref({ totalUsers: 0, totalRestaurants: 0, systemHealth: 100, uptime: '99.9%' });
const userGrowth = ref(12);
const sessionGrowth = ref(5);
const recentUsers = ref([]);
@@ -195,20 +195,18 @@ let interval: number;
async function loadDashboardData() {
try {
const [usersRes, sessionsRes, healthRes, restaurantsRes] = await Promise.all([
const [usersRes, healthRes, restaurantsRes] = await Promise.all([
fetch('/api/admin/users'),
fetch('/api/admin/active-sessions'),
fetch('/api/health'),
fetch('/api/admin/restaurants')
]);
const users = await usersRes.json();
const sessions = await sessionsRes.json();
const health = await healthRes.json();
const restaurants = await restaurantsRes.json();
stats.value.totalUsers = users.length;
stats.value.activeSessions = sessions.count || 0;
stats.value.totalRestaurants = restaurants.length;
recentUsers.value = users.slice(-5).reverse();
recentRestaurants.value = restaurants.slice(-5).reverse();
@@ -242,7 +240,7 @@ function formatDate(dateStr: string) {
onMounted(() => {
loadDashboardData();
interval = window.setInterval(loadDashboardData, 30000);
interval = window.setInterval(loadDashboardData, 10000);
});
onUnmounted(() => {

View File

@@ -0,0 +1,641 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
<h1 class="text-2xl font-bold text-gray-900">{{ t('olap.columnsTitle') }}</h1>
<div class="flex gap-3">
<button
v-if="hasData && !loading && !initializing"
@click="openRefreshModal"
class="btn-secondary flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olap.refreshStructure') }}
</button>
<button
v-if="!hasData && !loading && !initializing"
@click="openInitModal"
class="btn-primary flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olap.initialize') }}
</button>
</div>
</div>
<!-- Фильтры -->
<div v-if="hasData" class="card mb-6 p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterFieldKey') }}</label>
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olap.filterFieldKeyPlaceholder')" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterReportType') }}</label>
<select v-model="filters.reportType" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="rt in availableReportTypes" :key="rt" :value="rt">{{ rt }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterTag') }}</label>
<select v-model="filters.tag" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
</div>
<div class="flex items-end">
<button @click="resetFilters" class="btn-secondary w-full">{{ t('app.reset') }}</button>
</div>
</div>
</div>
<!-- Таблица -->
<div v-if="loading" class="card p-8 text-center">
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-2 text-gray-500">{{ t('app.loading') }}</p>
</div>
<div v-else-if="hasData && filteredColumns.length === 0" class="card p-8 text-center text-gray-500">
{{ t('olap.noColumnsFound') }}
</div>
<div v-else-if="hasData" class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.fieldKey') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.reportTypes') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.tags') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.aggregation') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.grouping') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.filtering') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="col in filteredColumns" :key="col.fieldKey" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-mono text-gray-900">{{ col.fieldKey }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ col.name }}</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="rt in col.reportTypes" :key="rt" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
{{ rt }}
</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
{{ col.typeNormal || col.type }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="tag in col.tags" :key="tag" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{{ tag }}
</span>
<span v-if="!col.tags || col.tags.length === 0" class="text-gray-400"></span>
</div>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.aggregationAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.groupingAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.filteringAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<!-- <button @click="openEditModal(col)" class="text-blue-600 hover:text-blue-800 transition-colors" :title="t('app.edit')">-->
<!-- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">-->
<!-- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />-->
<!-- </svg>-->
<!-- </button>-->
<button @click="openDeleteFieldModal(col.fieldKey)" class="text-red-600 hover:text-red-800 transition-colors" :title="t('app.delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модалка выбора ресторана (для инициализации и обновления) -->
<Transition name="fade">
<div v-if="initModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeInitModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
<!-- Заголовок -->
<div class="flex justify-between items-center p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">{{ initModalTitle }}</h3>
<button @click="closeInitModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Поле поиска -->
<div class="p-4 border-b">
<div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="restaurantSearch"
type="text"
class="input-field pl-9"
:placeholder="t('olap.searchRestaurant')"
/>
</div>
</div>
<!-- Список ресторанов (скролл) -->
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<div
v-for="rest in filteredRestaurants"
:key="rest.id"
@click="selectedRestaurantId = rest.id"
class="flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-150 hover:shadow-md"
:class="[
selectedRestaurantId === rest.id
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300'
]"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900">{{ rest.name }}</p>
<p class="text-sm text-gray-500">{{ rest.host }}</p>
</div>
</div>
<div v-if="selectedRestaurantId === rest.id" class="text-primary-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div v-if="filteredRestaurants.length === 0" class="text-center py-8 text-gray-500">
{{ t('olap.noRestaurantsFound') }}
</div>
</div>
<!-- Кнопки действий -->
<div class="flex justify-end space-x-3 p-6 border-t bg-gray-50 rounded-b-2xl">
<button @click="closeInitModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button
@click="onInitConfirm"
:disabled="!selectedRestaurantId"
class="btn-primary"
>
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка предупреждения перед обновлением (после выбора ресторана) -->
<Transition name="fade">
<div v-if="refreshWarningModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="refreshWarningModal.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olap.refreshWarningTitle') }}</h3>
<p class="text-sm text-gray-500 mb-4">
{{ t('olap.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
</p>
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olap.refreshWarningConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="refreshWarningModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="executeInitialize" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка редактирования поля -->
<Transition name="fade">
<div v-if="editModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeEditModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ t('olap.editField') }}</h2>
<button @click="closeEditModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="updateField" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }}</label>
<input v-model="editForm.name" type="text" class="input-field" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.displayType') }}</label>
<select v-model="editForm.typeNormal" class="input-field">
<option value="string">string</option>
<option value="integer">integer</option>
<option value="decimal">decimal</option>
<option value="datetime">datetime</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.aggregationAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.aggregation') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.groupingAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.grouping') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.filteringAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.filtering') }}</label>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeEditModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модалка подтверждения удаления поля -->
<Transition name="fade">
<div v-if="deleteFieldConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteFieldConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olap.deleteField') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olap.deleteFieldConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteFieldConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="confirmDeleteField" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="initializing" class="fixed inset-0 z-60 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center gap-4 max-w-sm w-full mx-4">
<svg class="animate-spin h-12 w-12 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-700 font-medium">
{{ initializingText }}
</p>
<p class="text-sm text-gray-500 text-center">
{{ t('olap.waitMessage') }}
</p>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
interface Column {
fieldKey: string;
fieldKeyNormal: string;
reportTypes: string[];
name: string;
type: string;
typeNormal: string;
aggregationAllowed: boolean;
groupingAllowed: boolean;
filteringAllowed: boolean;
tags: string[];
}
interface Restaurant {
id: number;
name: string;
host: string;
}
const columns = ref<Column[]>([]);
const loading = ref(false);
const initializing = ref(false);
const initModalOpen = ref(false);
const initModalTitle = ref('');
const restaurants = ref<Restaurant[]>([]);
const selectedRestaurantId = ref<number | null>(null);
const pendingRestaurantId = ref<number | null>(null);
const pendingRestaurantName = ref('');
const refreshWarningModal = ref({ show: false });
const initializingText = ref('');
const filters = ref({
fieldKey: '',
reportType: '',
tag: ''
});
const editModalOpen = ref(false);
const editForm = ref({
fieldKey: '',
name: '',
typeNormal: '',
aggregationAllowed: false,
groupingAllowed: false,
filteringAllowed: false
});
const deleteFieldConfirm = ref({ show: false, fieldKey: '' });
const hasData = computed(() => columns.value.length > 0);
const availableReportTypes = computed(() => {
const types = new Set<string>();
for (const col of columns.value) {
for (const rt of col.reportTypes) {
types.add(rt);
}
}
return Array.from(types).sort();
});
const availableTags = computed(() => {
const tags = new Set<string>();
for (const col of columns.value) {
for (const tag of col.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
const filteredColumns = computed(() => {
let result = columns.value;
if (filters.value.fieldKey) {
const lowerKey = filters.value.fieldKey.toLowerCase();
result = result.filter(col => col.fieldKey.toLowerCase().includes(lowerKey));
}
if (filters.value.reportType) {
result = result.filter(col => col.reportTypes.includes(filters.value.reportType));
}
if (filters.value.tag) {
result = result.filter(col => col.tags.includes(filters.value.tag));
}
return result;
});
async function loadColumns() {
loading.value = true;
try {
const res = await fetch('/api/reports/olap/columns');
if (!res.ok) {
if (res.status === 404 || res.status === 204) {
columns.value = [];
} else {
throw new Error(`HTTP ${res.status}`);
}
} else {
const data = await res.json();
columns.value = data.columns || [];
}
} catch (error) {
console.error(error);
showNotification('olap.loadError', 'error');
columns.value = [];
} finally {
loading.value = false;
}
}
async function loadRestaurants() {
try {
const res = await fetch('/api/admin/restaurants');
if (res.ok) {
const data = await res.json();
restaurants.value = data;
} else {
showNotification('restaurants.loadError', 'error');
}
} catch (error) {
showNotification('restaurants.loadError', 'error');
}
}
const restaurantSearch = ref('');
const filteredRestaurants = computed(() => {
if (!restaurantSearch.value) return restaurants.value;
const lower = restaurantSearch.value.toLowerCase();
return restaurants.value.filter(r =>
r.name.toLowerCase().includes(lower) ||
r.host.toLowerCase().includes(lower)
);
});
function openInitModal() {
initModalTitle.value = t('olap.selectRestaurant');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function openRefreshModal() {
initModalTitle.value = t('olap.refreshStructure');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function closeInitModal() {
initModalOpen.value = false;
selectedRestaurantId.value = null;
}
function onInitConfirm() {
if (!selectedRestaurantId.value) return;
const selectedRest = restaurants.value.find(r => r.id === selectedRestaurantId.value);
pendingRestaurantName.value = selectedRest ? selectedRest.name : '';
pendingRestaurantId.value = selectedRestaurantId.value;
if (hasData.value) {
closeInitModal();
refreshWarningModal.value.show = true;
} else {
executeInitialize();
}
}
async function executeInitialize() {
const id = pendingRestaurantId.value ?? selectedRestaurantId.value;
if (!id) {
showNotification('olap.selectRestaurantFirst', 'error');
return;
}
initModalOpen.value = false;
refreshWarningModal.value.show = false;
editModalOpen.value = false;
deleteFieldConfirm.value.show = false;
initializingText.value = hasData.value ? t('olap.refreshingData') : t('olap.initializingData');
initializing.value = true;
try {
const res = await fetch('/api/reports/olap/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restaurantId: id })
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || `HTTP ${res.status}`);
}
showNotification('olap.initSuccess', 'success');
await loadColumns();
} catch (error: any) {
showNotification('olap.initError', 'error', { error: error.message });
} finally {
initializing.value = false;
initializingText.value = '';
pendingRestaurantId.value = null;
pendingRestaurantName.value = '';
selectedRestaurantId.value = null;
}
}
function openEditModal(col: Column) {
editForm.value = {
fieldKey: col.fieldKey,
name: col.name,
typeNormal: col.typeNormal,
aggregationAllowed: col.aggregationAllowed,
groupingAllowed: col.groupingAllowed,
filteringAllowed: col.filteringAllowed
};
editModalOpen.value = true;
}
function closeEditModal() {
editModalOpen.value = false;
}
async function updateField() {
try {
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(editForm.value.fieldKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editForm.value.name,
typeNormal: editForm.value.typeNormal,
aggregationAllowed: editForm.value.aggregationAllowed,
groupingAllowed: editForm.value.groupingAllowed,
filteringAllowed: editForm.value.filteringAllowed
})
});
if (!res.ok) throw new Error();
showNotification('olap.updateSuccess', 'success');
closeEditModal();
await loadColumns();
} catch (error) {
showNotification('olap.updateError', 'error');
}
}
function openDeleteFieldModal(fieldKey: string) {
deleteFieldConfirm.value = { show: true, fieldKey };
}
async function confirmDeleteField() {
const fieldKey = deleteFieldConfirm.value.fieldKey;
if (!fieldKey) return;
try {
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(fieldKey)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error();
showNotification('olap.deleteSuccess', 'success');
deleteFieldConfirm.value.show = false;
await loadColumns();
} catch (error) {
showNotification('olap.deleteError', 'error');
}
}
function resetFilters() {
filters.value = { fieldKey: '', reportType: '', tag: '' };
}
onMounted(() => {
loadColumns();
});
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.z-60 {
z-index: 60;
}
</style>

View File

@@ -84,10 +84,12 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useUserStore } from '../stores/user';
import { useUserStore } from '@/stores/user';
import { useI18n } from 'vue-i18n';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import {useNotification} from "@/composables/useNotification";
const { showNotification } = useNotification();
const userStore = useUserStore();
const { t, locale } = useI18n();
@@ -129,7 +131,7 @@ async function saveProfile() {
if (ok) {
locale.value = form.language;
showNotification('profile.updateSuccess', 'success');
resetForm(); // очищаем поля пароля
resetForm();
} else {
showNotification('profile.updateError', 'error');
}

View File

@@ -153,9 +153,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();

View File

@@ -36,7 +36,7 @@
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="user.id === currentUserId" class="text-xs text-gray-500">{{ t('users.you') }}</div>
<div v-if="user.id === userStore.id" class="text-xs text-gray-500">{{ t('users.you') }}</div>
<label v-else class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
@@ -57,7 +57,7 @@
</svg>
</button>
<button
v-if="user.id !== currentUserId"
v-if="user.id !== userStore.id"
@click="confirmDelete(user.id)"
class="text-red-600 hover:text-red-800 transition-colors"
>
@@ -155,16 +155,15 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue';
import { useUserStore } from '../stores/user';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useUserStore } from '@/stores/user';
import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
const userStore = useUserStore();
const currentUserId = ref<number | null>(null);
const users = ref<any[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
@@ -173,23 +172,9 @@ const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null });
const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
return modalMode.value === 'edit' && form.value.id === userStore.id;
});
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
} else {
showNotification('users.loadCurrentError', 'error');
}
} catch (e) {
showNotification('common.networkError', 'error');
}
}
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
@@ -297,7 +282,6 @@ async function deleteUser(id: number) {
}
onMounted(async () => {
await loadCurrentUser();
await loadUsers();
});
</script>

View File

@@ -103,8 +103,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
const settings = useSettingsStore()

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules"]
}

View File

@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src',
},
},
server: {
proxy: {
'/api': 'http://localhost:8080' // для разработки

Binary file not shown.

Binary file not shown.

BIN
libs/asm-9.7.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/client-v2-0.9.8.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/commons-io-2.20.0.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/guava-33.4.6-jre.jar Normal file

Binary file not shown.

BIN
libs/httpclient5-5.4.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-5.3.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-h2-5.3.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/jdbc-v2-0.9.8.jar Normal file

Binary file not shown.

BIN
libs/lz4-java-1.10.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/postgresql-42.7.11.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -6,25 +6,29 @@ import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.handler.SecurityHandler;
import su.xserver.iikocon.handler.SetupHandler;
import su.xserver.iikocon.handler.*;
import su.xserver.iikocon.iiko.IikoHandler;
import su.xserver.iikocon.iiko.IikoOlapClient;
import su.xserver.iikocon.service.*;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
@@ -36,13 +40,18 @@ public class MainVerticle extends AbstractVerticle {
private RedisService redis;
private HttpServer httpServer;
private AppConfig config;
private SessionStore sessionStore;
private UserService userService;
private RestaurantService restaurantService;
private ExternalDataBaseService externalDataBaseService;
private SettingsService settingsService;
@Override
public void start(Promise<Void> startPromise) {
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("org.postgresql.Driver");
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
.setType("file")
@@ -62,12 +71,11 @@ public class MainVerticle extends AbstractVerticle {
db = new DataBaseService(vertx, config.database);
redis = new RedisService(vertx, config.redis);
// Инициализация сервисов
userService = new UserService(db.getPool());
restaurantService = new RestaurantService(db.getPool());
settingsService = new SettingsService(db.getPool());
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
// Инициализация БД (создание таблицы users)
userService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
@@ -80,6 +88,10 @@ public class MainVerticle extends AbstractVerticle {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
externalDataBaseService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
createRouterAndStartHttp(startPromise);
@@ -99,7 +111,7 @@ public class MainVerticle extends AbstractVerticle {
}
long timeoutMs = timeoutMinutes * 60 * 1000;
SessionStore sessionStore = LocalSessionStore.create(vertx);
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
@@ -116,15 +128,110 @@ public class MainVerticle extends AbstractVerticle {
});
}
private void setupPhpmyadminProxy(Router router) {
if (config.pma == null || !config.pma.enabled) return;
String upstream = config.pma.upstream;
String basePath = config.pma.basePath;
final URI upstreamUri = URI.create(upstream);
final String host = upstreamUri.getHost();
int portTmp = upstreamUri.getPort();
if (portTmp == -1) {
portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80;
}
final int port = portTmp;
final WebClient webClient = WebClient.create(vertx);
router.route(basePath + "/*").handler(ctx -> {
if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) {
ctx.next();
} else {
ctx.response().putHeader("Location", "/").setStatusCode(302).end();
}
});
router.route(basePath + "/*").handler(ctx -> {
String targetPathBase = ctx.request().path().substring(basePath.length());
if (targetPathBase.isEmpty()) targetPathBase = "/";
String targetPath = targetPathBase;
String query = ctx.request().query();
if (query != null && !query.isEmpty()) {
targetPath += "?" + query;
}
final String targetPathFinal = targetPath;
final HttpRequest<Buffer> proxyReq = webClient.request(
ctx.request().method(), port, host, targetPathFinal
);
ctx.request().headers().forEach(header -> {
if (!"host".equalsIgnoreCase(header.getKey())) {
proxyReq.putHeader(header.getKey(), header.getValue());
}
});
proxyReq.putHeader("Host", host + ":" + port);
ctx.request().bodyHandler(body -> {
if (body != null && body.length() > 0) {
proxyReq.sendBuffer(body)
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
} else {
proxyReq.send()
.onSuccess(resp -> sendResponse(ctx, resp))
.onFailure(err -> sendError(ctx, err));
}
});
});
}
private void sendResponse(RoutingContext ctx, HttpResponse<Buffer> resp) {
ctx.response().setStatusCode(resp.statusCode());
resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue()));
ctx.response().end(resp.body());
}
private void sendError(RoutingContext ctx, Throwable err) {
log.error("Proxy error: {}", err.getMessage());
ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage());
}
private Router initRouter(SessionHandler sessionHandler) {
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create());
router.route().handler(ctx -> {
long start = System.currentTimeMillis();
String method = ctx.request().method().name();
String path = ctx.request().path();
final String remoteIp = ctx.get("realClientIp") != null ?
ctx.get("realClientIp") :
ctx.request().remoteAddress().host();
ctx.addBodyEndHandler(v -> {
long duration = System.currentTimeMillis() - start;
log.info("{} {} - {} ms - {} - {}",
method, path, duration, ctx.response().getStatusCode(), remoteIp);
});
ctx.next();
});
router.route().handler(ctx -> {
String path = ctx.request().path();
if (path != null && path.startsWith(config.pma.basePath + "/")) {
ctx.next(); // пропускаем BodyHandler для прокси
} else {
BodyHandler.create().handle(ctx);
}
});
router.route().handler(sessionHandler);
setupPhpmyadminProxy(router);
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
// Обработчики безопасности (порядок важен)
// Обработчики безопасности
router.route().handler(securityHandlers.hostValidator());
router.route().handler(securityHandlers.proxyHeadersHandler());
router.route().handler(securityHandlers.cspHeader());
@@ -160,6 +267,13 @@ public class MainVerticle extends AbstractVerticle {
}
});
// Rate Limiter Handler
RedisRateLimiter limiter = new RedisRateLimiter(
redis.getRedis(), 60, 60_000
);
router.route().handler(limiter);
// Health Checks
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
healthCheckService.registerHealthCheck(router);
@@ -181,7 +295,6 @@ public class MainVerticle extends AbstractVerticle {
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
return;
}
// существующий код регистрации
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
@@ -196,15 +309,14 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}));
// В initRouter после настройки authHandler, до объявления /api/admin/*:
router.route("/api/admin/profile").handler(authHandler::requireAuth);
router.get("/api/admin/profile").handler(rc -> {
router.route("/api/profile").handler(authHandler::requireAuth);
router.get("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
userService.getProfile(userId)
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/profile").handler(rc -> {
router.put("/api/profile").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String email = body.getString("email");
@@ -217,30 +329,8 @@ public class MainVerticle extends AbstractVerticle {
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/language").handler(rc -> {
Integer userId = rc.session().get("userId");
JsonObject body = rc.body().asJsonObject();
String language = body.getString("language");
if (language == null || (!"en".equals(language) && !"ru".equals(language))) {
rc.response().setStatusCode(400).end("Invalid language");
return;
}
userService.updateLanguage(userId, language)
.onSuccess(v -> {
rc.session().put("language", language);
rc.response().end(new JsonObject().put("success", true).encode());
})
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Затем существующий блок router.route("/api/admin/*").handler(authHandler::requireAuth);
router.route("/api/admin/*").handler(authHandler::requireAuth);
// Добавить проверку роли для чувствительных эндпоинтов:
// router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/restaurants*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
// router.route("/api/admin/active-sessions").handler(AdminHandler::requireAdmin);
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
@@ -251,6 +341,7 @@ public class MainVerticle extends AbstractVerticle {
}
}));
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
router.post("/api/admin/users").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
@@ -316,21 +407,6 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получение текущего пользователя
router.get("/api/admin/me").handler(rc -> {
Integer userId = rc.session().get("userId");
if (userId != null) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("id", userId)
.put("login", rc.session().get("login"))
.encode());
} else {
rc.response().setStatusCode(401).end();
}
});
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
@@ -409,28 +485,25 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получение всех настроек
router.get("/api/settings").handler(rc -> {
settingsService.getPublicSettings()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end());
});
// Получить метаданные всех настроек (для построения формы)
router.get("/api/settings/meta").handler(rc -> {
router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
router.get("/api/admin/settings/meta").handler(rc -> {
settingsService.getMetadata()
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Получить все настройки со значениями по умолчанию
router.get("/api/settings/all").handler(rc -> {
router.get("/api/admin/settings").handler(rc -> {
settingsService.getAllWithDefaults()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Обновление настроек (админ)
router.put("/api/admin/settings").handler(rc -> {
JsonObject body = rc.body().asJsonObject();
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
@@ -440,17 +513,14 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
// Количество активных сессий (на основе Redis)
router.get("/api/admin/active-sessions").handler(rc -> {
// TODO: реализовать подсчёт активных сессий через Redis или другой механизм
rc.response().end(new JsonObject().put("count", 0).encode());
});
externalDataBaseService.handleRoute(router);
new IikoHandler(vertx, router, db, restaurantService, authHandler);
return router;
}
private void startHttp(Router router, Promise<Void> startPromise) {
// Запуск HTTP-сервера
httpServer = vertx.createHttpServer();
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
.onSuccess(server -> {

View File

@@ -8,6 +8,7 @@ public class AppConfig {
public ServerConfig server;
public DatabaseConfig database;
public RedisConfig redis;
public PhpMyAdminConfig pma;
public static AppConfig from(JsonObject json) {
JsonObject resolved = json.copy();
@@ -94,7 +95,8 @@ public class AppConfig {
return new JsonObject()
.put("server", server.json().getJsonObject("server"))
.put("database", database.json().getJsonObject("database"))
.put("redis", redis.json().getJsonObject("redis"));
.put("redis", redis.json().getJsonObject("redis"))
.put("pma", pma.json().getJsonObject("pma"));
}
@Override

View File

@@ -0,0 +1,18 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class PhpMyAdminConfig {
public boolean enabled;
public String upstream;
public String basePath;
public JsonObject json() {
return new JsonObject()
.put("pma", new JsonObject()
.put("enabled", enabled)
.put("upstream", upstream)
.put("basePath", basePath)
);
}
}

View File

@@ -0,0 +1,185 @@
package su.xserver.iikocon.handler;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.redis.client.Command;
import io.vertx.redis.client.Redis;
import io.vertx.redis.client.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class RedisRateLimiter implements Handler<RoutingContext> {
private final Logger logger;
private final Redis redis;
private final int limitPerWindow;
private final long windowMillis;
private static final String PREFIX = "ip:limit:";
// Основной кэш: clientKey -> время окончания блокировки
private final ConcurrentHashMap<String, Long> blockedClients = new ConcurrentHashMap<>();
// Индекс по времени: время окончания -> множество клиентов
private final ConcurrentSkipListMap<Long, Set<String>> expiryIndex = new ConcurrentSkipListMap<>();
private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
private final AtomicLong allowedRequests = new AtomicLong(0);
private final AtomicLong blockedRequests = new AtomicLong(0);
private final AtomicLong redisCalls = new AtomicLong(0);
private final AtomicLong redisFailures = new AtomicLong(0);
private final AtomicLong totalRedisLatency = new AtomicLong(0);
private final AtomicLong redisLatencyCount = new AtomicLong(0);
// Частота блокировок по IP
private final ConcurrentHashMap<String, AtomicLong> blockedByClient = new ConcurrentHashMap<>();
public RedisRateLimiter(Redis redis, int limitPerWindow, long windowMillis) {
this.logger = LoggerFactory.getLogger("[RedisRateLimiter]");
this.redis = redis;
this.limitPerWindow = limitPerWindow;
this.windowMillis = windowMillis;
// Периодическая очистка только истёкших блокировок
cleaner.scheduleAtFixedRate(this::cleanupExpiredClients, windowMillis, windowMillis / 2, TimeUnit.MILLISECONDS);
}
@Override
public void handle(RoutingContext context) {
String clientKey = getClientKey(context);
long now = System.currentTimeMillis();
// Проверяем локальную блокировку
Long blockedUntil = blockedClients.get(clientKey);
if (blockedUntil != null) {
if (blockedUntil > now) {
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
return;
} else {
unblockClient(clientKey, blockedUntil);
}
}
String redisKey = PREFIX + clientKey;
checkRateLimit(context, redisKey, clientKey);
}
private void checkRateLimit(RoutingContext context, String redisKey, String clientKey) {
String luaScript = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, ttl)
end
if current > limit then
return 'TOO_MANY_REQUESTS'
else
return 'OK'
end
""";
redisCalls.incrementAndGet();
long start = System.nanoTime();
Request request = Request.cmd(Command.EVAL)
.arg(luaScript)
.arg(1)
.arg(redisKey)
.arg(limitPerWindow)
.arg(windowMillis);
redis.send(request)
.onSuccess(response -> {
long duration = System.nanoTime() - start;
redisLatencyCount.incrementAndGet();
totalRedisLatency.addAndGet(TimeUnit.NANOSECONDS.toMillis(duration));
String result = response.toString();
if ("TOO_MANY_REQUESTS".equals(result)) {
blockClient(clientKey);
blockedRequests.incrementAndGet();
incrementBlockCount(clientKey);
sendTooManyRequests(context);
} else {
allowedRequests.incrementAndGet();
context.next();
}
}).onFailure(error -> {
redisFailures.incrementAndGet();
context.response()
.setStatusCode(503)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "503 Service Unavailable")
.put("message", "Redis is not connected")
.encodePrettily()
);
logger.error(error.getMessage());
});
}
private void blockClient(String clientKey) {
long blockedUntil = System.currentTimeMillis() + windowMillis;
blockedClients.put(clientKey, blockedUntil);
expiryIndex.computeIfAbsent(blockedUntil, t -> ConcurrentHashMap.newKeySet()).add(clientKey);
}
private void unblockClient(String clientKey, long expiryTime) {
blockedClients.remove(clientKey);
Set<String> clients = expiryIndex.get(expiryTime);
if (clients != null) {
clients.remove(clientKey);
if (clients.isEmpty()) {
expiryIndex.remove(expiryTime);
}
}
}
private void incrementBlockCount(String clientKey) {
blockedByClient.computeIfAbsent(clientKey, k -> new AtomicLong(0)).incrementAndGet();
}
private void cleanupExpiredClients() {
long now = System.currentTimeMillis();
// Получаем все записи, у которых время истечения <= now
NavigableMap<Long, Set<String>> expired = expiryIndex.headMap(now, true);
if (expired.isEmpty()) return;
for (Map.Entry<Long, Set<String>> entry : expired.entrySet()) {
Set<String> clients = entry.getValue();
for (String client : clients) {
blockedClients.remove(client);
}
}
expired.clear(); // очищаем диапазон из индекса
}
private void sendTooManyRequests(RoutingContext context) {
context.response()
.setStatusCode(429)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("error", "429 Too Many Requests")
.put("message", "Try again later")
.encodePrettily()
);
}
private String getClientKey(RoutingContext context) {
return context.request().remoteAddress().host().replace(':', '.');
}
}

View File

@@ -0,0 +1,266 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.handler.AdminHandler;
import su.xserver.iikocon.handler.AuthHandler;
import su.xserver.iikocon.service.DataBaseService;
import su.xserver.iikocon.service.RestaurantService;
public class IikoHandler {
private final Logger log = LoggerFactory.getLogger("[IikoHandler]");
private final DataBaseService db;
private final Vertx vertx;
private final RestaurantService restaurantService;
public IikoHandler(Vertx vertx, Router router, DataBaseService db, RestaurantService restaurantService, AuthHandler authHandler) {
this.vertx = vertx;
this.restaurantService = restaurantService;
this.db = db;
createTablesIfNotExist().onFailure(err -> {
log.error("Failed to initialize database", err);
});
router.route("/api/reports/olap/*").handler(authHandler::requireAuth);
router.get("/api/reports/olap/columns").handler(this::getColumns);
router.delete("/api/reports/olap/columns/:fieldKey").handler(AdminHandler::requireAdmin).handler(this::deleteColumn);
router.post("/api/reports/olap/initialize").handler(AdminHandler::requireAdmin).handler(this::postInitialize);
}
private void getColumns(RoutingContext ctx) {
getAllFieldsWithReportAndTags()
.onSuccess(ar -> ctx.response()
.putHeader("Content-Type", "application/json")
.end(ar.encodePrettily()))
.onFailure(err -> ctx.response()
.setStatusCode(500)
.end(err.getMessage()));
}
public void deleteColumn(RoutingContext ctx) {
String fieldKey = ctx.pathParam("fieldKey");
String sql = "DELETE FROM iiko_fields_common WHERE field_key = ?";
db.getPool().preparedQuery(sql)
.execute(Tuple.of(fieldKey))
.onSuccess(res -> {
ctx.end();
})
.onFailure(err -> ctx.response().setStatusCode(500).end(err.getMessage()));
}
private void postInitialize(RoutingContext ctx) {
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
ctx.response()
.setStatusCode(400)
.end("Request body is missing or not a JSON object");
return;
}
if (!body.containsKey("restaurantId") || body.getValue("restaurantId") == null) {
ctx.response()
.setStatusCode(400)
.end("restaurantId is required");
return;
}
Integer restaurantId;
try {
restaurantId = body.getInteger("restaurantId");
if (restaurantId == null) {
throw new IllegalArgumentException("restaurantId must be a number");
}
} catch (ClassCastException e) {
ctx.response()
.setStatusCode(400)
.end("restaurantId must be a valid integer");
return;
}
restaurantService.findById(restaurantId)
.onSuccess(rest -> {
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
iiko.checkConnection()
.onSuccess(ping -> clearTables()
.onSuccess(data -> {
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(iiko, db);
importer.fetchAndStoreAll()
.onSuccess(res -> ctx.end("OK"))
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage())))
.onFailure(err -> ctx.response().setStatusCode(400).end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
}
public Future<JsonObject> getAllFieldsWithReportAndTags() {
String sql = """
SELECT
fc.field_key,
fc.field_key_normal,
fc.name,
fc.type,
fc.type_normal,
fc.aggregation_allowed,
fc.grouping_allowed,
fc.filtering_allowed,
GROUP_CONCAT(DISTINCT rt.name ORDER BY rt.name SEPARATOR ',') AS report_names,
GROUP_CONCAT(DISTINCT t.tag_name ORDER BY t.tag_name SEPARATOR ',') AS tag_names
FROM iiko_fields_common fc
LEFT JOIN iiko_report_type_fields rtf ON fc.field_id = rtf.field_id
LEFT JOIN iiko_report_types rt ON rtf.report_type_id = rt.report_type_id
LEFT JOIN iiko_field_tags ft ON fc.field_id = ft.field_id
LEFT JOIN iiko_tags t ON ft.tag_id = t.tag_id
GROUP BY fc.field_id
ORDER BY fc.field_key
""";
return db.getPool().query(sql).execute()
.map(rows -> {
JsonArray columnsArray = new JsonArray();
for (Row row : rows) {
String reportNamesStr = row.getString("report_names");
JsonArray reportTypes = new JsonArray();
if (reportNamesStr != null && !reportNamesStr.isBlank()) {
for (String name : reportNamesStr.split(",")) {
reportTypes.add(name.trim());
}
}
String tagNamesStr = row.getString("tag_names");
JsonArray tags = new JsonArray();
if (tagNamesStr != null && !tagNamesStr.isBlank()) {
for (String tag : tagNamesStr.split(",")) {
tags.add(tag.trim());
}
}
JsonObject fieldObj = new JsonObject()
.put("fieldKey", row.getString("field_key"))
.put("fieldKeyNormal", row.getString("field_key_normal"))
.put("reportTypes", reportTypes)
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("typeNormal", row.getString("type_normal"))
.put("aggregationAllowed", row.getBoolean("aggregation_allowed"))
.put("groupingAllowed", row.getBoolean("grouping_allowed"))
.put("filteringAllowed", row.getBoolean("filtering_allowed"))
.put("tags", tags);
columnsArray.add(fieldObj);
}
return new JsonObject().put("columns", columnsArray);
});
}
private Future<Void> createTablesIfNotExist() {
String createReportTypes = """
CREATE TABLE IF NOT EXISTS iiko_report_types (
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldsCommon = """
CREATE TABLE IF NOT EXISTS iiko_fields_common (
field_id INT AUTO_INCREMENT PRIMARY KEY,
field_key VARCHAR(255) NOT NULL UNIQUE,
field_key_normal VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
type_normal VARCHAR(50) NOT NULL,
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
filtering_allowed BOOLEAN NOT NULL DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createReportTypeFields = """
CREATE TABLE IF NOT EXISTS iiko_report_type_fields (
report_type_id INT NOT NULL,
field_id INT NOT NULL,
PRIMARY KEY (report_type_id, field_id),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE CASCADE,
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTags = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldTags = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String idxKeyNormal = "CREATE INDEX IF NOT EXISTS idx_fields_common_key_normal ON iiko_fields_common(field_key_normal)";
String idxFieldName = "CREATE INDEX IF NOT EXISTS idx_fields_common_name ON iiko_fields_common(name)";
String idxFieldTagsTag = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return db.getPool().query(createReportTypes).execute()
.compose(v -> db.getPool().query(createFieldsCommon).execute())
.compose(v -> db.getPool().query(createReportTypeFields).execute())
.compose(v -> db.getPool().query(createTags).execute())
.compose(v -> db.getPool().query(createFieldTags).execute())
.compose(v -> db.getPool().query(idxKeyNormal).execute())
.compose(v -> db.getPool().query(idxFieldName).execute())
.compose(v -> db.getPool().query(idxFieldTagsTag).execute())
.mapEmpty();
}
private Future<Void> clearTables() {
String sql = """
-- Отключаем проверку внешних ключей
SET FOREIGN_KEY_CHECKS = 0;
-- Удаляем данные из всех таблиц (порядок не важен при отключённой проверке)
DELETE FROM iiko_field_tags;
DELETE FROM iiko_report_type_fields;
DELETE FROM iiko_fields_common;
DELETE FROM iiko_tags;
DELETE FROM iiko_report_types;
-- Сбрасываем счётчики AUTO_INCREMENT (чтобы новые ID начинались с 1)
ALTER TABLE iiko_fields_common AUTO_INCREMENT = 1;
ALTER TABLE iiko_tags AUTO_INCREMENT = 1;
ALTER TABLE iiko_report_types AUTO_INCREMENT = 1;
-- Включаем проверку обратно
SET FOREIGN_KEY_CHECKS = 1;
""";
return db.getPool().query(sql).execute().mapEmpty();
}
}

View File

@@ -14,19 +14,12 @@ import java.util.stream.Collectors;
public class IikoOlapClient {
private static final Logger log = LoggerFactory.getLogger(IikoOlapClient.class);
private static final Logger log = LoggerFactory.getLogger("[IikoOlapClient]");
private final WebClient webClient;
private final String iikoHost;
private final String iikoLogin;
private final String iikoPassHash;
public IikoOlapClient(Vertx vertx, String host, String login, String passHash, boolean https) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (https ? "https://" : "http://") + host + (https ? ":443" : ":80");
this.iikoLogin = login;
this.iikoPassHash = passHash;
}
public IikoOlapClient(Vertx vertx, JsonObject rest) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
@@ -36,7 +29,7 @@ public class IikoOlapClient {
private Future<String> authenticate() {
Promise<String> promise = Promise.promise();
String url = iikoHost + "/resto/api/auth"; //?login=" + iikoLogin + "&pass=" + iikoPassHash;
String url = iikoHost + "/resto/api/auth";
webClient.getAbs(url)
.addQueryParam("login", iikoLogin)
@@ -65,7 +58,6 @@ public class IikoOlapClient {
.addQueryParam("key", token)
.send()
.onSuccess(resp -> {
// log.info("Logout completed for token, status {}", resp.statusCode());
log.info(resp.bodyAsString());
promise.complete();
})
@@ -105,7 +97,6 @@ public class IikoOlapClient {
.onSuccess(resp -> {
if (resp.statusCode() == 200) {
JsonObject body = resp.bodyAsJsonObject();
// Если есть обёртка data, распаковываем
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
? body.getJsonObject("data")
: body;

View File

@@ -1,44 +1,30 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.mysqlclient.MySQLConnectOptions;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.service.DataBaseService;
import java.util.ArrayList;
import java.util.List;
public class IikoOlapColumnsImporter {
private static final Logger log = LoggerFactory.getLogger(IikoOlapColumnsImporter.class);
private final Pool dbPool;
private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]");
private final DataBaseService db;
private final IikoOlapClient iikoOlapClient;
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES");
public IikoOlapColumnsImporter(Vertx vertx, String iikoServer, String iikoLogin, String iikoPassword, String dbHost, int dbPort, String dbName, String dbUser, String dbPassword) {
this.iikoOlapClient = new IikoOlapClient(vertx, iikoServer, iikoLogin, iikoPassword, true);
MySQLConnectOptions connectOptions = new MySQLConnectOptions()
.setHost(dbHost)
.setPort(dbPort)
.setDatabase(dbName)
.setUser(dbUser)
.setPassword(dbPassword)
.setCharset("utf8mb4");
PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
this.dbPool = Pool.pool(vertx, connectOptions, poolOptions);
public IikoOlapColumnsImporter(IikoOlapClient iikoOlapClient, DataBaseService db) {
this.iikoOlapClient = iikoOlapClient;
this.db = db;
}
public Future<Void> fetchAndStoreAll() {
return createTablesIfNotExist()
.compose(v -> processAllReportTypesSequentially())
return processAllReportTypesSequentially()
.onSuccess(v -> log.info("All reports imported successfully"))
.onFailure(err -> log.error("Import failed: {}", err.getMessage()));
}
@@ -57,18 +43,11 @@ public class IikoOlapColumnsImporter {
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
}
// Запрос полей для конкретного reportType
private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
Promise<JsonObject> promise = Promise.promise();
iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns", new JsonObject().put("reportType", reportType))
.onSuccess(promise::complete)
.onFailure(promise::fail);
return promise.future();
return iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns",
new JsonObject().put("reportType", reportType));
}
// ---------- Методы работы с БД (с префиксом iiko_) ----------
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
return getOrCreateReportType(reportType)
.compose(reportTypeId -> {
@@ -82,86 +61,86 @@ public class IikoOlapColumnsImporter {
}
private Future<Integer> getOrCreateReportType(String reportType) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(reportType))
.onComplete(ar -> {
if (ar.succeeded() && ar.result().size() > 0) {
promise.complete(ar.result().iterator().next().getInteger("report_type_id"));
} else if (ar.succeeded()) {
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
dbPool.preparedQuery(insertSql)
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
.onComplete(insAr -> {
if (insAr.succeeded()) {
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(reportType))
.onComplete(selAr -> {
if (selAr.succeeded() && selAr.result().size() > 0) {
promise.complete(selAr.result().iterator().next().getInteger("report_type_id"));
} else {
promise.fail("Cannot retrieve inserted report_type_id for " + reportType);
}
});
} else {
promise.fail(insAr.cause());
}
});
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("report_type_id"));
} else {
promise.fail(ar.cause());
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.map(rows2 -> rows2.iterator().next().getInteger("report_type_id"))
);
}
});
return promise.future();
}
/**
* Сохранить одно поле (без дублирования).
* Сначала получаем/создаём запись в iiko_fields_common,
* затем связываем её с report_type_id через iiko_report_type_fields,
* потом обрабатываем теги.
*/
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
// Нормализованный ключ (без точек)
String fieldKeyNormal = fieldKey.replace('.', '_');
String name = fieldDef.getString("name");
String originalType = fieldDef.getString("type");
String typeNormal = normalizeType(originalType);
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
String insertFieldSql = """
INSERT INTO iiko_fields (
report_type_id, field_key, field_key_normal, name, type, type_normal,
aggregation_allowed, grouping_allowed, filtering_allowed
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
field_key_normal = VALUES(field_key_normal),
name = VALUES(name),
type_normal = VALUES(type_normal),
aggregation_allowed = VALUES(aggregation_allowed),
grouping_allowed = VALUES(grouping_allowed),
filtering_allowed = VALUES(filtering_allowed)
""";
return getOrCreateCommonField(fieldKey, fieldKeyNormal, name, originalType, typeNormal,
aggregationAllowed, groupingAllowed, filteringAllowed)
.compose(fieldId -> linkFieldToReportType(reportTypeId, fieldId)
.compose(v -> processTags(fieldId, tagsArray))
);
}
return dbPool.preparedQuery(insertFieldSql)
.execute(Tuple.of(
reportTypeId, fieldKey, fieldKeyNormal, name, originalType, typeNormal,
aggregationAllowed, groupingAllowed, filteringAllowed
))
.compose(ignored -> {
String selectFieldIdSql = "SELECT field_id FROM iiko_fields WHERE report_type_id = ? AND field_key = ?";
return dbPool.preparedQuery(selectFieldIdSql)
.execute(Tuple.of(reportTypeId, fieldKey))
.compose(rows -> {
if (rows.size() == 0) {
return Future.failedFuture("Field not found after upsert: " + fieldKey);
}
int fieldId = rows.iterator().next().getInteger("field_id");
return processTags(fieldId, tagsArray);
});
/**
* Найти или создать поле в iiko_fields_common (по уникальному field_key).
*/
private Future<Integer> getOrCreateCommonField(String fieldKey, String fieldKeyNormal, String name,
String type, String typeNormal,
boolean aggAllowed, boolean groupAllowed, boolean filterAllowed) {
String selectSql = "SELECT field_id FROM iiko_fields_common WHERE field_key = ?";
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("field_id"));
} else {
String insertSql = """
INSERT INTO iiko_fields_common
(field_key, field_key_normal, name, type, type_normal,
aggregation_allowed, grouping_allowed, filtering_allowed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(fieldKey, fieldKeyNormal, name, type, typeNormal,
aggAllowed, groupAllowed, filterAllowed))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.map(rows2 -> rows2.iterator().next().getInteger("field_id"))
);
}
});
}
/**
* Привязать поле к типу отчёта (если ещё не привязано).
*/
private Future<Void> linkFieldToReportType(int reportTypeId, int fieldId) {
String sql = "INSERT IGNORE INTO iiko_report_type_fields (report_type_id, field_id) VALUES (?, ?)";
return db.getPool().preparedQuery(sql).execute(Tuple.of(reportTypeId, fieldId)).mapEmpty();
}
/**
* Обработать теги поля (теги одинаковы для всех типов отчётов).
*/
private Future<Void> processTags(int fieldId, JsonArray tags) {
List<Future<Void>> tagFutures = new ArrayList<>();
for (Object tagObj : tags) {
@@ -173,93 +152,25 @@ public class IikoOlapColumnsImporter {
}
private Future<Integer> getOrCreateTag(String tagName) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(tagName))
.onComplete(ar -> {
if (ar.succeeded() && ar.result().size() > 0) {
promise.complete(ar.result().iterator().next().getInteger("tag_id"));
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.compose(rows -> {
if (rows.size() > 0) {
return Future.succeededFuture(rows.iterator().next().getInteger("tag_id"));
} else {
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
dbPool.preparedQuery(insertSql)
.execute(Tuple.of(tagName))
.onComplete(insAr -> {
// После IGNORE всё равно выбираем ID (он мог уже существовать)
dbPool.preparedQuery(selectSql)
.execute(Tuple.of(tagName))
.onComplete(selAr -> {
if (selAr.succeeded() && selAr.result().size() > 0) {
promise.complete(selAr.result().iterator().next().getInteger("tag_id"));
} else {
promise.fail("Cannot retrieve tag_id for " + tagName);
}
});
});
return db.getPool().preparedQuery(insertSql).execute(Tuple.of(tagName))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.map(rows2 -> rows2.iterator().next().getInteger("tag_id"))
);
}
});
return promise.future();
}
private Future<Void> linkFieldTag(int fieldId, int tagId) {
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
return dbPool.preparedQuery(sql)
.execute(Tuple.of(fieldId, tagId))
.mapEmpty();
}
private Future<Void> createTablesIfNotExist() {
String createReportTypesTable = """
CREATE TABLE IF NOT EXISTS iiko_report_types (
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldsTable = """
CREATE TABLE IF NOT EXISTS iiko_fields (
field_id INT AUTO_INCREMENT PRIMARY KEY,
report_type_id INT NOT NULL,
field_key VARCHAR(255) NOT NULL,
field_key_normal VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
type_normal VARCHAR(50) NOT NULL,
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
filtering_allowed BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_fields_report_type_field_key (report_type_id, field_key),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE RESTRICT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTagsTable = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldTagsTable = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createIdxFieldsReportType = "CREATE INDEX IF NOT EXISTS idx_fields_report_type ON iiko_fields(report_type_id)";
String createIdxFieldsName = "CREATE INDEX IF NOT EXISTS idx_fields_name ON iiko_fields(name)";
String createIdxFieldTagsTagId = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return dbPool.query(createReportTypesTable).execute()
.compose(ignored -> dbPool.query(createFieldsTable).execute())
.compose(ignored -> dbPool.query(createTagsTable).execute())
.compose(ignored -> dbPool.query(createFieldTagsTable).execute())
.compose(ignored -> dbPool.query(createIdxFieldsReportType).execute())
.compose(ignored -> dbPool.query(createIdxFieldsName).execute())
.compose(ignored -> dbPool.query(createIdxFieldTagsTagId).execute())
.mapEmpty();
return db.getPool().preparedQuery(sql).execute(Tuple.of(fieldId, tagId)).mapEmpty();
}
private String normalizeType(String iikoType) {

View File

@@ -1,43 +0,0 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Vertx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Main {
private static final Logger log = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) {
long time = System.currentTimeMillis();
Vertx vertx = Vertx.vertx();
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(
vertx,
"folk-amber-co.iiko.it", // без https://
"4444",
"92f2fd99879b0c2466ab8648afb63c49032379c1",
"phpmyadmin.xserver.su", // хост MariaDB
3306,
"test", // имя БД
"test",
"test"
);
importer.fetchAndStoreAll()
.onComplete(ar -> {
if (ar.succeeded()) {
System.out.println("Import completed successfully.");
log.info("time to sc: {}", (System.currentTimeMillis() - time) + "ms");
} else {
System.err.println("Import failed: " + ar.cause().getMessage());
}
// importer.close();
// vertx.close();
});
}
}

View File

@@ -0,0 +1,259 @@
package su.xserver.iikocon.service;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.jdbcclient.JDBCConnectOptions;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.templates.SqlTemplate;
import su.xserver.iikocon.handler.AdminHandler;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class ExternalDataBaseService {
private final Pool pool;
private final Vertx vertx;
public ExternalDataBaseService(Pool pool, Vertx vertx) {
this.pool = pool;
this.vertx = vertx;
}
public void handleRoute(Router router) {
router.get("/api/admin/database-connections").handler(rc -> this.getAllDataBases().onComplete(ar -> {
if (ar.succeeded()) {
rc.response()
.putHeader("Content-Type", "application/json")
.end(ar.result().encode());
} else {
rc.response().setStatusCode(500).end(ar.cause().getMessage());
}
}));
router.get("/api/admin/database-connections/:id/test").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
this.testConnection(id)
.onSuccess(result -> rc.response()
.setStatusCode(200)
.putHeader("Content-Type", "application/json")
.end(result.encode()))
.onFailure(err -> rc.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end(new JsonObject()
.put("success", false)
.put("error", err.getMessage())
.encode()));
});
router.post("/api/admin/database-connections").handler(AdminHandler::requireAdmin).handler(rc -> {
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String type = body.getString("type");
String host = body.getString("host");
int port = body.getInteger("port");
String database = body.getString("database");
String user = body.getString("user");
String password = body.getString("password");
if (name == null || type == null || host == null || port < 1 || database == null || user == null || password == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
this.createDataBase(name, type, host, port, database, user, password)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.put("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
JsonObject body = rc.body().asJsonObject();
String name = body.getString("name");
String type = body.getString("type");
String host = body.getString("host");
int port = body.getInteger("port");
String database = body.getString("database");
String user = body.getString("user");
String password = body.getString("password");
if (name == null || type == null || host == null || port < 1 || database == null || user == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
this.updateDataBase(id, name, type, host, port, database, user, password)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
router.delete("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
this.deleteDataBase(id)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
}
public Future<Void> initDatabase() {
String createTable = """
CREATE TABLE IF NOT EXISTS external_database (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(40) UNIQUE NOT NULL,
host VARCHAR(255) NOT NULL,
port INT NOT NULL,
database VARCHAR(255) NOT NULL,
user VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
""";
return pool.query(createTable).execute().mapEmpty();
}
public Future<Void> createDataBase(String name, String type, String host, int port, String database, String user, String password) {
Map<String, Object> params = Map.of(
"name", name,
"type", type,
"host", host,
"port", port,
"database", database,
"user", user,
"password", password
);
return SqlTemplate.forUpdate(pool,
"INSERT INTO external_database (name, type, host, port, database, user, password) VALUES (#{name}, #{type}, #{host}, #{port}, #{database}, #{user}, #{password})")
.execute(params)
.mapEmpty();
}
public Future<JsonArray> getAllDataBases() {
return pool.query("SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database ORDER BY id")
.execute()
.map(rows -> {
JsonArray array = new JsonArray();
for (Row row : rows) {
array.add(new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("host", row.getString("host"))
.put("port", row.getInteger("port"))
.put("database", row.getString("database"))
.put("user", row.getString("user"))
.put("created", row.getLocalDateTime("created") != null ?
row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null));
}
return array;
});
}
public Future<JsonObject> findById(int id) {
return SqlTemplate.forQuery(pool,
"SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database WHERE id = #{id}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("host", row.getString("host"))
.put("port", row.getInteger("port"))
.put("database", row.getString("database"))
.put("user", row.getString("user"))
.put("password", row.getString("password"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
.execute(Collections.singletonMap("id", id))
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<Void> updateDataBase(int id, String name, String type, String host, int port, String database, String user, String password) {
Map<String, Object> params = new HashMap<>();
params.put("id", id);
params.put("name", name);
params.put("type", type);
params.put("host", host);
params.put("port", port);
params.put("database", database);
params.put("user", user);
String sql;
if (password != null && !password.isEmpty()) {
params.put("password", password);
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user}, password = #{password} WHERE id = #{id}";
} else {
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user} WHERE id = #{id}";
}
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> deleteDataBase(int id) {
return SqlTemplate.forUpdate(pool, "DELETE FROM external_database WHERE id = #{id}")
.execute(Collections.singletonMap("id", id))
.mapEmpty();
}
public Future<JsonObject> testConnection(int id) {
Promise<JsonObject> promise = Promise.promise();
this.findById(id)
.onSuccess(conn -> {
String jdbcUrl = buildJdbcUrl(conn);
if (jdbcUrl == null) {
promise.fail("Unsupported database type: " + conn.getString("type"));
return;
}
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
.setJdbcUrl(jdbcUrl)
.setDatabase(conn.getString("database"))
.setUser(conn.getString("user"))
.setPassword(conn.getString("password"));
PoolOptions poolOptions = new PoolOptions()
.setMaxSize(1);
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
long startTime = System.currentTimeMillis();
pool
.query("SELECT 1")
.execute()
.onSuccess(rows -> {
long latency = System.currentTimeMillis() - startTime;
JsonObject result = new JsonObject()
.put("success", true)
.put("latency_ms", latency);
promise.complete(result);
pool.close();
})
.onFailure(err -> promise.fail("Connection failed: " + err.getMessage()));
})
.onFailure(promise::fail);
return promise.future();
}
private String buildJdbcUrl(JsonObject conn) {
return switch (conn.getString("type").toLowerCase()) {
case "mysql" -> String.format("jdbc:mysql://%s:%d",
conn.getString("host"), conn.getInteger("port"));
case "postgres" -> String.format("jdbc:postgresql://%s:%d",
conn.getString("host"), conn.getInteger("port"));
case "clickhouse" ->
String.format("jdbc:clickhouse://%s:%d",
conn.getString("host"), conn.getInteger("port"));
default -> null;
};
}
}

View File

@@ -5,7 +5,6 @@ import io.vertx.core.json.JsonObject;
import io.vertx.ext.healthchecks.Status;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.healthchecks.HealthCheckHandler;
import su.xserver.iikocon.iiko.IikoOlapClient;
import java.util.Collections;
@@ -56,21 +55,7 @@ public class HealthCheckService {
.onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage()));
});
// healthCheckHandler.register("iiko", future -> {
//
// IikoOlapClient iiko = new IikoOlapClient(vertx, "folk-amber-co.iiko.it", "4444", "92f2fd99879b0c2466ab8648afb63c49032379c1", true);
//
// iiko.checkConnection()
// .onSuccess(res -> {
// JsonObject data = new JsonObject()
// .put("name", "iiko")
// .put("latency_ms", res.getLong("latency_ms"));
// future.complete(Status.OK(data));
// })
// .onFailure(err -> future.tryFail("iiko ping failed: " + err.getMessage()));
// });
// Регистрируем endpoint /api/health
// Endpoint /api/health
router.get("/api/health").handler(healthCheckHandler);
}
}

View File

@@ -54,12 +54,6 @@ public class RestaurantService {
return pool.query(createTable).execute().mapEmpty();
}
public Future<Long> countRestaurant() {
return pool.query("SELECT COUNT(*) AS cnt FROM restaurants")
.execute()
.map(rows -> rows.iterator().next().getLong("cnt"));
}
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
String hashedPassword = hashPassword(password);
Map<String, Object> params = Map.of(

View File

@@ -101,8 +101,8 @@ public class SettingsService {
case "site_description" -> "";
case "enable_registration" -> "true";
case "maintenance_mode" -> "false";
case "session_timeout_minutes" -> "60";
case "use_proxy_headers" -> "true";
case "session_timeout_minutes" -> "120";
case "use_proxy_headers" -> "false";
case "trusted_proxies" -> "127.0.0.1";
case "enable_csp" -> "true";
case "allowed_hosts" -> "";
@@ -120,13 +120,9 @@ public class SettingsService {
return pool.query(createTable).execute()
.compose(v -> setIfAbsent("site_name", "Admin Panel"))
.compose(v -> setIfAbsent("site_description", "Powerful administration dashboard"))
.compose(v -> setIfAbsent("theme", "light"))
.compose(v -> setIfAbsent("items_per_page", "20"))
.compose(v -> setIfAbsent("enable_registration", "true"))
.compose(v -> setIfAbsent("maintenance_mode", "false"))
.compose(v -> setIfAbsent("default_language", "en"))
.compose(v -> setIfAbsent("session_timeout_minutes", "60"))
.compose(v -> setIfAbsent("logo_url", "/assets/logo.png"))
.compose(v -> setIfAbsent("session_timeout_minutes", "120"))
.mapEmpty();
}

View File

@@ -176,7 +176,6 @@ public class UserService {
}
if (setClauses.isEmpty()) {
// Ни одно поле не обновляется — возвращаем успешный Future
return Future.succeededFuture();
}
@@ -184,12 +183,6 @@ public class UserService {
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> updateLanguage(int userId, String language) {
return SqlTemplate.forUpdate(pool, "UPDATE users SET language = #{lang} WHERE id = #{id}")
.execute(Map.of("id", userId, "lang", language))
.mapEmpty();
}
public boolean checkPassword(String plain, String hash) {
try {
return BCrypt.checkpw(plain, hash);

View File

@@ -0,0 +1,41 @@
package su.xserver.iikocon.test;
import io.vertx.core.Vertx;
import io.vertx.jdbcclient.JDBCConnectOptions;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ClickHouseJDBCExample {
private static final Logger log = LoggerFactory.getLogger(ClickHouseJDBCExample.class);
public static void main(String[] args) {
Vertx vertx = Vertx.vertx();
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
.setJdbcUrl("jdbc:clickhouse://dl-import.aramagedec.ru:8123")
.setDatabase("test")
.setUser("clickhouse_admin")
.setPassword("7002ITinsta11");
PoolOptions poolOptions = new PoolOptions()
.setMaxSize(16);
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
pool
.query("SELECT 1")
.execute()
.onSuccess(rows -> {
rows.forEach(row -> log.info(row.toJson().encodePrettily()));
vertx.close();
})
.onFailure(err -> {
log.error(err.getMessage());
vertx.close();
});
}
}

View File

@@ -2,7 +2,6 @@ package su.xserver.iikocon.test;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public class DateRangeSetup {
public static void main(String[] args) {
@@ -10,29 +9,11 @@ public class DateRangeSetup {
// Вычисление dateFrom и dateTo
LocalDate today = LocalDate.now();
LocalDate dateFrom = today.minusDays(7);
LocalDate dateTo = today;
// Переопределение из аргументов командной строки
if (args.length > 0 && args[0] != null && !args[0].isEmpty()) {
try {
dateFrom = LocalDate.parse(args[0]);
} catch (DateTimeParseException e) {
System.err.println("Ошибка парсинга dateFrom: " + args[0] + ". Используется значение по умолчанию.");
}
}
if (args.length > 1 && args[1] != null && !args[1].isEmpty()) {
try {
dateTo = LocalDate.parse(args[1]);
} catch (DateTimeParseException e) {
System.err.println("Ошибка парсинга dateTo: " + args[1] + ". Используется значение по умолчанию.");
}
}
// Форматирование дат в YYYY-MM-DD
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String formattedDateFrom = dateFrom.format(formatter);
String formattedDateTo = dateTo.format(formatter);
String formattedDateTo = today.format(formatter);
System.out.println("dateFrom=" + formattedDateFrom);
System.out.println("dateTo=" + formattedDateTo);

View File

@@ -1,183 +0,0 @@
package su.xserver.iikocon.test;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientOptions;
import io.vertx.ext.web.codec.BodyCodec;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class ProxyVerticle extends AbstractVerticle {
private WebClient webClient;
@Override
public void start(Promise<Void> startPromise) {
webClient = WebClient.create(vertx, new WebClientOptions()
.setSsl(true)
.setTrustAll(true)
.setVerifyHost(false));
Router router = Router.router(vertx);
router.post("/api/proxy").handler(this::handlePost);
router.get("/api/proxy").handler(this::handleGet);
int port = 8080;
vertx.createHttpServer()
.requestHandler(router)
.listen(port).onComplete(http -> {
if (http.succeeded()) {
System.out.println("Proxy server started on port " + port);
startPromise.complete();
} else {
startPromise.fail(http.cause());
}
});
}
private void handlePost(RoutingContext ctx) {
String apiServer = System.getenv("IIKO_API_SERVER");
String apiLogin = System.getenv("IIKO_API_LOGIN");
String apiPass = System.getenv("IIKO_API_PASS");
String externalEndpoint = System.getenv("IIKO_API_ENDPOINT");
if (externalEndpoint == null || externalEndpoint.isBlank()) {
externalEndpoint = "/your-endpoint";
}
if (apiServer == null || apiLogin == null || apiPass == null) {
fail(ctx, 500, "Missing required environment variables: IIKO_API_SERVER, IIKO_API_LOGIN, IIKO_API_PASS");
return;
}
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
fail(ctx, 400, "Request body must be JSON");
return;
}
String signature = sha1(apiPass);
String authUrl = "https://" + apiServer + ":443/resto/api/auth?login=" + apiLogin + "&pass=" + signature;
String finalExternalEndpoint = externalEndpoint;
webClient.getAbs(authUrl)
.as(BodyCodec.string())
.send()
.onSuccess(authResp -> {
if (authResp.statusCode() != 200) {
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
return;
}
String token = authResp.body();
String targetUrl = "https://" + apiServer + finalExternalEndpoint;
webClient.request(HttpMethod.POST, targetUrl)
.putHeader("Content-Type", "application/json")
.as(BodyCodec.jsonObject())
.sendJsonObject(body)
.onSuccess(apiResp -> {
webClient.getAbs("https://" + apiServer + ":443/resto/api/logout?key=" + token)
.send()
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
if (apiResp.statusCode() == 200) {
ctx.response().setStatusCode(200).end(apiResp.body().encode());
} else {
fail(ctx, apiResp.statusCode(), "External API error: " + apiResp.statusMessage());
}
})
.onFailure(err -> fail(ctx, 500, "Request to external API failed: " + err.getMessage()));
})
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
}
private void handleGet(RoutingContext ctx) {
String presetId = ctx.queryParam("presetId").stream().findFirst().orElse(null);
String dateFrom = ctx.queryParam("dateFrom").stream().findFirst().orElse(null);
String dateTo = ctx.queryParam("dateTo").stream().findFirst().orElse(null);
String server = ctx.queryParam("server").stream().findFirst().orElse(null);
String password = ctx.queryParam("password").stream().findFirst().orElse(null);
String login = ctx.queryParam("login").stream().findFirst().orElse(null);
String type = ctx.queryParam("type").stream().findFirst().orElse(null);
String rootType = ctx.queryParam("rootType").stream().findFirst().orElse(null);
if (server == null || login == null || password == null) {
fail(ctx, 400, "Missing required parameters: server, login, password");
return;
}
String signature = sha1(password);
String authUrl = "https://" + server + ":443/resto/api/auth?login=" + login + "&pass=" + signature;
webClient.getAbs(authUrl)
.as(BodyCodec.string())
.send()
.onSuccess(authResp -> {
if (authResp.statusCode() != 200) {
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
return;
}
String token = authResp.body();
String dataUrl;
if ("entity".equals(type)) {
dataUrl = "https://" + server + "/resto/api/v2/entities/list?key=" + token;
if (rootType != null && !rootType.isBlank()) {
dataUrl += "&rootType=" + rootType;
}
} else {
if (presetId == null || dateFrom == null || dateTo == null) {
fail(ctx, 400, "Missing presetId, dateFrom or dateTo for report request");
return;
}
dataUrl = "https://" + server + "/resto/api/v2/reports/olap/byPresetId/" + presetId +
"?key=" + token + "&dateFrom=" + dateFrom + "&dateTo=" + dateTo;
}
System.out.println("URL: " + dataUrl);
webClient.getAbs(dataUrl)
.as(BodyCodec.jsonObject())
.send()
.onSuccess(dataResp -> {
// logout (fire and forget)
webClient.getAbs("https://" + server + ":443/resto/api/logout?key=" + token)
.send()
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
if (dataResp.statusCode() == 200) {
JsonObject responseBody = dataResp.body();
if ("entity".equals(type)) {
ctx.response().setStatusCode(200).end(responseBody.encode());
} else {
Object data = responseBody.getValue("data");
if (data == null) {
ctx.response().setStatusCode(200).end(responseBody.encode());
} else {
// data может быть массивом, объектом или другим типом
ctx.response().setStatusCode(200).end(Json.encode(data));
}
}
} else {
fail(ctx, dataResp.statusCode(), "External API error: " + dataResp.statusMessage());
}
})
.onFailure(err -> fail(ctx, 500, "Data request failed: " + err.getMessage()));
})
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
}
private String sha1(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] digest = md.digest(input.getBytes());
return HexFormat.of().formatHex(digest).toLowerCase();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private void fail(RoutingContext ctx, int status, String message) {
System.err.println("Error: " + message);
ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode());
}
}

View File

@@ -18,5 +18,10 @@
"password": null,
"maxPoolSize": 6,
"maxWaitingHandlers": 6
},
"pma": {
"enabled": false,
"basePath": "/pma",
"upstream": "http://localhost:80/"
}
}