mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
авторизация fixed
This commit is contained in:
244
API_DOCS.md
244
API_DOCS.md
@@ -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": "..."}
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 initData = tg?.initData;
|
||||
|
||||
const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
4
rmser-view/src/vite-env.d.ts
vendored
4
rmser-view/src/vite-env.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user