авторизация fixed

This commit is contained in:
2025-12-18 08:33:12 +03:00
parent 4e4571b3db
commit b5b9504019
6 changed files with 161 additions and 293 deletions

View File

@@ -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": "..."}
```

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 = () => (
<div
style={{
height: "100vh",
@@ -18,15 +18,16 @@ const UnauthorizedScreen = () => (
alignItems: "center",
justifyContent: "center",
background: "#fff",
padding: 20,
}}
>
<Result
status="403"
title="Доступ запрещен"
subTitle="Мы не нашли вас в базе данных. Пожалуйста, запустите бота и настройте сервер."
status="warning"
title="Доступ ограничен"
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot для корректной работы."
extra={
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
Перейти в бота @RmserBot
Перейти в бота
</Button>
}
/>
@@ -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 <NotInTelegramScreen />;
}
// Если бэкенд вернул 401
if (isUnauthorized) {
return <UnauthorizedScreen />;
return (
<div
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Result
status="403"
title="Ошибка доступа"
subTitle="Не удалось подтвердить вашу личность. Попробуйте обновить страницу внутри Telegram."
/>
</div>
);
}
return (
@@ -56,14 +84,11 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<AppLayout />}>
{/* Если Dashboard удален, можно сделать редирект на invoices */}
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<DraftsList />} />
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -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);
}

View File

@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
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 {