fix user UI 9

This commit is contained in:
TronoSfera 2026-03-03 17:50:17 +03:00
parent f833c0a9aa
commit 46234f6d51
10 changed files with 777 additions and 202 deletions

View file

@ -285,6 +285,31 @@ def upload_complete(
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
existing_row = (
db.query(Attachment)
.filter(Attachment.request_id == request.id, Attachment.s3_key == payload.key)
.first()
)
if existing_row is not None:
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=True,
object_key=payload.key,
request_id=request.id,
details={
"mime_type": existing_row.mime_type,
"size_bytes": int(existing_row.size_bytes or 0),
"idempotent_replay": True,
},
responsible=responsible,
)
db.commit()
return UploadCompleteResponse(status="ok", attachment_id=str(existing_row.id))
_ensure_case_capacity_or_400(request, actual_size)
message_uuid = None

View file

@ -1,6 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from uuid import UUID
from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import and_
from sqlalchemy.orm import Session
@ -8,10 +12,15 @@ from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.landing_featured_staff import LandingFeaturedStaff
from app.models.topic import Topic
from app.services.s3_storage import get_s3_storage
router = APIRouter()
def _featured_avatar_proxy_path(admin_user_id: str) -> str:
return "/api/public/featured-staff/avatar/" + str(admin_user_id)
@router.get("")
def list_featured_staff(
limit: int = Query(20, ge=1, le=100),
@ -46,6 +55,10 @@ def list_featured_staff(
role_code = str(user.role or "").upper()
role_label = "Администратор" if role_code == "ADMIN" else "Юрист"
primary_topic_code = str(user.primary_topic_code or "").strip() or None
raw_avatar_url = str(user.avatar_url or "").strip()
avatar_url = raw_avatar_url
if raw_avatar_url.startswith("s3://"):
avatar_url = _featured_avatar_proxy_path(str(user.id))
result.append(
{
"id": str(slot.id),
@ -53,7 +66,7 @@ def list_featured_staff(
"name": user.name,
"role": role_code,
"role_label": role_label,
"avatar_url": user.avatar_url,
"avatar_url": avatar_url,
"caption": str(slot.caption or "").strip() or None,
"pinned": bool(slot.pinned),
"sort_order": int(slot.sort_order or 0),
@ -62,3 +75,52 @@ def list_featured_staff(
}
)
return {"items": result, "total": len(result)}
@router.get("/avatar/{admin_user_id}")
def get_featured_staff_avatar(
admin_user_id: str,
db: Session = Depends(get_db),
):
try:
user_uuid = UUID(str(admin_user_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный id пользователя")
row = (
db.query(AdminUser.avatar_url)
.join(LandingFeaturedStaff, LandingFeaturedStaff.admin_user_id == AdminUser.id)
.filter(
LandingFeaturedStaff.enabled.is_(True),
AdminUser.id == user_uuid,
AdminUser.is_active.is_(True),
AdminUser.role.in_(("ADMIN", "LAWYER")),
AdminUser.avatar_url.is_not(None),
and_(AdminUser.avatar_url != ""),
)
.first()
)
if row is None:
raise HTTPException(status_code=404, detail="Аватар не найден")
raw_avatar_url = str(row[0] or "").strip()
if not raw_avatar_url.startswith("s3://"):
raise HTTPException(status_code=404, detail="Аватар не найден")
key = raw_avatar_url[len("s3://") :].strip()
if not key.startswith("avatars/" + str(user_uuid) + "/"):
raise HTTPException(status_code=404, detail="Аватар не найден")
try:
obj = get_s3_storage().get_object(key)
except ClientError:
raise HTTPException(status_code=404, detail="Аватар не найден")
body = obj.get("Body")
if body is None or not hasattr(body, "iter_chunks"):
raise HTTPException(status_code=500, detail="Не удалось открыть аватар")
media_type = str(obj.get("ContentType") or "application/octet-stream")
content_length = obj.get("ContentLength")
headers = {}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)

View file

@ -185,6 +185,31 @@ def upload_complete(
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session)
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
existing_row = (
db.query(Attachment)
.filter(Attachment.request_id == request.id, Attachment.s3_key == payload.key)
.first()
)
if existing_row is not None:
record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=True,
object_key=payload.key,
request_id=request.id,
details={
"mime_type": existing_row.mime_type,
"size_bytes": int(existing_row.size_bytes or 0),
"idempotent_replay": True,
},
responsible="Клиент",
)
db.commit()
return UploadCompleteResponse(status="ok", attachment_id=str(existing_row.id))
storage = get_s3_storage()
try:

View file

@ -5764,7 +5764,9 @@
}
if (!response.ok) {
const message = payload && (payload.detail || payload.error || payload.raw) || "HTTP " + response.status;
throw new Error(translateApiError(String(message)));
const error = new Error(translateApiError(String(message)));
error.httpStatus = Number(response.status || 0);
throw error;
}
return payload;
},
@ -6025,6 +6027,7 @@
bank_account: "40702810501860000582",
bank_corr_account: "30101810200000000593"
});
var UPLOAD_MAX_ATTEMPTS = 4;
async function buildStorageUploadError(response, fallbackMessage) {
const base = String(fallbackMessage || "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435");
const status = Number((response == null ? void 0 : response.status) || 0);
@ -6041,6 +6044,22 @@
if (details) parts.push(details);
return parts.length ? base + " (" + parts.join("; ") + ")" : base;
}
function wait(ms) {
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
function nextUploadRetryDelayMs(attempt) {
const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7e3);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}
function isRetryableUploadError(error) {
const status = Number((error == null ? void 0 : error.httpStatus) || (error == null ? void 0 : error.status) || 0);
if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
if (status > 0) return false;
const message = String((error == null ? void 0 : error.message) || "").toLowerCase();
if (!message) return true;
return message.includes("networkerror") || message.includes("failed to fetch") || message.includes("load failed") || message.includes("network request failed") || message.includes("timeout");
}
function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React;
const opts = options || {};
@ -6086,6 +6105,70 @@
const clearRequestModalFiles = useCallback(() => {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
}, []);
const uploadRequestAttachmentWithRetry = useCallback(
async ({ requestId, file, messageId }) => {
if (!api) throw new Error("API \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D");
const targetRequestId = String(requestId || "").trim();
if (!targetRequestId) throw new Error("\u041D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u0430 \u0437\u0430\u044F\u0432\u043A\u0430");
if (!file) throw new Error("\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D");
const mimeType = String(file.type || "application/octet-stream");
const runUploadStepWithRetry = async (label, action) => {
let lastError = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) {
attemptsUsed = attempt;
try {
return await action(attempt);
} catch (error) {
lastError = error;
const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error);
if (!canRetry) break;
await wait(nextUploadRetryDelayMs(attempt));
}
}
const reason = String((lastError == null ? void 0 : lastError.message) || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438");
throw new Error(label + ": " + reason + " (\u043F\u043E\u043F\u044B\u0442\u043E\u043A: " + attemptsUsed + ")");
};
const init = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => {
return api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId
}
});
});
await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435", async () => {
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file
});
if (putResp.ok) return null;
const error = new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"));
error.httpStatus = Number(putResp.status || 0);
throw error;
});
return runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => {
return api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
message_id: messageId || null
}
});
});
},
[api]
);
const loadRequestModalData = useCallback(
async (requestId, loadOptions) => {
if (!api || !requestId) return;
@ -6261,35 +6344,7 @@
messageId = String((message == null ? void 0 : message.id) || "").trim() || null;
}
for (const file of files) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId
}
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file
});
if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"));
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: messageId
}
});
await uploadRequestAttachmentWithRetry({ requestId, file, messageId });
}
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
const successMessage = body && files.length ? "\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u0438 \u0444\u0430\u0439\u043B\u044B \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B" : files.length ? "\u0424\u0430\u0439\u043B\u044B \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B" : "\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E";
@ -6300,7 +6355,15 @@
if (typeof setStatus === "function") setStatus("requestModal", "\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438: " + error.message, "error");
}
},
[api, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setStatus]
[
api,
loadRequestModalData,
requestModal.messageDraft,
requestModal.requestId,
requestModal.selectedFiles,
setStatus,
uploadRequestAttachmentWithRetry
]
);
const loadRequestDataTemplates = useCallback(
async (documentName) => {
@ -6420,41 +6483,13 @@
messageId = String((message == null ? void 0 : message.id) || "").trim() || null;
}
for (const file of attachedFiles) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId
}
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file
});
if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"));
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
message_id: messageId
}
});
await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId });
}
if (typeof setStatus === "function") setStatus("requestModal", "\u0421\u0442\u0430\u0442\u0443\u0441 \u0437\u0430\u044F\u0432\u043A\u0438 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D", "ok");
await loadRequestModalData(targetRequestId, { showLoading: false });
return result;
},
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus]
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry]
);
const issueRequestInvoice = useCallback(
async ({ requestId, amount, serviceDescription, payerDisplayName } = {}) => {

View file

@ -30,7 +30,9 @@ export function useAdminApi(token) {
if (!response.ok) {
const message = (payload && (payload.detail || payload.error || payload.raw)) || "HTTP " + response.status;
throw new Error(translateApiError(String(message)));
const error = new Error(translateApiError(String(message)));
error.httpStatus = Number(response.status || 0);
throw error;
}
return payload;

View file

@ -11,6 +11,7 @@ const DEFAULT_INVOICE_REQUISITES = Object.freeze({
bank_account: "40702810501860000582",
bank_corr_account: "30101810200000000593",
});
const UPLOAD_MAX_ATTEMPTS = 4;
async function buildStorageUploadError(response, fallbackMessage) {
const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище");
@ -29,6 +30,31 @@ async function buildStorageUploadError(response, fallbackMessage) {
return parts.length ? base + " (" + parts.join("; ") + ")" : base;
}
function wait(ms) {
return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
function nextUploadRetryDelayMs(attempt) {
const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7000);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}
function isRetryableUploadError(error) {
const status = Number(error?.httpStatus || error?.status || 0);
if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
if (status > 0) return false;
const message = String(error?.message || "").toLowerCase();
if (!message) return true;
return (
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("load failed") ||
message.includes("network request failed") ||
message.includes("timeout")
);
}
export function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React;
const opts = options || {};
@ -85,6 +111,73 @@ export function useRequestWorkspace(options) {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
}, []);
const uploadRequestAttachmentWithRetry = useCallback(
async ({ requestId, file, messageId }) => {
if (!api) throw new Error("API недоступен");
const targetRequestId = String(requestId || "").trim();
if (!targetRequestId) throw new Error("Не выбрана заявка");
if (!file) throw new Error("Файл не выбран");
const mimeType = String(file.type || "application/octet-stream");
const runUploadStepWithRetry = async (label, action) => {
let lastError = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) {
attemptsUsed = attempt;
try {
return await action(attempt);
} catch (error) {
lastError = error;
const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error);
if (!canRetry) break;
await wait(nextUploadRetryDelayMs(attempt));
}
}
const reason = String(lastError?.message || "Ошибка сети");
throw new Error(label + ": " + reason + " (попыток: " + attemptsUsed + ")");
};
const init = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => {
return api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
},
});
});
await runUploadStepWithRetry("Не удалось загрузить файл в хранилище", async () => {
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (putResp.ok) return null;
const error = new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище"));
error.httpStatus = Number(putResp.status || 0);
throw error;
});
return runUploadStepWithRetry("Не удалось завершить загрузку файла", async () => {
return api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
message_id: messageId || null,
},
});
});
},
[api]
);
const loadRequestModalData = useCallback(
async (requestId, loadOptions) => {
if (!api || !requestId) return;
@ -264,35 +357,7 @@ export function useRequestWorkspace(options) {
}
for (const file of files) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
},
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище"));
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: messageId,
},
});
await uploadRequestAttachmentWithRetry({ requestId, file, messageId });
}
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
@ -304,7 +369,15 @@ export function useRequestWorkspace(options) {
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка отправки: " + error.message, "error");
}
},
[api, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setStatus]
[
api,
loadRequestModalData,
requestModal.messageDraft,
requestModal.requestId,
requestModal.selectedFiles,
setStatus,
uploadRequestAttachmentWithRetry,
]
);
const loadRequestDataTemplates = useCallback(
@ -439,42 +512,14 @@ export function useRequestWorkspace(options) {
messageId = String(message?.id || "").trim() || null;
}
for (const file of attachedFiles) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
},
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище"));
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
message_id: messageId,
},
});
await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId });
}
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
await loadRequestModalData(targetRequestId, { showLoading: false });
return result;
},
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus]
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry]
);
const issueRequestInvoice = useCallback(

View file

@ -2480,6 +2480,7 @@
}
function App() {
var _a, _b;
const UPLOAD_MAX_ATTEMPTS = 4;
const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState("");
@ -2513,7 +2514,11 @@
window.location.href = "/";
throw new Error("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430");
}
if (!response.ok) throw new Error(apiError(data, fallbackMessage || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430"));
if (!response.ok) {
const error = new Error(apiError(data, fallbackMessage || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430"));
error.httpStatus = Number(response.status || 0);
throw error;
}
return data;
}, []);
const buildStorageUploadError = useCallback(async (response, fallbackMessage) => {
@ -2532,50 +2537,95 @@
if (details) parts.push(details);
return parts.length ? base + " (" + parts.join("; ") + ")" : base;
}, []);
const wait = useCallback(async (ms) => {
await new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}, []);
const nextUploadRetryDelayMs = useCallback((attempt) => {
const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7e3);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}, []);
const isRetryableUploadError = useCallback((error) => {
const status2 = Number((error == null ? void 0 : error.httpStatus) || (error == null ? void 0 : error.status) || 0);
if ([408, 425, 429, 500, 502, 503, 504].includes(status2)) return true;
if (status2 > 0) return false;
const message = String((error == null ? void 0 : error.message) || "").toLowerCase();
if (!message) return true;
return message.includes("networkerror") || message.includes("failed to fetch") || message.includes("load failed") || message.includes("network request failed") || message.includes("timeout");
}, []);
const runUploadStepWithRetry = useCallback(
async (label, action) => {
let lastError = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) {
attemptsUsed = attempt;
try {
return await action(attempt);
} catch (error) {
lastError = error;
const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error);
if (!canRetry) break;
await wait(nextUploadRetryDelayMs(attempt));
}
}
const reason = String((lastError == null ? void 0 : lastError.message) || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438");
throw new Error(label + ": " + reason + " (\u043F\u043E\u043F\u044B\u0442\u043E\u043A: " + attemptsUsed + ")");
},
[UPLOAD_MAX_ATTEMPTS, isRetryableUploadError, nextUploadRetryDelayMs, wait]
);
const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
const requestId = String(requestModal.requestId || "").trim();
if (!requestId) throw new Error("\u041D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u0430 \u0437\u0430\u044F\u0432\u043A\u0430");
const mimeType = String((file == null ? void 0 : file.type) || "application/octet-stream");
const initData = await apiJson(
"/api/public/uploads/init",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId
})
},
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430"
);
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file
const initData = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => {
return apiJson(
"/api/public/uploads/init",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId
})
},
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430"
);
});
await runUploadStepWithRetry("\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435", async () => {
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file
});
if (putResponse.ok) return null;
const error = new Error(await buildStorageUploadError(putResponse, "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"));
error.httpStatus = Number(putResponse.status || 0);
throw error;
});
const completeData = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => {
return apiJson(
"/api/public/uploads/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: (extra == null ? void 0 : extra.message_id) || null
})
},
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430"
);
});
if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"));
const completeData = await apiJson(
"/api/public/uploads/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: (extra == null ? void 0 : extra.message_id) || null
})
},
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430"
);
return completeData;
}, [apiJson, buildStorageUploadError, requestModal.requestId]);
}, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
const loadRequestWorkspace = useCallback(
async (trackNumber, showLoading) => {
const track = String(trackNumber || "").trim().toUpperCase();

View file

@ -414,6 +414,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
}
function App() {
const UPLOAD_MAX_ATTEMPTS = 4;
const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState("");
@ -451,7 +452,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
window.location.href = "/";
throw new Error("Нет доступа");
}
if (!response.ok) throw new Error(apiError(data, fallbackMessage || "Ошибка запроса"));
if (!response.ok) {
const error = new Error(apiError(data, fallbackMessage || "Ошибка запроса"));
error.httpStatus = Number(response.status || 0);
throw error;
}
return data;
}, []);
@ -472,50 +477,105 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
return parts.length ? base + " (" + parts.join("; ") + ")" : base;
}, []);
const wait = useCallback(async (ms) => {
await new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}, []);
const nextUploadRetryDelayMs = useCallback((attempt) => {
const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7000);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}, []);
const isRetryableUploadError = useCallback((error) => {
const status = Number(error?.httpStatus || error?.status || 0);
if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
if (status > 0) return false;
const message = String(error?.message || "").toLowerCase();
if (!message) return true;
return (
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("load failed") ||
message.includes("network request failed") ||
message.includes("timeout")
);
}, []);
const runUploadStepWithRetry = useCallback(
async (label, action) => {
let lastError = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) {
attemptsUsed = attempt;
try {
return await action(attempt);
} catch (error) {
lastError = error;
const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error);
if (!canRetry) break;
await wait(nextUploadRetryDelayMs(attempt));
}
}
const reason = String(lastError?.message || "Ошибка сети");
throw new Error(label + ": " + reason + " (попыток: " + attemptsUsed + ")");
},
[UPLOAD_MAX_ATTEMPTS, isRetryableUploadError, nextUploadRetryDelayMs, wait]
);
const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
const requestId = String(requestModal.requestId || "").trim();
if (!requestId) throw new Error("Не выбрана заявка");
const mimeType = String(file?.type || "application/octet-stream");
const initData = await apiJson(
"/api/public/uploads/init",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
}),
},
"Не удалось начать загрузку файла"
);
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
const initData = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => {
return apiJson(
"/api/public/uploads/init",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
}),
},
"Не удалось начать загрузку файла"
);
});
await runUploadStepWithRetry("Ошибка передачи файла в хранилище", async () => {
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (putResponse.ok) return null;
const error = new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище"));
error.httpStatus = Number(putResponse.status || 0);
throw error;
});
const completeData = await runUploadStepWithRetry("Не удалось завершить загрузку файла", async () => {
return apiJson(
"/api/public/uploads/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: extra?.message_id || null,
}),
},
"Не удалось завершить загрузку файла"
);
});
if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище"));
const completeData = await apiJson(
"/api/public/uploads/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: extra?.message_id || null,
}),
},
"Не удалось завершить загрузку файла"
);
return completeData;
}, [apiJson, buildStorageUploadError, requestModal.requestId]);
}, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
const loadRequestWorkspace = useCallback(
async (trackNumber, showLoading) => {

View file

@ -0,0 +1,152 @@
import os
import unittest
from unittest.mock import patch
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.landing_featured_staff import LandingFeaturedStaff
from app.models.topic import Topic
class _FakeBody:
def __init__(self, payload: bytes):
self.payload = payload
def iter_chunks(self, chunk_size=65536):
for i in range(0, len(self.payload), chunk_size):
yield self.payload[i : i + chunk_size]
class _FakeS3Storage:
def __init__(self):
self.objects = {}
def get_object(self, key: str) -> dict:
obj = self.objects.get(key)
if obj is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]}
class FeaturedStaffPublicTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Topic.__table__.create(bind=cls.engine)
LandingFeaturedStaff.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
LandingFeaturedStaff.__table__.drop(bind=cls.engine)
Topic.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(LandingFeaturedStaff))
db.execute(delete(Topic))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
def _seed_featured_lawyer(self, enabled: bool = True):
with self.SessionLocal() as db:
topic = Topic(code="consulting", name="consulting", enabled=True, sort_order=1)
user = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-featured@example.com",
password_hash="hash",
is_active=True,
primary_topic_code="consulting",
)
db.add_all([topic, user])
db.flush()
avatar_key = f"avatars/{user.id}/avatar.webp"
user.avatar_url = "s3://" + avatar_key
slot = LandingFeaturedStaff(
admin_user_id=user.id,
caption="Я крут!",
sort_order=10,
pinned=True,
enabled=enabled,
)
db.add(slot)
db.commit()
return str(user.id), avatar_key
def test_list_featured_staff_returns_public_avatar_proxy_url(self):
user_id, _ = self._seed_featured_lawyer(enabled=True)
response = self.client.get("/api/public/featured-staff?limit=24")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload.get("total"), 1)
self.assertEqual(len(payload.get("items") or []), 1)
row = payload["items"][0]
self.assertEqual(row.get("admin_user_id"), user_id)
self.assertEqual(row.get("avatar_url"), "/api/public/featured-staff/avatar/" + user_id)
def test_featured_staff_avatar_proxy_streams_s3_avatar(self):
user_id, avatar_key = self._seed_featured_lawyer(enabled=True)
fake_s3 = _FakeS3Storage()
fake_s3.objects[avatar_key] = {
"size": 7,
"mime": "image/webp",
"content": b"webpimg",
}
with patch("app.api.public.featured_staff.get_s3_storage", return_value=fake_s3):
response = self.client.get("/api/public/featured-staff/avatar/" + user_id)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"webpimg")
self.assertIn("image/webp", response.headers.get("content-type", ""))
def test_featured_staff_avatar_proxy_denies_non_featured_user(self):
user_id, avatar_key = self._seed_featured_lawyer(enabled=False)
fake_s3 = _FakeS3Storage()
fake_s3.objects[avatar_key] = {
"size": 7,
"mime": "image/webp",
"content": b"webpimg",
}
with patch("app.api.public.featured_staff.get_s3_storage", return_value=fake_s3):
response = self.client.get("/api/public/featured-staff/avatar/" + user_id)
self.assertEqual(response.status_code, 404)

View file

@ -285,6 +285,63 @@ class UploadsS3Tests(unittest.TestCase):
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].s3_key, key)
def test_public_upload_complete_is_idempotent_for_same_key(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-IDEMPOTENT",
client_name="Клиент",
client_phone="+79991112244",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.commit()
request_id = str(req.id)
track = req.track_number
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/public/uploads/init",
cookies=cookies,
json={
"file_name": "retry-safe.pdf",
"mime_type": "application/pdf",
"size_bytes": 4096,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
payload = {
"key": key,
"file_name": "retry-safe.pdf",
"mime_type": "application/pdf",
"size_bytes": 4096,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
}
fake_s3.objects[key] = {"size": 4096, "mime": "application/pdf", "content": b"p" * 4096}
first = self.client.post("/api/public/uploads/complete", cookies=cookies, json=payload)
second = self.client.post("/api/public/uploads/complete", cookies=cookies, json=payload)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.json().get("attachment_id"), second.json().get("attachment_id"))
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertEqual(req.total_attachments_bytes, 4096)
attachments = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all()
self.assertEqual(len(attachments), 1)
def test_public_attachment_object_preview_returns_inline_response(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
@ -428,6 +485,68 @@ class UploadsS3Tests(unittest.TestCase):
self.assertTrue(req.client_has_unread_updates)
self.assertEqual(req.client_unread_event_type, "ATTACHMENT")
def test_admin_upload_complete_is_idempotent_for_same_key(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ Идемпотентность",
email="admin-idempotent@example.com",
password_hash="hash",
is_active=True,
)
req = Request(
track_number="TRK-ADM-IDEMPOTENT",
client_name="Клиент",
client_phone="+79995556644",
topic_code="civil-law",
status_code="IN_PROGRESS",
extra_fields={},
total_attachments_bytes=0,
)
db.add_all([admin, req])
db.commit()
admin_id = str(admin.id)
request_id = str(req.id)
headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-idempotent@example.com")
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
init_resp = self.client.post(
"/api/admin/uploads/init",
headers=headers,
json={
"file_name": "retry-safe-admin.pdf",
"mime_type": "application/pdf",
"size_bytes": 2048,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
},
)
self.assertEqual(init_resp.status_code, 200)
key = init_resp.json()["key"]
payload = {
"key": key,
"file_name": "retry-safe-admin.pdf",
"mime_type": "application/pdf",
"size_bytes": 2048,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
}
fake_s3.objects[key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048}
first = self.client.post("/api/admin/uploads/complete", headers=headers, json=payload)
second = self.client.post("/api/admin/uploads/complete", headers=headers, json=payload)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.json().get("attachment_id"), second.json().get("attachment_id"))
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertEqual(req.total_attachments_bytes, 2048)
attachments = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all()
self.assertEqual(len(attachments), 1)
def test_admin_upload_rejects_attachment_for_immutable_message(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db: