feat(monitoring): multi-model dashboard v2.1 — tabs, per-model records, voice playback + MinIO dual-write
Problem: the dashboard was Jev-centric; NLU/arbiter/fallback DeepSeek, STT
providers and the audio-enhancer were barely visible, and stored texts/voices
had no real access path.
Bot (additive; reply path untouched):
- nlu.result now records latency_ms + the DeepSeek usage tokens
- matching.arbitration/fallback.result fill the model + latency_ms columns
(payload unchanged; SQL coalesces column/payload so old rows still count)
- storage.py: MinIO dual-write layer — every voice (raw + denoised) and user
attachment is mirrored fire-and-forget into bucket bot-media; the blocking
minio SDK runs via asyncio.to_thread (senior-engineer review) so the event
loop is never stalled; empty MINIO_* env = layer off; bucket auto-created
- requirements: minio>=7.2
Backend (admin-gated, read-only):
- GET /bot-metrics/models — one record per model (jev, deepseek-nlu,
deepseek-arbiter, deepseek-fallback, audio-enhancer + dynamic per-provider
STT) with calls/ok-rate/p50/p95/tokens/last-seen + version-drift vs the pin
- GET /bot-metrics/models/{id}/daily — per-model daily calls/fails/tokens/p50
- GET /bot-metrics/stages — every stage tagged with its Persian pipeline
section (صوت/درک پیام/تطبیق/ابهام و انتخاب/پیشنویس)
- /traces now carries input_kind + a 120-char text excerpt (consensus →
input.text → first STT transcript)
- media serving: POST /media-url mints a short-lived HMAC URL (audio/a tags
cannot send Authorization); GET /media streams from MinIO bot-media with
the read-only ./bot/data mount as fallback for pre-MinIO files; hard ref
validation (voices|attachments only, no .., extension whitelist)
- router exempted from the global Bearer dependency (files.py pattern) —
every endpoint keeps its own require_role('admin')
Frontend /bot-monitor (RTL, recharts):
- four tabs: نمای کلی / مدلها (records table + per-model expandable daily
charts: calls/fails, tokens, p50) / بخشها (sections grouped) / پیامها
- message drawer rebuilt: inline <audio controls> for raw + denoised voice,
download links, every STT transcript + consensus side-by-side, user
attachments, then the event timeline
- terminology: «ترس» → «پروندهی پیام» with tooltips + a one-line legend
Grafana: bot-observability gains a «کارکرد مدلها» table (stage→model
mapping for legacy rows); the explorer gains a message text column.
Seed: real (tiny) ogg files written + mirrored to MinIO, NLU usage tokens,
column-level model/latency on matching events.
Verified locally: /models returns all 7 records incl. per-STT and NLU tokens;
media 200 via signed URL (MinIO) and 401 without; ../ and .py rejected (400);
disk fallback serves a legacy file; pm-role 403 on every new endpoint;
bucket auto-created with 14 mirrored objects; tsc + vite build clean;
Grafana re-provisions 24 panels.
This commit is contained in:
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_BUCKET: str = "procurement-files"
|
||||
MINIO_SECURE: bool = False
|
||||
# Second bucket the BOT dual-writes its media into (voices/attachments);
|
||||
# the admin bot-media endpoint streams from here first, disk as fallback.
|
||||
BOT_MEDIA_BUCKET: str = "bot-media"
|
||||
|
||||
# Security token for standalone wizard review URL (must match bot)
|
||||
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||
|
||||
@@ -236,6 +236,10 @@ from app.routers.auth import router as _auth_router # noqa: E402
|
||||
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
||||
from app.routers.files import router as _files_router # noqa: E402
|
||||
from app.routers.drafts import router as _drafts_router # noqa: E402
|
||||
# bot-metrics: every endpoint carries its own require_role("admin"); the media
|
||||
# GET additionally accepts the short-lived signed ?t= token (same reason as
|
||||
# files) so <audio>/<a> tags can load it without an Authorization header.
|
||||
from app.routers.bot_metrics import router as _bot_metrics_router # noqa: E402
|
||||
|
||||
for router in ROUTERS:
|
||||
if router is _auth_router:
|
||||
@@ -244,6 +248,8 @@ for router in ROUTERS:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _drafts_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _bot_metrics_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
else:
|
||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@@ -4,13 +4,28 @@ Reads the bot's own tables (bot_traces / bot_trace_events — owned and written
|
||||
by the Bale bot container) with plain SELECTs only. Admin-gated. Calibration,
|
||||
demo and test traffic (source != 'bale' / chat_id = 0) is excluded from the
|
||||
aggregates; the traces endpoint can show everything with ?include_synthetic=1.
|
||||
|
||||
v2.1: per-MODEL and per-SECTION records (/models, /stages), message text
|
||||
excerpts on /traces, and media serving (voice/attachment playback+download)
|
||||
streamed from the bot's MinIO bucket with an on-disk fallback, via a
|
||||
short-lived signed URL — <audio>/<a> tags cannot send Authorization headers.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.auth import get_optional_user, require_role
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
|
||||
|
||||
router = APIRouter(prefix="/bot-metrics", tags=["bot-metrics"])
|
||||
|
||||
@@ -230,6 +245,191 @@ async def stt_health(
|
||||
return {"data": {"providers": providers, "enhance": enhance[0] if enhance else None}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# per-MODEL records (v2.1) — one row per AI model / engine in the pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
# result stage(s) per model; latency lives in the latency_ms column with the
|
||||
# payload copy as fallback for pre-v2.1 rows; tokens in payload->'usage'.
|
||||
MODEL_DEFS: list[dict] = [
|
||||
{"id": "jev", "name": "Jev (تطبیق موجودیت)", "kind": "matching",
|
||||
"stages": ["jev.request", "jev.result", "jev.failure"], "result_stage": "jev.result",
|
||||
"model_stages": ["jev.result"], "pinned": "jev-1.13.0"}, # the bot's pinned version (calibrated)
|
||||
{"id": "deepseek-nlu", "name": "DeepSeek — درک پیام (NLU)", "kind": "llm",
|
||||
"stages": ["nlu.request", "nlu.result"], "result_stage": "nlu.result",
|
||||
"model_stages": ["nlu.result"], "pinned": None},
|
||||
{"id": "deepseek-arbiter", "name": "DeepSeek — داور تطبیق", "kind": "llm",
|
||||
"stages": ["matching.arbitration.result", "matching.arbitration.failure"], "result_stage": "matching.arbitration.result",
|
||||
"model_stages": [], "pinned": None},
|
||||
{"id": "deepseek-fallback", "name": "DeepSeek — فالبک بحرانی", "kind": "llm",
|
||||
"stages": ["matching.fallback.result", "matching.fallback.failure"], "result_stage": "matching.fallback.result",
|
||||
"model_stages": [], "pinned": None},
|
||||
{"id": "audio-enhancer", "name": "پیشپردازش صدا (Denoise)", "kind": "audio",
|
||||
"stages": ["audio.enhance.result"], "result_stage": "audio.enhance.result",
|
||||
"model_stages": ["audio.enhance.result"], "pinned": None},
|
||||
]
|
||||
|
||||
_STAGE_SECTION = {
|
||||
"audio.enhance.result": ("صوت", "پیشپردازش و نویززدایی"),
|
||||
"stt.run": ("صوت", "اجرای STT"),
|
||||
"stt.result": ("صوت", "تبدیل گفتار به متن"),
|
||||
"nlu.request": ("درک پیام", "درخواست NLU"),
|
||||
"nlu.result": ("درک پیام", "پاسخ NLU"),
|
||||
"entities.extracted": ("درک پیام", "موجودیتهای استخراجشده"),
|
||||
"jev.request": ("تطبیق", "درخواست Jev"),
|
||||
"jev.result": ("تطبیق", "امتیازدهی Jev"),
|
||||
"jev.failure": ("تطبیق", "خطای Jev"),
|
||||
"routing.decision": ("تطبیق", "تصمیم مسیریابی"),
|
||||
"matching.arbitration.result": ("تطبیق", "داوری DeepSeek"),
|
||||
"matching.arbitration.failure": ("تطبیق", "خطای داوری"),
|
||||
"matching.fallback.result": ("تطبیق", "فالبک بحرانی"),
|
||||
"matching.fallback.failure": ("تطبیق", "خطای فالبک"),
|
||||
"ambiguity.sent": ("ابهام و انتخاب", "ارسال پرسش ابهام"),
|
||||
"ambiguity.user_choice": ("ابهام و انتخاب", "انتخاب کاربر"),
|
||||
"user_choice.pick": ("ابهام و انتخاب", "دکمهی گزینه"),
|
||||
"draft.render": ("پیشنویس", "نمایش پیشنویس"),
|
||||
"draft.edit": ("پیشنویس", "ویرایش پیشنویس"),
|
||||
"draft.commit": ("پیشنویس", "ثبت نهایی"),
|
||||
"draft.cancel": ("پیشنویس", "لغو"),
|
||||
}
|
||||
|
||||
_LAT = "coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int)"
|
||||
_TOK = "(e.payload->'usage'->>'input_tokens')::bigint"
|
||||
_TOK_OUT = "(e.payload->'usage'->>'output_tokens')::bigint"
|
||||
|
||||
|
||||
async def _model_rows(db: AsyncSession, days: int, stage: str) -> list[dict]:
|
||||
return await _rows(db, f"""
|
||||
SELECT count(*)::int AS calls,
|
||||
count(*) FILTER (WHERE e.ok)::int AS ok,
|
||||
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
|
||||
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
|
||||
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
|
||||
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out,
|
||||
max(e.created_at)::text AS last_seen
|
||||
FROM bot_trace_events e WHERE {REAL} AND e.stage = :stage
|
||||
AND e.created_at >= now() - (:days * interval '1 day')
|
||||
""", {"stage": stage, "days": days})
|
||||
|
||||
|
||||
async def _models_seen(db: AsyncSession, days: int, stages: list[str]) -> list[dict]:
|
||||
return await _rows(db, f"""
|
||||
SELECT DISTINCT ON (coalesce(e.model,'?')) coalesce(e.model,'?') AS model,
|
||||
count(*) OVER (PARTITION BY coalesce(e.model,'?'))::int AS n
|
||||
FROM bot_trace_events e
|
||||
WHERE {REAL} AND e.stage = ANY(:stages)
|
||||
AND e.created_at >= now() - (:days * interval '1 day')
|
||||
""", {"stages": stages, "days": days})
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def models(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx=Depends(require_role("admin")),
|
||||
):
|
||||
"""One record per model in the pipeline (incl. dynamic STT providers)."""
|
||||
out: list[dict] = []
|
||||
for d in MODEL_DEFS:
|
||||
agg = (await _model_rows(db, days, d["result_stage"]))[0]
|
||||
versions: list[str] = []
|
||||
if d["model_stages"]:
|
||||
versions = [r["model"] for r in await _models_seen(db, days, d["model_stages"])
|
||||
if r["model"] != "?"]
|
||||
out.append({
|
||||
"id": d["id"], "name": d["name"], "kind": d["kind"],
|
||||
"stages": d["stages"], **agg,
|
||||
"versions": versions,
|
||||
"drift": bool(d["pinned"] and any(v != d["pinned"] for v in versions)),
|
||||
"pinned": d["pinned"],
|
||||
})
|
||||
# dynamic STT models — one record per provider/model id in stt.result
|
||||
stt_rows = await _rows(db, f"""
|
||||
SELECT coalesce(e.model, e.payload->>'provider', 'stt') AS model,
|
||||
count(*)::int AS calls,
|
||||
count(*) FILTER (WHERE e.ok)::int AS ok,
|
||||
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
|
||||
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
|
||||
0::bigint AS tokens_in, 0::bigint AS tokens_out,
|
||||
max(e.created_at)::text AS last_seen
|
||||
FROM bot_trace_events e
|
||||
WHERE {REAL} AND e.stage = 'stt.result'
|
||||
AND e.created_at >= now() - (:days * interval '1 day')
|
||||
GROUP BY 1 ORDER BY calls DESC
|
||||
""", {"days": days})
|
||||
for r in stt_rows:
|
||||
out.append({"id": f"stt:{r['model']}",
|
||||
"name": f"STT — {r['model']}", "kind": "stt",
|
||||
"stages": ["stt.result"], **r,
|
||||
"versions": [], "drift": False, "pinned": None})
|
||||
return {"data": out, "days": days}
|
||||
|
||||
|
||||
def _model_def(model_id: str) -> dict | None:
|
||||
for d in MODEL_DEFS:
|
||||
if d["id"] == model_id:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/daily")
|
||||
async def model_daily(
|
||||
model_id: str,
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx=Depends(require_role("admin")),
|
||||
):
|
||||
"""Daily calls / failures / tokens / latency for one model."""
|
||||
if model_id.startswith("stt:"):
|
||||
stage, model_val = "stt.result", model_id[4:]
|
||||
extra = " AND coalesce(e.model, e.payload->>'provider') = :mv"
|
||||
params: dict = {"mv": model_val}
|
||||
else:
|
||||
d = next((x for x in MODEL_DEFS if x["id"] == model_id), None)
|
||||
if d is None:
|
||||
raise NotFoundError("مدل یافت نشد")
|
||||
stage, model_val, extra = d["result_stage"], None, ""
|
||||
params = {}
|
||||
rows = await _rows(db, f"""
|
||||
SELECT to_char(date_trunc('day', e.created_at), 'YYYY-MM-DD') AS day,
|
||||
count(*)::int AS calls,
|
||||
count(*) FILTER (WHERE NOT e.ok)::int AS fails,
|
||||
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
|
||||
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
|
||||
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out
|
||||
FROM bot_trace_events e
|
||||
WHERE {REAL} AND e.stage = :stage{extra}
|
||||
AND e.created_at >= now() - (:days * interval '1 day')
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""", {"stage": stage, "days": days, **params})
|
||||
return {"data": rows, "model": model_id, "days": days}
|
||||
|
||||
|
||||
@router.get("/stages")
|
||||
async def stages(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx=Depends(require_role("admin")),
|
||||
):
|
||||
"""One record per pipeline stage, tagged with its Persian section."""
|
||||
rows = await _rows(db, f"""
|
||||
SELECT e.stage,
|
||||
count(*)::int AS n,
|
||||
count(*) FILTER (WHERE NOT e.ok)::int AS fails,
|
||||
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
|
||||
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
|
||||
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
|
||||
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out
|
||||
FROM bot_trace_events e
|
||||
WHERE {REAL} AND e.created_at >= now() - (:days * interval '1 day')
|
||||
GROUP BY 1 ORDER BY n DESC
|
||||
""", {"days": days})
|
||||
for r in rows:
|
||||
sec = _STAGE_SECTION.get(r["stage"])
|
||||
r["section"] = sec[0] if sec else "سایر"
|
||||
r["stage_fa"] = sec[1] if sec else r["stage"]
|
||||
return {"data": rows, "days": days}
|
||||
|
||||
|
||||
@router.get("/traces")
|
||||
async def traces(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -238,12 +438,25 @@ async def traces(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx=Depends(require_role("admin")),
|
||||
):
|
||||
"""Latest traces for the explorer table (oldest events come from /traces/{id})."""
|
||||
"""Latest message records («پروندهی پیام») with a text excerpt + media flag."""
|
||||
sql = """
|
||||
SELECT t.id::text AS id, t.created_at::text AS created_at,
|
||||
coalesce(t.source,'bale') AS source, t.chat_id::text AS chat_id,
|
||||
t.kind, coalesce(t.status,'open') AS status,
|
||||
(SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id)::int AS events
|
||||
(SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id)::int AS events,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM bot_trace_events e
|
||||
WHERE e.trace_id=t.id AND e.stage='input.voice')
|
||||
THEN 'voice' ELSE 'text' END AS input_kind,
|
||||
left(coalesce((SELECT e.payload->>'consensus_text' FROM bot_trace_events e
|
||||
WHERE e.trace_id=t.id AND e.stage='nlu.result'
|
||||
ORDER BY e.created_at LIMIT 1),
|
||||
(SELECT e.payload->>'text' FROM bot_trace_events e
|
||||
WHERE e.trace_id=t.id AND e.stage='input.text'
|
||||
ORDER BY e.created_at LIMIT 1),
|
||||
(SELECT e.payload->>'text' FROM bot_trace_events e
|
||||
WHERE e.trace_id=t.id AND e.stage='stt.result' AND e.ok
|
||||
ORDER BY e.created_at LIMIT 1),
|
||||
''), 120) AS text_excerpt
|
||||
FROM bot_traces t
|
||||
WHERE (:include_synthetic
|
||||
OR (coalesce(t.source,'bale') NOT IN ('calibration','demo') AND coalesce(t.chat_id,0) <> 0))
|
||||
@@ -280,3 +493,139 @@ async def trace_detail(
|
||||
FROM bot_traces WHERE id::text = :tid
|
||||
""", {"tid": trace_id})
|
||||
return {"data": {"trace": head[0] if head else None, "events": events}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# media serving (v2.1) — inline playback + download of voices / user files
|
||||
# ---------------------------------------------------------------------------
|
||||
# <audio controls> and <a download> cannot attach an Authorization header, so
|
||||
# the admin page first mints a short-lived signed URL (same idea as the quote
|
||||
# preview tokens); the GET below accepts that token OR an admin session and
|
||||
# streams the file from the bot's MinIO bucket, falling back to the read-only
|
||||
# ./bot/data mount for pre-MinIO files.
|
||||
|
||||
_MEDIA_TTL_SECONDS = 600
|
||||
_MEDIA_RE = re.compile(r"^(voices|attachments)/[A-Za-z0-9_\-./]+$")
|
||||
_MEDIA_EXT = {".ogg", ".wav", ".m4a", ".mp3", ".pdf", ".png", ".jpg", ".jpeg", ".webp"}
|
||||
_MEDIA_MIME = {
|
||||
".ogg": "audio/ogg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg", ".pdf": "application/pdf", ".png": "image/png",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _check_ref(ref: str) -> str:
|
||||
"""Validate a media ref (path under ./bot/data, doubles as the MinIO key)."""
|
||||
ref = (ref or "").strip()
|
||||
if not _MEDIA_RE.match(ref) or ".." in ref or ref.endswith("."):
|
||||
raise ValidationError("مسیر فایل نامعتبر است")
|
||||
if os.path.splitext(ref)[1].lower() not in _MEDIA_EXT:
|
||||
raise ValidationError("نوع فایل پشتیبانی نمیشود")
|
||||
return ref
|
||||
|
||||
|
||||
def _media_secret() -> bytes:
|
||||
base = (settings.CASDOOR_CLIENT_SECRET or settings.MINIO_SECRET_KEY
|
||||
or "procurement-preview-fallback")
|
||||
return base.encode("utf-8")
|
||||
|
||||
|
||||
def _media_sign(ref: str, expiry: int) -> str:
|
||||
return hmac.new(_media_secret(), f"bot-media:{ref}:{expiry}".encode(),
|
||||
hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _media_token(ref: str, ttl: int = _MEDIA_TTL_SECONDS) -> str:
|
||||
expiry = int(time.time()) + ttl
|
||||
return f"{expiry}.{_media_sign(ref, expiry)}"
|
||||
|
||||
|
||||
def _verify_media_token(token: str, ref: str) -> bool:
|
||||
if not token or "." not in token:
|
||||
return False
|
||||
expiry_raw, sig = token.split(".", 1)
|
||||
try:
|
||||
expiry = int(expiry_raw)
|
||||
except ValueError:
|
||||
return False
|
||||
if expiry < int(time.time()):
|
||||
return False
|
||||
return hmac.compare_digest(_media_sign(ref, expiry), sig)
|
||||
|
||||
|
||||
from pydantic import BaseModel # noqa: E402 (kept next to its only use)
|
||||
|
||||
|
||||
class MediaUrlRequest(BaseModel):
|
||||
ref: str
|
||||
|
||||
|
||||
@router.post("/media-url")
|
||||
async def media_url(
|
||||
body: MediaUrlRequest,
|
||||
_ctx=Depends(require_role("admin")),
|
||||
):
|
||||
"""Mint a short-lived signed URL for inline playback / download."""
|
||||
ref = _check_ref(body.ref)
|
||||
from urllib.parse import quote
|
||||
return {"data": {
|
||||
"url": f"/api/v1/bot-metrics/media?ref={quote(ref)}&t={_media_token(ref)}",
|
||||
"expires_in": _MEDIA_TTL_SECONDS,
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/media")
|
||||
async def media(
|
||||
request: Request,
|
||||
ref: str,
|
||||
t: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream one bot media file (admin session OR signed ?t= token).
|
||||
|
||||
Prefers the MinIO copy (bucket bot-media, key = ref); falls back to the
|
||||
read-only disk mount for files saved before MinIO mirroring existed.
|
||||
"""
|
||||
ref = _check_ref(ref)
|
||||
allowed = False
|
||||
if request.headers.get("authorization"):
|
||||
user = await get_optional_user(request, db)
|
||||
if user is not None and user.role == "admin":
|
||||
allowed = True
|
||||
if not allowed and t and _verify_media_token(t, ref):
|
||||
allowed = True
|
||||
if not allowed:
|
||||
raise UnauthorizedError("دسترسی به این فایل مجاز نیست")
|
||||
|
||||
name = os.path.basename(ref)
|
||||
mime = _MEDIA_MIME.get(os.path.splitext(ref)[1].lower(), "application/octet-stream")
|
||||
|
||||
# 1) MinIO copy (the bot dual-writes everything here)
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from app.services.storage import get_client as _minio_client
|
||||
|
||||
def _fetch() -> bytes:
|
||||
resp = _minio_client().get_object(settings.BOT_MEDIA_BUCKET, ref)
|
||||
try:
|
||||
return resp.read()
|
||||
finally:
|
||||
resp.close()
|
||||
resp.release_conn()
|
||||
|
||||
content = await asyncio.to_thread(_fetch)
|
||||
return Response(content, media_type=mime, headers={
|
||||
"Content-Disposition": f'inline; filename="{name}"',
|
||||
"Cache-Control": "private, max-age=300",
|
||||
})
|
||||
except Exception:
|
||||
pass # not in MinIO (old file) or MinIO down → disk fallback
|
||||
|
||||
# 2) disk fallback (read-only mount of ./bot/data)
|
||||
path = (Path("/srv/bot/data") / ref).resolve()
|
||||
data_root = Path("/srv/bot/data").resolve()
|
||||
if data_root not in path.parents or not path.is_file():
|
||||
raise NotFoundError("فایل یافت نشد")
|
||||
return FileResponse(path, media_type=mime, filename=name,
|
||||
headers={"Cache-Control": "private, max-age=300"})
|
||||
|
||||
Reference in New Issue
Block a user