use ::session::AuthedUser; use api::http::{Json, Path, Query, Response, State}; use api::rest::{AppResponseData, NoData}; use imaging::layout::ImageVariant; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::ApiError; use crate::services::attachments::{ AttachmentDto, CreateAttachmentCmd, FinalizeAttachmentCmd, UploadIntentDto, }; use crate::services::service::ChatService; #[utoipa::path( post, path = "/chat/attachments", tag = "chat", description = "为一次附件上传预留记录并返回预签名 PUT 地址。", request_body = CreateAttachmentCmd, responses( (status = 200, description = "upload intent", body = AppResponseData), (status = 400, description = "bad request", body = AppResponseData), (status = 429, description = "too many pending attachments", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn create_attachment( State(service): State, AuthedUser(uid): AuthedUser, Json(cmd): Json, ) -> Result, ApiError> { let intent = service.create_attachment(uid, cmd).await?; Ok(AppResponseData::ok(intent)) } #[utoipa::path( post, path = "/chat/attachments/{attachment_id}/finalize", tag = "chat", description = "确认对象已上传,校验大小与类型,并生成压缩预览图(失败时回退原图)。", params(("attachment_id" = Uuid, Path, description = "attachment id")), request_body = FinalizeAttachmentCmd, responses( (status = 200, description = "attachment finalized", body = AppResponseData), (status = 400, description = "bad request", body = AppResponseData), (status = 404, description = "not found", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn finalize_attachment( State(service): State, AuthedUser(uid): AuthedUser, Path(attachment_id): Path, Json(cmd): Json, ) -> Result, ApiError> { let dto = service.finalize_attachment(uid, attachment_id, cmd).await?; Ok(AppResponseData::ok(dto)) } #[utoipa::path( get, path = "/chat/attachments/{attachment_id}", tag = "chat", description = "读取附件元数据。", params(("attachment_id" = Uuid, Path, description = "attachment id")), responses( (status = 200, description = "attachment metadata", body = AppResponseData), (status = 404, description = "not found", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn get_attachment( State(service): State, AuthedUser(uid): AuthedUser, Path(attachment_id): Path, ) -> Result, ApiError> { let dto = service.get_attachment_meta(uid, attachment_id).await?; Ok(AppResponseData::ok(dto)) } /// `?variant=` selector. Omitting it keeps the historical meaning of this route /// (the original bytes, which is what a download link must hand back); the /// transcript grid asks for `preview` explicitly. #[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)] pub struct AttachmentVariantQuery { #[serde(default)] pub variant: Option, } #[utoipa::path( get, path = "/chat/attachments/{attachment_id}/file", tag = "chat", description = "以安全响应头流式下载聊天附件。默认返回上传原图(下载语义不变),`?variant=preview` 返回压缩预览图;没有可用预览图时同样返回原图。", params(("attachment_id" = Uuid, Path, description = "attachment id"), AttachmentVariantQuery), responses( (status = 200, description = "file stream"), (status = 404, description = "not found", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn download_attachment( State(service): State, AuthedUser(uid): AuthedUser, Path(attachment_id): Path, Query(query): Query, ) -> Result { let (row, body, content_type) = service .download_attachment( uid, attachment_id, query.variant.unwrap_or(ImageVariant::Original), ) .await?; let mut response = actix_web::HttpResponse::Ok().streaming(body); let headers = response.headers_mut(); for (name, value) in ChatService::attachment_content_headers(&row, content_type) { if let Ok(value) = actix_web::http::header::HeaderValue::from_str(&value) { headers.insert(name, value); } } // Attachments are immutable once finalized, so a preview may be cached // privately; the original stays uncached to keep download semantics exactly // as they were. if query.variant == Some(ImageVariant::Preview) { headers.insert( actix_web::http::header::CACHE_CONTROL, "private, max-age=300".parse().expect("static header value"), ); } Ok(response) }