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 := r.Group("/api")
|
||||||
|
|
||||||
api.Use(middleware.AuthMiddleware(accountRepo))
|
api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token))
|
||||||
{
|
{
|
||||||
// Drafts & Invoices
|
// Drafts & Invoices
|
||||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||||
|
|||||||
@@ -1,49 +1,129 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthMiddleware извлекает Telegram User ID и находит User UUID
|
// AuthMiddleware проверяет initData от Telegram
|
||||||
func AuthMiddleware(accountRepo account.Repository) gin.HandlerFunc {
|
func AuthMiddleware(accountRepo account.Repository, botToken string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// 1. Ищем в заголовке (стандартный путь)
|
// 1. Извлекаем данные авторизации
|
||||||
tgIDStr := c.GetHeader("X-Telegram-User-ID")
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
var initData string
|
||||||
|
|
||||||
// 2. Если нет в заголовке, ищем в Query (для отладки в браузере)
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
// Пример: /api/drafts?_tg_id=12345678
|
initData = strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
if tgIDStr == "" {
|
} else {
|
||||||
tgIDStr = c.Query("_tg_id")
|
// Оставляем лазейку для отладки ТОЛЬКО если это не production режим
|
||||||
|
// В реальности лучше всегда требовать подпись
|
||||||
|
initData = c.Query("_auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
if tgIDStr == "" {
|
if initData == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Telegram-User-ID header or _tg_id param"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Telegram ID"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Не удалось извлечь Telegram ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ищем пользователя в БД
|
// 4. Ищем пользователя в БД
|
||||||
user, err := accountRepo.GetUserByTelegramID(tgID)
|
user, err := accountRepo.GetUserByTelegramID(tgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Если пользователя нет - значит он не нажал /start в боте
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not registered via Bot. Please start the bot first."})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кладем UUID пользователя в контекст
|
|
||||||
c.Set("userID", user.ID)
|
c.Set("userID", user.ID)
|
||||||
c.Set("telegramID", tgID)
|
c.Set("telegramID", tgID)
|
||||||
|
|
||||||
c.Next()
|
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 { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { UNAUTHORIZED_EVENT } from "./services/api";
|
import { UNAUTHORIZED_EVENT } from "./services/api";
|
||||||
|
|
||||||
// Компонент заглушки для 401 ошибки
|
// Компонент-заглушка для внешних браузеров
|
||||||
const UnauthorizedScreen = () => (
|
const NotInTelegramScreen = () => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
@@ -18,15 +18,16 @@ const UnauthorizedScreen = () => (
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
|
padding: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Result
|
<Result
|
||||||
status="403"
|
status="warning"
|
||||||
title="Доступ запрещен"
|
title="Доступ ограничен"
|
||||||
subTitle="Мы не нашли вас в базе данных. Пожалуйста, запустите бота и настройте сервер."
|
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot для корректной работы."
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
||||||
Перейти в бота @RmserBot
|
Перейти в бота
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -35,20 +36,47 @@ const UnauthorizedScreen = () => (
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
|
||||||
|
// Проверяем, есть ли данные от Telegram
|
||||||
|
const isInTelegram = !!tg?.initData;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||||
|
|
||||||
// Подписываемся на событие из api.ts
|
|
||||||
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
|
|
||||||
|
if (tg) {
|
||||||
|
tg.expand(); // Расширяем приложение на все окно
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [tg]);
|
||||||
|
|
||||||
|
// Если открыто не в Telegram — блокируем всё
|
||||||
|
if (!isInTelegram) {
|
||||||
|
return <NotInTelegramScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если бэкенд вернул 401
|
||||||
if (isUnauthorized) {
|
if (isUnauthorized) {
|
||||||
return <UnauthorizedScreen />;
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="Ошибка доступа"
|
||||||
|
subTitle="Не удалось подтвердить вашу личность. Попробуйте обновить страницу внутри Telegram."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,14 +84,11 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
{/* Если Dashboard удален, можно сделать редирект на invoices */}
|
|
||||||
<Route index element={<Navigate to="/invoices" replace />} />
|
<Route index element={<Navigate to="/invoices" replace />} />
|
||||||
|
|
||||||
<Route path="ocr" element={<OcrLearning />} />
|
<Route path="ocr" element={<OcrLearning />} />
|
||||||
<Route path="invoices" element={<DraftsList />} />
|
<Route path="invoices" element={<DraftsList />} />
|
||||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { notification } from 'antd';
|
||||||
import type {
|
import type {
|
||||||
CatalogItem,
|
CatalogItem,
|
||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
@@ -30,9 +31,6 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
|||||||
// Телеграм объект
|
// Телеграм объект
|
||||||
const tg = window.Telegram?.WebApp;
|
const tg = window.Telegram?.WebApp;
|
||||||
|
|
||||||
// ID для локальной разработки (Fallback)
|
|
||||||
const DEBUG_USER_ID = 665599275;
|
|
||||||
|
|
||||||
// Событие для глобальной обработки 401
|
// Событие для глобальной обработки 401
|
||||||
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
||||||
|
|
||||||
@@ -43,31 +41,42 @@ const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Request Interceptor (Авторизация) ---
|
// --- Request Interceptor (Авторизация через initData) ---
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
// 1. Пробуем взять ID из Telegram WebApp
|
const initData = tg?.initData;
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (userId) {
|
// Если initData пустая — мы не в Telegram. Блокируем запрос.
|
||||||
config.headers['X-Telegram-User-ID'] = userId.toString();
|
if (!initData) {
|
||||||
|
console.error('Запрос заблокирован: приложение запущено вне Telegram.');
|
||||||
|
return Promise.reject(new Error('MISSING_TELEGRAM_DATA'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Устанавливаем заголовок согласно новым требованиям
|
||||||
|
config.headers['Authorization'] = `Bearer ${initData}`;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Response Interceptor (Обработка ошибок) ---
|
// --- Response Interceptor (Обработка ошибок и уведомления) ---
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
// Генерируем кастомное событие, которое поймает App.tsx
|
// Глобальное уведомление об ошибке авторизации
|
||||||
|
notification.error({
|
||||||
|
message: 'Ошибка авторизации',
|
||||||
|
description: 'Ваша сессия в Telegram истекла или данные неверны. Попробуйте перезапустить бота.',
|
||||||
|
placement: 'top',
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API
|
||||||
|
if (error.message === 'MISSING_TELEGRAM_DATA') {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
return Promise.reject(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" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface TelegramWebApp {
|
interface TelegramWebApp {
|
||||||
initData: string;
|
initData: string; // Сырая строка с параметрами и хешем
|
||||||
initDataUnsafe: {
|
initDataUnsafe: {
|
||||||
user?: {
|
user?: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,11 +10,9 @@ interface TelegramWebApp {
|
|||||||
username?: string;
|
username?: string;
|
||||||
language_code?: string;
|
language_code?: string;
|
||||||
};
|
};
|
||||||
// ... другие поля по необходимости
|
|
||||||
};
|
};
|
||||||
close: () => void;
|
close: () => void;
|
||||||
expand: () => void;
|
expand: () => void;
|
||||||
// ... другие методы
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
Reference in New Issue
Block a user