From b5b9504019452f289a3139b704708a4ce7cc5ee5 Mon Sep 17 00:00:00 2001 From: SERTY Date: Thu, 18 Dec 2025 08:33:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_DOCS.md | 244 --------------------- cmd/main.go | 2 +- internal/transport/http/middleware/auth.go | 114 ++++++++-- rmser-view/src/App.tsx | 51 +++-- rmser-view/src/services/api.ts | 39 ++-- rmser-view/src/vite-env.d.ts | 4 +- 6 files changed, 161 insertions(+), 293 deletions(-) delete mode 100644 API_DOCS.md diff --git a/API_DOCS.md b/API_DOCS.md deleted file mode 100644 index 1b59557..0000000 --- a/API_DOCS.md +++ /dev/null @@ -1,244 +0,0 @@ -# RMSER Backend API Documentation (v2.0) - -**Дата обновления:** Текущая -**Base URL:** `http://localhost:8080/api` - -## 1. Типы данных (TypeScript Interfaces) - -Используйте эти интерфейсы для строгой типизации на клиенте. - -```typescript -// Базовые типы -type UUID = string; - -// --- Каталог и Фасовки --- - -export interface MeasureUnit { - id: UUID; - name: string; // "кг", "л", "шт", "порц" - code: string; -} - -export interface ProductContainer { - id: UUID; - name: string; // "Коробка", "Пачка 200г" - count: number; // Коэффициент пересчета в базовые единицы (напр. 0.2 или 12.0) -} - -export interface CatalogItem { - id: UUID; - name: string; - code: string; - measure_unit: string; // Название базовой единицы (из MainUnit) - containers: ProductContainer[]; // Доступные фасовки -} - -// --- Матчинг (Обучение) --- - -export interface MatchRequest { - raw_name: string; // Текст из чека - product_id: UUID; // ID товара iiko - quantity: number; // Количество (по умолчанию 1.0) - container_id?: UUID; // Опционально: ID фасовки, если выбрана -} - -export interface SavedMatch { - raw_name: string; - product: CatalogItem; // Вложенный объект товара - quantity: number; // Сохраненный коэффициент/количество - container_id?: UUID; - container?: ProductContainer; // Вложенный объект фасовки (для отображения имени) - updated_at: string; -} - -// --- Нераспознанное --- - -export interface UnmatchedItem { - raw_name: string; - count: number; // Сколько раз встречалось в чеках - last_seen: string; // ISO Date -} -``` - ---- - -## 2. Эндпоинты: OCR и Обучение - -### `GET /ocr/catalog` -Получить полный справочник товаров для поиска. -Теперь включает **единицы измерения** и **фасовки**. - -* **Response:** `200 OK` - -```json -[ - { - "id": "uuid-butter...", - "name": "Масло сливочное 82%", - "code": "00123", - "measure_unit": "кг", - "containers": [ - { - "id": "uuid-pack...", - "name": "Пачка 180г", - "count": 0.180 - }, - { - "id": "uuid-box...", - "name": "Коробка (20 пачек)", - "count": 3.6 - } - ] - }, - { - "id": "uuid-milk...", - "name": "Молоко 3.2%", - "code": "00124", - "measure_unit": "л", - "containers": [] // Пустой массив, если фасовок нет - } -] -``` - ---- - -### `GET /ocr/matches` -Получить список уже обученных позиций. Используется для отображения таблицы "Ранее сохраненные связи". - -* **Response:** `200 OK` - -```json -[ - { - "raw_name": "Масло слив. 3 пачки", - "product_id": "uuid-butter...", - "product": { - "id": "uuid-butter...", - "name": "Масло сливочное 82%", - "measure_unit": "кг" - // ...остальные поля product - }, - "quantity": 3, - "container_id": "uuid-pack...", - "container": { - "id": "uuid-pack...", - "name": "Пачка 180г", - "count": 0.180 - }, - "updated_at": "2023-10-27T10:00:00Z" - } -] -``` -> **Логика отображения на фронте:** -> Если `container` пришел (не null) -> отображаем: `Quantity` x `Container.Name` (3 x Пачка 180г). -> Если `container` null -> отображаем: `Quantity` `Product.MeasureUnit` (0.54 кг). - ---- - -### `POST /ocr/match` -Создать или обновить привязку. - -* **Body:** - -**Вариант 1: Базовая единица (без фасовки)** -Пользователь выбрал "Петрушка", ввел "0.5" (кг). -```json -{ - "raw_name": "петрушка вес", - "product_id": "uuid-parsley...", - "quantity": 0.5 - // container_id не передаем или null -} -``` - -**Вариант 2: С фасовкой** -Пользователь выбрал "Масло", выбрал фасовку "Коробка", ввел "2" (штуки). -```json -{ - "raw_name": "масло коробка", - "product_id": "uuid-butter...", - "quantity": 2, - "container_id": "uuid-box..." -} -``` - -* **Response:** `200 OK` -> `{"status": "saved"}` - ---- - -### `GET /ocr/unmatched` -Получить список частых нераспознанных товаров. -Используется для автодополнения (Suggest) в поле ввода "Текст из чека", чтобы пользователь не вводил название вручную. - -* **Response:** `200 OK` - -```json -[ - { - "raw_name": "Хлеб Бородинский нар.", - "count": 12, - "last_seen": "2023-10-27T12:00:00Z" - }, - { - "raw_name": "Пакет майка", - "count": 5, - "last_seen": "2023-10-26T09:00:00Z" - } -] -``` - ---- - -## 3. Эндпоинты: Накладные (Invoices) - -### `POST /invoices/send` -Отправка накладной в iiko. - -**Важно:** При формировании накладной на фронте, вы должны пересчитывать количество в базовые единицы, либо (в будущем) мы научим бэкенд принимать ID фасовки в `items`. -На данный момент API ожидает `amount` и `price` уже приведенными к единому знаменателю, либо iiko сама разберется, если мы передадим ContainerID (этот функционал в разработке на стороне `rms_client`, пока шлите базовые единицы). - -```json -{ - "document_number": "INV-100", - "date_incoming": "2023-10-27", - "supplier_id": "uuid...", - "store_id": "uuid...", - "items": [ - { - "product_id": "uuid...", - "amount": 1.5, // 1.5 кг - "price": 100 // Цена за кг - } - ] -} -``` - ---- - -## 4. Эндпоинты: Аналитика - -### `GET /recommendations` -Возвращает список проблем (товары без техкарт, без закупок и т.д.). - -```json -[ - { - "ID": "uuid...", - "Type": "UNUSED_IN_RECIPES", - "ProductID": "uuid...", - "ProductName": "Лист салата", - "Reason": "Товар не используется ни в одной техкарте", - "CreatedAt": "..." - } -] -``` - ---- - -## 5. System - -### `GET /health` -Проверка статуса. -```json -{"status": "ok", "time": "..."} -``` \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 4795ef3..46ef81c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -115,7 +115,7 @@ func main() { api := r.Group("/api") - api.Use(middleware.AuthMiddleware(accountRepo)) + api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token)) { // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go index a215b26..4880393 100644 --- a/internal/transport/http/middleware/auth.go +++ b/internal/transport/http/middleware/auth.go @@ -1,49 +1,129 @@ package middleware import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" "net/http" + "net/url" + "sort" "strconv" + "strings" "rmser/internal/domain/account" "github.com/gin-gonic/gin" ) -// AuthMiddleware извлекает Telegram User ID и находит User UUID -func AuthMiddleware(accountRepo account.Repository) gin.HandlerFunc { +// AuthMiddleware проверяет initData от Telegram +func AuthMiddleware(accountRepo account.Repository, botToken string) gin.HandlerFunc { return func(c *gin.Context) { - // 1. Ищем в заголовке (стандартный путь) - tgIDStr := c.GetHeader("X-Telegram-User-ID") + // 1. Извлекаем данные авторизации + authHeader := c.GetHeader("Authorization") + var initData string - // 2. Если нет в заголовке, ищем в Query (для отладки в браузере) - // Пример: /api/drafts?_tg_id=12345678 - if tgIDStr == "" { - tgIDStr = c.Query("_tg_id") + if strings.HasPrefix(authHeader, "Bearer ") { + initData = strings.TrimPrefix(authHeader, "Bearer ") + } else { + // Оставляем лазейку для отладки ТОЛЬКО если это не production режим + // В реальности лучше всегда требовать подпись + initData = c.Query("_auth") } - if tgIDStr == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Telegram-User-ID header or _tg_id param"}) + if initData == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"}) return } - tgID, err := strconv.ParseInt(tgIDStr, 10, 64) + // 2. Проверяем подпись (HMAC-SHA256) + isValid, err := verifyTelegramInitData(initData, botToken) + if !isValid || err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: поддельная подпись"}) + return + } + + // 3. Извлекаем User ID из проверенных данных + values, _ := url.ParseQuery(initData) + userJSON := values.Get("user") + + // Извлекаем id вручную из JSON-подобной строки или через простой парсинг + // Telegram передает user как JSON-объект: {"id":12345,"first_name":"..."} + tgID, err := extractIDFromUserJSON(userJSON) if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Telegram ID"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Не удалось извлечь Telegram ID"}) return } - // Ищем пользователя в БД + // 4. Ищем пользователя в БД user, err := accountRepo.GetUserByTelegramID(tgID) if err != nil { - // Если пользователя нет - значит он не нажал /start в боте - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not registered via Bot. Please start the bot first."}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."}) return } - // Кладем UUID пользователя в контекст c.Set("userID", user.ID) c.Set("telegramID", tgID) - c.Next() } } + +// verifyTelegramInitData реализует алгоритм проверки Telegram +func verifyTelegramInitData(initData, token string) (bool, error) { + values, err := url.ParseQuery(initData) + if err != nil { + return false, err + } + + hash := values.Get("hash") + if hash == "" { + return false, fmt.Errorf("no hash found") + } + values.Del("hash") + + // Сортируем ключи + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + + // Собираем data_check_string + var dataCheckArr []string + for _, k := range keys { + dataCheckArr = append(dataCheckArr, fmt.Sprintf("%s=%s", k, values.Get(k))) + } + dataCheckString := strings.Join(dataCheckArr, "\n") + + // Вычисляем секретный ключ: HMAC-SHA256("WebAppData", token) + sha := sha256.New() + sha.Write([]byte(token)) + + secretKey := hmac.New(sha256.New, []byte("WebAppData")) + secretKey.Write([]byte(token)) + + // Вычисляем финальный HMAC + h := hmac.New(sha256.New, secretKey.Sum(nil)) + h.Write([]byte(dataCheckString)) + expectedHash := hex.EncodeToString(h.Sum(nil)) + + return expectedHash == hash, nil +} + +// Упрощенное извлечение ID из JSON-строки поля user +func extractIDFromUserJSON(userJSON string) (int64, error) { + // Ищем "id":(\d+) + // Для надежности в будущем можно использовать json.Unmarshal + startIdx := strings.Index(userJSON, "\"id\":") + if startIdx == -1 { + return 0, fmt.Errorf("id not found") + } + startIdx += 5 + endIdx := strings.IndexAny(userJSON[startIdx:], ",}") + if endIdx == -1 { + return 0, fmt.Errorf("invalid json") + } + + idStr := userJSON[startIdx : startIdx+endIdx] + return strconv.ParseInt(idStr, 10, 64) +} diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index c900fe5..ea63e91 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -9,8 +9,8 @@ import { DraftsList } from "./pages/DraftsList"; import { SettingsPage } from "./pages/SettingsPage"; import { UNAUTHORIZED_EVENT } from "./services/api"; -// Компонент заглушки для 401 ошибки -const UnauthorizedScreen = () => ( +// Компонент-заглушка для внешних браузеров +const NotInTelegramScreen = () => (
( alignItems: "center", justifyContent: "center", background: "#fff", + padding: 20, }} > - Перейти в бота @RmserBot + Перейти в бота } /> @@ -35,20 +36,47 @@ const UnauthorizedScreen = () => ( function App() { const [isUnauthorized, setIsUnauthorized] = useState(false); + const tg = window.Telegram?.WebApp; + + // Проверяем, есть ли данные от Telegram + const isInTelegram = !!tg?.initData; useEffect(() => { const handleUnauthorized = () => setIsUnauthorized(true); - - // Подписываемся на событие из api.ts window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); + if (tg) { + tg.expand(); // Расширяем приложение на все окно + } + return () => { window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); }; - }, []); + }, [tg]); + // Если открыто не в Telegram — блокируем всё + if (!isInTelegram) { + return ; + } + + // Если бэкенд вернул 401 if (isUnauthorized) { - return ; + return ( +
+ +
+ ); } return ( @@ -56,14 +84,11 @@ function App() { }> - {/* Если Dashboard удален, можно сделать редирект на invoices */} } /> - } /> } /> } /> } /> - } /> diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index b1ef671..6b7ebea 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { notification } from 'antd'; import type { CatalogItem, CreateInvoiceRequest, @@ -30,9 +31,6 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; // Телеграм объект const tg = window.Telegram?.WebApp; -// ID для локальной разработки (Fallback) -const DEBUG_USER_ID = 665599275; - // Событие для глобальной обработки 401 export const UNAUTHORIZED_EVENT = 'rms_unauthorized'; @@ -43,31 +41,42 @@ const apiClient = axios.create({ }, }); -// --- Request Interceptor (Авторизация) --- +// --- Request Interceptor (Авторизация через initData) --- apiClient.interceptors.request.use((config) => { - // 1. Пробуем взять ID из Telegram WebApp - // 2. Ищем в URL параметрах (удобно для тестов в браузере: ?_tg_id=123) - // 3. Используем хардкод для локальной разработки - const urlParams = new URLSearchParams(window.location.search); - const paramId = urlParams.get('_tg_id'); - - const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID; + const initData = tg?.initData; - if (userId) { - config.headers['X-Telegram-User-ID'] = userId.toString(); + // Если initData пустая — мы не в Telegram. Блокируем запрос. + if (!initData) { + console.error('Запрос заблокирован: приложение запущено вне Telegram.'); + return Promise.reject(new Error('MISSING_TELEGRAM_DATA')); } + + // Устанавливаем заголовок согласно новым требованиям + config.headers['Authorization'] = `Bearer ${initData}`; return config; }); -// --- Response Interceptor (Обработка ошибок) --- +// --- Response Interceptor (Обработка ошибок и уведомления) --- apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response && error.response.status === 401) { - // Генерируем кастомное событие, которое поймает App.tsx + // Глобальное уведомление об ошибке авторизации + notification.error({ + message: 'Ошибка авторизации', + description: 'Ваша сессия в Telegram истекла или данные неверны. Попробуйте перезапустить бота.', + placement: 'top', + }); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); } + + // Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API + if (error.message === 'MISSING_TELEGRAM_DATA') { + return Promise.reject(error); + } + console.error('API Error:', error); return Promise.reject(error); } diff --git a/rmser-view/src/vite-env.d.ts b/rmser-view/src/vite-env.d.ts index 45d0dd5..f403597 100644 --- a/rmser-view/src/vite-env.d.ts +++ b/rmser-view/src/vite-env.d.ts @@ -1,7 +1,7 @@ /// interface TelegramWebApp { - initData: string; + initData: string; // Сырая строка с параметрами и хешем initDataUnsafe: { user?: { id: number; @@ -10,11 +10,9 @@ interface TelegramWebApp { username?: string; language_code?: string; }; - // ... другие поля по необходимости }; close: () => void; expand: () => void; - // ... другие методы } interface Window {