Auth: multibot TG MAX logging fix 500

This commit is contained in:
Fedor
2026-02-27 07:48:16 +03:00
parent b3a7396d32
commit 62fc57f108
6 changed files with 417 additions and 78 deletions

View File

@@ -0,0 +1,214 @@
/**
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
*/
import { useEffect, useState } from 'react';
import { Button, Form, Input, message as antMessage } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
export interface SupportLimits {
max_count: number;
max_size_per_file: number;
allowed_types: string;
unlimited: boolean;
}
export interface SupportFormProps {
/** Привязка к обращению (из карточки жалобы) */
claimId?: string;
/** bar | complaint_card */
source?: 'bar' | 'complaint_card';
/** После успешной отправки */
onSuccess?: () => void;
/** Компактный вид (модалка) */
compact?: boolean;
/** Скрыть заголовок «По обращению №…» когда передан claimId */
hideClaimLabel?: boolean;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
export default function SupportForm({
claimId,
source = 'bar',
onSuccess,
compact = false,
hideClaimLabel = false,
}: SupportFormProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [limits, setLimits] = useState<SupportLimits | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
useEffect(() => {
fetch('/api/v1/support/limits')
.then((res) => (res.ok ? res.json() : null))
.then((data: SupportLimits | null) => {
if (data) setLimits(data);
})
.catch(() => {});
}, []);
const canAddFile = (): boolean => {
if (!limits || limits.unlimited) return true;
return files.length < limits.max_count;
};
const isFileSizeOk = (file: File): boolean => {
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
return file.size <= limits.max_size_per_file;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (!limits?.unlimited && limits && limits.max_count > 0) {
const remaining = limits.max_count - files.length;
if (selected.length > remaining) {
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
setFileInputKey((k) => k + 1);
return;
}
}
const ok: File[] = [];
for (const f of selected) {
if (!isFileSizeOk(f)) {
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
continue;
}
ok.push(f);
}
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
setFileInputKey((k) => k + 1);
e.target.value = '';
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
const values = await form.validateFields().catch(() => null);
if (!values || !values.message?.trim()) return;
const token = getSessionToken();
if (!token) {
antMessage.error('Сессия не найдена. Войдите снова.');
return;
}
const fd = new FormData();
fd.append('message', values.message.trim());
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', {
method: 'POST',
body: fd,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
}
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
form.resetFields();
setFiles([]);
setFileInputKey((k) => k + 1);
onSuccess?.();
} catch (err) {
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
} finally {
setSubmitting(false);
}
};
const limitHint =
limits && !limits.unlimited
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
: null;
return (
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="message"
label="Сообщение"
rules={[{ required: true, message: 'Введите текст обращения' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
</Form.Item>
<Form.Item label="Прикрепить файлы">
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-attachments-input"
onChange={handleFileChange}
/>
<label htmlFor="support-attachments-input">
<Button
type="button"
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
disabled={!canAddFile()}
onClick={() => document.getElementById('support-attachments-input')?.click()}
>
Прикрепить файлы
</Button>
</label>
{files.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{files.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<button
type="button"
aria-label="Удалить"
onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -12,8 +12,10 @@ import {
ClipboardList,
FileWarning,
MessageCircle,
Scale,
} from 'lucide-react';
import './HelloAuth.css';
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
type Status = 'idle' | 'loading' | 'success' | 'error';
@@ -24,6 +26,8 @@ interface HelloAuthProps {
const INIT_DATA_WAIT_MS = 5500;
const INIT_DATA_POLL_MS = 200;
const INIT_DATA_BG_RECOVERY_MS = 15000;
const INIT_DATA_BG_TICK_MS = 250;
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
const [status, setStatus] = useState<Status>('idle');
@@ -59,6 +63,67 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
return null;
};
const authWithInitData = async (channel: 'telegram' | 'max', initData: string): Promise<'success' | 'need_contact' | 'error'> => {
miniappLog('auth_start', { channel, initDataLen: initData?.length ?? 0 });
const res = await fetch('/api/v1/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, init_data: initData }),
});
const data: Record<string, unknown> = await res.json().catch((e) => {
miniappLog('auth_json_error', { err: String(e), status: res.status });
return {};
});
miniappLog('auth_response', {
status: res.status,
ok: res.ok,
keys: Object.keys(data),
success: data.success,
need_contact: data.need_contact,
message: data.message,
detail: data.detail,
});
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
if (needContact) {
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
const doClose = () => {
try {
if (typeof webApp?.close === 'function') webApp.close();
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
} catch (_) {}
};
doClose();
setTimeout(doClose, 200);
return 'need_contact';
}
if (res.ok && data.success) {
const token = data.session_token as string | undefined;
if (token) {
try {
sessionStorage.setItem('session_token', token);
localStorage.setItem('session_token', token); // запас для TG: WebView иногда теряет sessionStorage при переходах
} catch (_) {}
}
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
const avatarUrl = user?.photo_url || (data.avatar_url as string);
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return 'success';
}
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
setStatus('error');
void miniappSendLogs('auth_error');
return 'error';
};
const tryAuth = async () => {
setStatus('loading');
setNoInitDataAfterTimeout(false);
@@ -85,55 +150,63 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
}
}
if (channelInit) {
const { channel, initData } = channelInit;
const res = await fetch('/api/v1/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, init_data: initData }),
});
const data: Record<string, unknown> = await res.json().catch(() => ({}));
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
if (needContact) {
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
const doClose = () => {
try {
if (typeof webApp?.close === 'function') webApp.close();
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
} catch (_) {}
};
doClose();
setTimeout(doClose, 200);
return;
}
if (res.ok && data.success) {
const token = data.session_token as string | undefined;
if (token) {
try {
sessionStorage.setItem('session_token', token);
} catch (_) {}
}
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
const avatarUrl = user?.photo_url || (data.avatar_url as string);
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return;
}
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
setStatus('error');
const result = await authWithInitData(channelInit.channel, channelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
return;
}
// 2) initData не появился за таймаут
// 2) initData не появился за таймаут — пробуем восстановить сессию по session_token (после обновления страницы)
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
if (likelyMiniapp) {
let token: string | null = null;
try {
token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
} catch (_) {}
if (token) {
try {
const verifyRes = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: token }),
});
const verifyData = await verifyRes.json().catch(() => ({}));
if (verifyData?.valid === true) {
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
setStatus('success');
return;
}
} catch (_) {}
}
// 2.5) TG после refresh иногда отдаёт initData с задержкой.
// Фоном ждём ещё немного и тихо повторяем auth, не показывая сразу экран ошибки.
const bgStart = Date.now();
const bgDeadline = bgStart + INIT_DATA_BG_RECOVERY_MS;
while (Date.now() < bgDeadline) {
await new Promise((r) => setTimeout(r, INIT_DATA_BG_TICK_MS));
const lateChannelInit = getChannelAndInitData();
if (!lateChannelInit) continue;
miniappLog('hello_init_data_recovered_bg', {
waited_ms: Date.now() - bgStart,
channel: lateChannelInit.channel,
});
const result = await authWithInitData(lateChannelInit.channel, lateChannelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
}
const ctx = {
url: window.location.href,
hasTgWebApp: !!(window as any).Telegram?.WebApp,
hasMaxWebApp: !!(window as any).WebApp,
tgInitDataLen: typeof (window as any).Telegram?.WebApp?.initData === 'string' ? (window as any).Telegram.WebApp.initData.length : 0,
maxInitDataLen: typeof (window as any).WebApp?.initData === 'string' ? (window as any).WebApp.initData.length : 0,
};
miniappLog('hello_no_init_data_after_timeout', ctx);
miniappSendLogs('no_init_data_after_timeout').catch(() => {});
setNoInitDataAfterTimeout(true);
setStatus('idle');
return;
@@ -255,6 +328,7 @@ if (data.avatar_url) {
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
{ title: 'Экспертиза', icon: Scale, color: '#0EA5E9' },
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
{ title: 'Членство', icon: IdCard, color: '#10B981' },
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },