Расположение: /home/user/work/luckfox/mybeacon-legacy/www/my-wifi/panel/
Технологии:
configs (устройства)CREATE TABLE `configs` (
`id` int(11) PRIMARY KEY AUTO_INCREMENT,
`time` timestamp DEFAULT current_timestamp(), -- Last seen
`mac` varchar(40) UNIQUE NOT NULL,
`wf_client_ssid` varchar(60), -- WiFi SSID для подключения
`wf_client_psk` varchar(60), -- WiFi пароль
`ovpn_flag` tinyint(1), -- OpenVPN включен
`ovpn_addr` varchar(60), -- URL для получения VPN конфига
`wf_flag` tinyint(1), -- WiFi сбор включен
`wf_addr` varchar(60), -- URL для отправки WiFi данных
`bt_flag` tinyint(1), -- BLE сбор включен
`bt_addr` varchar(60), -- URL для отправки BLE данных
`fw_flag` tinyint(1), -- Firmware update включен
`fw_addr` varchar(60), -- URL для получения firmware
`reboot_flag` tinyint(1), -- Флаг перезагрузки
`ip` varchar(40) -- IP адрес устройства
);
users (клиенты/организации)CREATE TABLE `users` (
`id` int(11) PRIMARY KEY AUTO_INCREMENT,
`login` text NOT NULL,
`password` text NOT NULL,
`device` text NOT NULL, -- MAC-адреса через ";" (b8:27:eb:c1:46:0e;cc:2d:e0:ca:9f:7e;)
`addTime` timestamp DEFAULT current_timestamp(),
`dopinfo` text, -- Доп. информация о клиенте
`paidTill` text, -- Текст об оплате
`folder` text, -- Папка для файлов (не использовалось)
`till` date DEFAULT '2022-05-03', -- Дата оплаты до
`dopfolder` varchar(128) -- Доп. папка (не использовалось)
);
user_devices (связь)CREATE TABLE `user_devices` (
`mac` varchar(40),
`name` varchar(255),
`user_id` int(11),
`max1000` tinyint(1), -- Лимит (0=оплачен, 1=лимит 1000)
`segment` tinyint(1) -- Для Yandex.Аудитории
);
1. Устройства (index.php) - главная страница, список всех устройств
2. Добавление (adddev.php) - форма добавления клиента с устройствами
3. Клиенты (clients.php) - список клиентов
4. Инструкция (info.php) - документация
5. VPN (внешняя ссылка) - сканер VPN подключений
Колонки:
# - порядковый номерMAC-адрес - читаемый (не редактируемый)время - last seen (не редактируемый)wf_ssid - РЕДАКТИРУЕМАЯ (inline editing)wf_psk - РЕДАКТИРУЕМАЯovpn_flag - РЕДАКТИРУЕМАЯovpn_addr - РЕДАКТИРУЕМАЯwf_flag - РЕДАКТИРУЕМАЯwf_addr - РЕДАКТИРУЕМАЯbt_flag - РЕДАКТИРУЕМАЯbt_addr - РЕДАКТИРУЕМАЯfw_flag - РЕДАКТИРУЕМАЯfw_addr - РЕДАКТИРУЕМАЯreboot_flag - РЕДАКТИРУЕМАЯip - РЕДАКТИРУЕМАЯБиблиотека: jEditable
Как работает:
.editable-td)<input> с кнопками "Ok" и "Cancel"При нажатии "Ok" отправляется AJAX запрос:
$.get('savewf.php', {
p1: value, // новое значение
p2: $(this).attr('id'), // ID ячейки (имя поля)
p3: $(this).parent('tr').attr('id') // ID строки (id записи)
})
Backend (savewf.php) выполняет SQL:
UPDATE `configs` SET `{field_name}` = '{value}' WHERE `id`='{id}'
Ячейка возвращается к обычному виду с новым значением
Библиотека: DataTables
Функционал:
Код:
var editableTable = exampleDatatable.dataTable({
order: [[ 1, 'desc' ]], // Сортировка по ID по убыванию
columnDefs: [ { orderable: false, targets: [ 0 ] } ]
});
$('.dataTables_filter input').attr('placeholder', 'Search');
Колонки:
# - порядковый номерЛогинПарольУстройства - MAC-адреса через <br> (разделены по ;)Оплачено до - ссылка на редактирование датыИнформация - допинфо о клиентеОплата - текст статуса оплатыimplode('<br>', explode(';', $line['device'])))<a href="tilledit.php?id={id}">{till}</a>Поля:
Логин - requiredПароль - requiredСписок устройств - textarea, через ; в конце
Пример: d8:0d:17:5e:07:94;ac:84:c6:42:17:90;Информация о клиенте - textarea (юрлицо, имя, телефон, email)Оплата - textarea (текст до какого числа оплачено)Оплачен - checkbox (max1000: 0=оплачен, 1=лимит)// 1. Валидация
if (login == null || password == null || device == null) {
error("Поля логин, пароль и устройства не могут быть пустыми");
}
// 2. Проверка существования логина
$query = "SELECT * FROM `users` WHERE `login` = '{login}'";
if (row_count > 0) {
error("Логин уже занят");
}
// 3. Создание пользователя
INSERT INTO `users` (login, password, device, dopinfo, paidTill, till)
VALUES ('{login}', '{password}', '{device}', '{dopinfo}', '{paidTill}', CURDATE());
$user_id = mysqli_insert_id();
// 4. Создание связей user_devices
$macs = explode(";", device);
foreach ($macs as $mac) {
INSERT INTO `user_devices` (mac, name, user_id, max1000, segment)
VALUES ('{mac}', '{mac}', {user_id}, {max1000}, 0);
}
SQL Injection - прямая конкатенация в запросах:
$query = "UPDATE `configs` SET `".$_REQUEST['p2']."` = '".$_REQUEST['p1']."' WHERE `id`='".$_REQUEST['p3']."'";
XSS - нет экранирования вывода
Пароли в открытом виде - хранятся как plain text
Нет CSRF защиты
Нет валидации типов данных
Хардкод credentials в каждом файле:
$dbname = 'wifi';
$dbuser = 'p328882_wifi';
$dbpass = '0354c0598ld';
Связь many-to-many через текст - MAC-адреса через ;
Почему важно:
Реализация в новом проекте:
Backend:
# backend/app/api/v1/superadmin/devices.py или client/devices.py
@router.get("/devices", response_model=DeviceListResponse)
async def get_devices(
search: Optional[str] = None,
organization_id: Optional[int] = None,
offset: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(Device)
# Фильтр по организации (для multi-tenant)
if current_user.role != "superadmin":
query = query.where(Device.organization_id == current_user.organization_id)
elif organization_id:
query = query.where(Device.organization_id == organization_id)
# Универсальный поиск
if search:
search_filter = or_(
Device.mac_address.ilike(f"%{search}%"),
Device.simple_id.cast(String).ilike(f"%{search}%"),
Organization.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
User.full_name.ilike(f"%{search}%")
)
query = (
query
.outerjoin(Organization)
.outerjoin(User, User.organization_id == Device.organization_id)
.where(search_filter)
)
total = await db.scalar(select(func.count()).select_from(query.subquery()))
devices = await db.scalars(query.offset(offset).limit(limit))
return DeviceListResponse(devices=devices.all(), total=total)
Frontend (Vue 3):
<!-- frontend/src/views/superadmin/DevicesView.vue -->
<template>
<div class="page">
<div class="page-header">
<h1>{{ $t('devices.title') }}</h1>
<!-- Поле поиска -->
<div class="search-box">
<input
v-model="searchQuery"
@input="debouncedSearch"
:placeholder="$t('devices.searchPlaceholder')"
type="text"
/>
<i class="search-icon">🔍</i>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>{{ $t('devices.simpleId') }}</th>
<th>{{ $t('devices.macAddress') }}</th>
<th>{{ $t('devices.organization') }}</th>
<th>{{ $t('common.status') }}</th>
<th>{{ $t('devices.lastSeen') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.id">
<td>#{{ device.simple_id }}</td>
<td><code>{{ device.mac_address }}</code></td>
<td>{{ getOrganizationName(device.organization_id) }}</td>
<td><span class="badge" :class="`status-${device.status}`">{{ device.status }}</span></td>
<td>{{ formatDate(device.last_seen_at) }}</td>
</tr>
</tbody>
</table>
<!-- Пагинация -->
<div class="pagination">
<button @click="prevPage" :disabled="offset === 0">Prev</button>
<span>{{ currentPage }} / {{ totalPages }}</span>
<button @click="nextPage" :disabled="!hasMore">Next</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { debounce } from 'lodash-es'
import devicesApi from '@/api/devices'
const devices = ref([])
const searchQuery = ref('')
const offset = ref(0)
const limit = ref(50)
const total = ref(0)
const hasMore = computed(() => offset.value + limit.value < total.value)
const totalPages = computed(() => Math.ceil(total.value / limit.value))
const currentPage = computed(() => Math.floor(offset.value / limit.value) + 1)
async function loadDevices() {
try {
const response = await devicesApi.getAllSuperadmin({
search: searchQuery.value,
offset: offset.value,
limit: limit.value
})
devices.value = response.devices
total.value = response.total
} catch (err) {
console.error(err)
}
}
const debouncedSearch = debounce(() => {
offset.value = 0 // Reset to first page
loadDevices()
}, 300) // 300ms delay
function nextPage() {
offset.value += limit.value
loadDevices()
}
function prevPage() {
offset.value = Math.max(0, offset.value - limit.value)
loadDevices()
}
onMounted(loadDevices)
</script>
Переводы (i18n):
// frontend/src/i18n/index.js
const messages = {
ru: {
devices: {
searchPlaceholder: 'Поиск по MAC, организации, владельцу...'
}
},
en: {
devices: {
searchPlaceholder: 'Search by MAC, organization, owner...'
}
}
}
Проблемы:
Решение: Модальные окна для редактирования (как сейчас)
;) ❌Проблема: device text NOT NULL - "b8:27:eb:c1:46:0e;cc:2d:e0:ca:9f:7e;"
Решение: Нормализованная БД с foreign keys (как сейчас)
Из ТЗ: "Сейчас у нас VPN не будет"
Убираем:
ovpn_flagovpn_addrLegacy имело:
wf_flag + wf_addr (WiFi)bt_flag + bt_addr (BLE)fw_flag + fw_addr (Firmware)Новый подход:
wifi_enabled, ble_enabledLegacy: wf_client_ssid и wf_client_psk в таблице configs
Проблема:
Решение:
Для новой админки:
Глобальный поиск (текстовое поле)
Фильтр по организации (dropdown для superadmin)
Фильтр по статусу (chips/badges)
Сортировка
Колонки (минимум):
| Колонка | Описание | Для Superadmin | Для Client |
|---|---|---|---|
| Simple ID | #1, #2, #3 | ✅ | ✅ |
| MAC Address | ac:84:c6:d4:9c:c4 |
✅ | ✅ |
| Organization | Название организации | ✅ | ❌ (скрыта) |
| Owner | Email владельца | ✅ | ✅ |
| Status | online/offline/error | ✅ | ✅ |
| Last Seen | "2 минуты назад" | ✅ | ✅ |
| Actions | Edit, Delete | ✅ | Owner/Admin only |
Общая информация:
Конфигурация (JSONB):
{
"wifi_enabled": true,
"ble_enabled": true,
"upload_interval": 300,
"scan_interval": 60,
"custom_settings": {}
}
История подключений:
Действия:
| Функция | Legacy | Новый проект |
|---|---|---|
| Аутентификация | ❌ Нет | ✅ JWT (access + refresh) |
| Multi-tenant | ❌ Все в одной куче | ✅ Изоляция по organization_id |
| Роли | ❌ Нет | ✅ 6 ролей (superadmin, owner, admin, manager, operator, viewer) |
| Поиск | ✅ По всем полям | ✅ По MAC, org, user, simple_id |
| Редактирование | ✅ Inline (опасно) | ✅ Модальные окна с валидацией |
| Simple ID | ❌ Нет | ✅ Auto-increment (#1, #2, #3) |
| OpenVPN | ✅ Было | ❌ Убрали |
| Audit Logs | ❌ Нет | ✅ Полное логирование |
| Статусы | ❌ Нет | ✅ online/offline/error |
| Пагинация | ✅ DataTables | ✅ Backend pagination |
| i18n | ❌ Только русский | ✅ RU/EN |
Безопасность на первом месте:
Multi-tenant архитектура:
UX улучшения:
Современный стек:
;