Skip to main content

tdm_server_rust/web/
oss_controller.rs

1//! OSS 对象存储接口 (OSS Controller)
2//!
3//! 文件上传凭证获取、下载鉴权。
4//! 对应 Java OssController。
5
6use crate::{
7    common::AppJson,
8    app::AppState,
9    common::result::ResultBody,
10    entity::oss::{OssCredential, OssDto},
11    error::{ApiResult, AppError},
12    middleware::AuthMember,
13    service::oss_service::OssService,
14};
15use axum::{
16    body::Body,
17    extract::{Extension, Query, State},
18    http::{header, HeaderValue, StatusCode},
19    response::{IntoResponse, Response},
20    routing::{get, post},
21    Router,
22};
23use crate::utils::query_deserialize::de_i32;
24use serde::Deserialize;
25
26/// 上传凭证查询参数
27#[derive(Debug, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct UploadCredentialQuery {
30    /// 话数 ID
31    #[serde(deserialize_with = "de_i32")]
32    pub episode_id: i32,
33    /// 岗位名
34    pub post_name: String,
35    /// 文件名
36    pub filename: String,
37}
38
39/// 图片上传凭证参数
40#[derive(Debug, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct ImageUploadCredentialQuery {
43    /// 图片类型
44    pub image_type: String,
45    /// 文件名
46    pub filename: String,
47}
48
49/// 下载凭证参数
50#[derive(Debug, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct DownloadCredentialQuery {
53    /// 话数 ID
54    #[serde(deserialize_with = "de_i32")]
55    pub episode_id: i32,
56    /// 岗位名
57    pub post_name: String,
58}
59
60/// OSS 路由(挂载于 `/api/oss`)
61pub fn routes() -> Router<AppState> {
62    Router::new()
63        .route("/", post(upsert))
64        .route("/uploadCredential", get(get_upload_credential))
65        .route("/imageUploadCredential", get(get_image_upload_credential))
66        .route("/downloadCredential", get(get_download_credential))
67        .route("/downloadFile", get(download_file))
68        .route("/downloadFile/proxy", get(download_file_proxy))
69}
70
71/// 新增或更新 OSS 记录
72#[tracing::instrument(skip_all, level = "info")]
73pub async fn upsert(
74    State(state): State<AppState>,
75    Extension(AuthMember(member)): Extension<AuthMember>,
76    AppJson(body): AppJson<OssDto>,
77) -> ApiResult<ResultBody<()>> {
78    let member_id = member.map(|m| m.id).unwrap_or(0);
79    OssService::upsert_oss(&state, body, member_id).await?;
80    Ok(ResultBody::success())
81}
82
83/// 获取上传凭证
84#[tracing::instrument(skip_all, level = "info")]
85pub async fn get_upload_credential(
86    State(state): State<AppState>,
87    Query(q): Query<UploadCredentialQuery>,
88) -> ApiResult<ResultBody<OssCredential>> {
89    let data =
90        OssService::get_upload_credential(&state, q.episode_id, q.post_name, q.filename).await?;
91    Ok(ResultBody::success_data(data))
92}
93
94/// 获取图片上传凭证
95#[tracing::instrument(skip_all, level = "info")]
96pub async fn get_image_upload_credential(
97    State(state): State<AppState>,
98    Query(q): Query<ImageUploadCredentialQuery>,
99) -> ApiResult<ResultBody<OssCredential>> {
100    let data =
101        OssService::get_image_upload_credential(&state, q.image_type, q.filename).await?;
102    Ok(ResultBody::success_data(data))
103}
104
105/// 获取下载凭证
106#[tracing::instrument(skip_all, level = "info")]
107pub async fn get_download_credential(
108    State(state): State<AppState>,
109    Query(q): Query<DownloadCredentialQuery>,
110) -> ApiResult<ResultBody<OssCredential>> {
111    let data = OssService::get_download_credential(&state, q.episode_id, q.post_name).await?;
112    Ok(ResultBody::success_data(data))
113}
114
115/// 鉴权后 302 跳转 CDN 直链(单文件浏览器下载)
116#[tracing::instrument(skip_all, level = "info")]
117pub async fn download_file(
118    State(state): State<AppState>,
119    Query(q): Query<DownloadCredentialQuery>,
120) -> ApiResult<Response> {
121    let (presigned_url, filename) =
122        OssService::download_redirect_target(&state, q.episode_id, q.post_name).await?;
123    let location = HeaderValue::from_str(&presigned_url)
124        .map_err(|e| AppError::Internal(format!("生成跳转地址失败: {e}")))?;
125    let encoded = urlencoding::encode(&filename);
126    let download_filename = HeaderValue::from_str(&encoded)
127        .map_err(|e| AppError::Internal(format!("生成下载文件名响应头失败: {e}")))?;
128
129    Ok(Response::builder()
130        .status(StatusCode::FOUND)
131        .header(header::LOCATION, location)
132        .header("X-Download-Filename", download_filename)
133        .header(
134            header::ACCESS_CONTROL_EXPOSE_HEADERS,
135            "Location, X-Download-Filename",
136        )
137        .body(Body::empty())
138        .map_err(|e| AppError::Internal(format!("构建 302 响应失败: {e}")))?
139        .into_response())
140}
141
142/// 同源代理下载 OSS 文件(ZIP 打包 CORS 失败时回退)
143#[tracing::instrument(skip_all, level = "info")]
144pub async fn download_file_proxy(
145    State(state): State<AppState>,
146    Query(q): Query<DownloadCredentialQuery>,
147) -> ApiResult<Response> {
148    let (filename, bytes) =
149        OssService::download_file_proxy(&state, q.episode_id, q.post_name).await?;
150    let encoded = urlencoding::encode(&filename);
151    let mut headers = axum::http::HeaderMap::new();
152    headers.insert(
153        header::CONTENT_TYPE,
154        HeaderValue::from_static("application/octet-stream"),
155    );
156    headers.insert(
157        header::CONTENT_DISPOSITION,
158        HeaderValue::from_str(&format!(
159            "attachment; filename=\"{encoded}\"; filename*=UTF-8''{encoded}"
160        ))
161        .map_err(|e| AppError::Internal(format!("生成下载文件名响应头失败: {e}")))?,
162    );
163    headers.insert(
164        header::ACCESS_CONTROL_EXPOSE_HEADERS,
165        HeaderValue::from_static("Content-Disposition,Content-Type,Content-Length"),
166    );
167    Ok((StatusCode::OK, headers, bytes).into_response())
168}