Skip to main content

tdm_server_rust/web/
oss_controller.rs

1//! OSS 对象存储接口 (OSS Controller)
2//!
3//! 文件上传凭证获取、下载鉴权。
4//! 对应 Java OssController。
5
6use crate::utils::query_deserialize::de_i32;
7use crate::{
8    app::AppState,
9    common::result::ResultBody,
10    common::AppJson,
11    entity::oss::{OssCredential, OssDto},
12    error::{ApiResult, AppError},
13    middleware::{member_can_upload_for_post, AuthMember},
14    service::oss_service::OssService,
15};
16use axum::{
17    body::Body,
18    extract::{Extension, Query, State},
19    http::{header, HeaderValue, StatusCode},
20    response::{IntoResponse, Response},
21    routing::{get, post},
22    Router,
23};
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 = member.ok_or_else(|| AppError::login_expired("请登录一下哦喵……"))?;
79    if !member_can_upload_for_post(&member, &body.post_name) {
80        return Err(AppError::download_unauth("没有该岗位的上传权限喵"));
81    }
82    let member_id = member.id;
83    OssService::upsert_oss(&state, body, member_id).await?;
84    Ok(ResultBody::success())
85}
86
87/// 获取上传凭证
88#[tracing::instrument(skip_all, level = "info")]
89pub async fn get_upload_credential(
90    State(state): State<AppState>,
91    Extension(AuthMember(member)): Extension<AuthMember>,
92    Query(q): Query<UploadCredentialQuery>,
93) -> ApiResult<ResultBody<OssCredential>> {
94    let member = member.ok_or_else(|| AppError::login_expired("请登录一下哦喵……"))?;
95    if !member_can_upload_for_post(&member, &q.post_name) {
96        return Err(AppError::download_unauth("没有该岗位的上传权限喵"));
97    }
98    let data =
99        OssService::get_upload_credential(&state, q.episode_id, q.post_name, q.filename).await?;
100    Ok(ResultBody::success_data(data))
101}
102
103/// 获取图片上传凭证
104#[tracing::instrument(skip_all, level = "info")]
105pub async fn get_image_upload_credential(
106    State(state): State<AppState>,
107    Query(q): Query<ImageUploadCredentialQuery>,
108) -> ApiResult<ResultBody<OssCredential>> {
109    let data = OssService::get_image_upload_credential(&state, q.image_type, q.filename).await?;
110    Ok(ResultBody::success_data(data))
111}
112
113/// 获取下载凭证
114#[tracing::instrument(skip_all, level = "info")]
115pub async fn get_download_credential(
116    State(state): State<AppState>,
117    Query(q): Query<DownloadCredentialQuery>,
118) -> ApiResult<ResultBody<OssCredential>> {
119    let data = OssService::get_download_credential(&state, q.episode_id, q.post_name).await?;
120    Ok(ResultBody::success_data(data))
121}
122
123/// 鉴权后 302 跳转 CDN 直链(单文件浏览器下载)
124#[tracing::instrument(skip_all, level = "info")]
125pub async fn download_file(
126    State(state): State<AppState>,
127    Query(q): Query<DownloadCredentialQuery>,
128) -> ApiResult<Response> {
129    let (presigned_url, filename) =
130        OssService::download_redirect_target(&state, q.episode_id, q.post_name).await?;
131    let location = HeaderValue::from_str(&presigned_url)
132        .map_err(|e| AppError::Internal(format!("生成跳转地址失败: {e}")))?;
133    let encoded = urlencoding::encode(&filename);
134    let download_filename = HeaderValue::from_str(&encoded)
135        .map_err(|e| AppError::Internal(format!("生成下载文件名响应头失败: {e}")))?;
136
137    Ok(Response::builder()
138        .status(StatusCode::FOUND)
139        .header(header::LOCATION, location)
140        .header("X-Download-Filename", download_filename)
141        .header(
142            header::ACCESS_CONTROL_EXPOSE_HEADERS,
143            "Location, X-Download-Filename",
144        )
145        .body(Body::empty())
146        .map_err(|e| AppError::Internal(format!("构建 302 响应失败: {e}")))?
147        .into_response())
148}
149
150/// 兼容旧代理路径:只跳转到 COS/CDN,不再由服务器中转文件流量。
151#[tracing::instrument(skip_all, level = "info")]
152pub async fn download_file_proxy(
153    State(state): State<AppState>,
154    Query(q): Query<DownloadCredentialQuery>,
155) -> ApiResult<Response> {
156    let (presigned_url, filename) =
157        OssService::download_redirect_target(&state, q.episode_id, q.post_name).await?;
158    let location = HeaderValue::from_str(&presigned_url)
159        .map_err(|e| AppError::Internal(format!("生成跳转地址失败: {e}")))?;
160    let encoded = urlencoding::encode(&filename);
161    let download_filename = HeaderValue::from_str(&encoded)
162        .map_err(|e| AppError::Internal(format!("生成下载文件名响应头失败: {e}")))?;
163
164    Ok(Response::builder()
165        .status(StatusCode::TEMPORARY_REDIRECT)
166        .header(header::LOCATION, location)
167        .header("X-Download-Filename", download_filename)
168        .header(
169            header::ACCESS_CONTROL_EXPOSE_HEADERS,
170            "Location, X-Download-Filename",
171        )
172        .body(Body::empty())
173        .map_err(|e| AppError::Internal(format!("构建 307 响应失败: {e}")))?
174        .into_response())
175}