diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 55ddfbf5..fdf3014f 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -2272,6 +2272,8 @@ pub async fn list_tries_by_spot( /// 까지 한 트랜잭션으로 묶을 수 있도록 변경 (#350). pub async fn create_post_from_raw( db: &C, + post_id: Uuid, + image_url: String, admin_id: Uuid, raw_post: &crate::entities::assets_raw_posts::Model, dto: crate::domains::raw_posts::dto::VerifyRawPostDto, @@ -2282,12 +2284,6 @@ pub async fn create_post_from_raw( subject_style_tags: Option<&[String]>, ) -> AppResult { let now = chrono::Utc::now().fixed_offset(); - let image_url = raw_post.image_url.clone().ok_or_else(|| { - AppError::BadRequest(format!( - "raw_post {} has no image_url — cannot create post", - raw_post.id - )) - })?; // title 우선순위: admin 입력 (or compose_title 결과 호출자가 미리 주입) → // pin caption (보통 비영어/길어 마지막 fallback). subject_parser.title_en 은 @@ -2331,7 +2327,7 @@ pub async fn create_post_from_raw( }; let post = ActiveModel { - id: Set(Uuid::new_v4()), + id: Set(post_id), user_id: Set(admin_id), image_url: Set(image_url), media_type: Set("image".to_string()), diff --git a/packages/api-server/src/domains/raw_posts/mod.rs b/packages/api-server/src/domains/raw_posts/mod.rs index 96fae581..27e83f7a 100644 --- a/packages/api-server/src/domains/raw_posts/mod.rs +++ b/packages/api-server/src/domains/raw_posts/mod.rs @@ -8,6 +8,7 @@ pub mod dto; pub mod handlers; +pub(crate) mod relocate; pub mod service; pub use handlers::router; diff --git a/packages/api-server/src/domains/raw_posts/relocate.rs b/packages/api-server/src/domains/raw_posts/relocate.rs new file mode 100644 index 00000000..e9ddfcfc --- /dev/null +++ b/packages/api-server/src/domains/raw_posts/relocate.rs @@ -0,0 +1,229 @@ +//! verify-time R2 relocation (#466 follow-up). +//! +//! Verify 시점에 raw bucket (assets storage) 의 hero / item thumbnail R2 객체를 +//! operation bucket 으로 복사한다. CopyObject 권한 이슈 회피 위해 +//! `download → upload` 두 단계로 처리 — bytes 가 우리 머신을 잠시 통과하지만 +//! 평균 1MB 이미지라 비용 무시. +//! +//! caller 책임: +//! - relocate 성공 후 raw 객체 best-effort delete (단일 책임 분리) +//! - new_key 의 사전 생성 (e.g. `posts/{post_id}`, `items/{solution_id}`) + +use crate::error::{AppError, AppResult}; +use crate::services::storage::StorageClient; + +use super::service::extract_assets_r2_key; + +/// raw bucket 의 객체를 operation bucket 의 새 키로 복사한다. +/// +/// `raw_url` 이 `raw_public_url` prefix 가 아니면 `BadRequest` — verify 흐름은 +/// raw bucket URL 만 가정 (외부 hotlink 은 별도 PR). +/// +/// 반환: operation public URL (`{op_public_url}/{new_key}`). +pub(crate) async fn relocate_raw_to_operation( + raw_storage: &dyn StorageClient, + op_storage: &dyn StorageClient, + raw_url: &str, + raw_public_url: &str, + op_public_url: &str, + new_key: &str, + fallback_content_type: &str, +) -> AppResult { + let raw_key = extract_assets_r2_key(raw_url, raw_public_url).ok_or_else(|| { + AppError::BadRequest(format!( + "non-raw URL cannot be relocated: {raw_url:?} (raw_public_url={raw_public_url:?})" + )) + })?; + + let (bytes, ct) = raw_storage.download(&raw_key).await?; + let content_type = ct.as_deref().unwrap_or(fallback_content_type); + + op_storage + .upload(new_key, bytes, content_type) + .await + .map_err(|e| { + AppError::ExternalService(format!( + "relocate: upload to operation failed (key={new_key}): {e}" + )) + })?; + + Ok(format!( + "{}/{}", + op_public_url.trim_end_matches('/'), + new_key + )) +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::Mutex; + + /// 테스트용 in-memory StorageClient — download 가 받은 bytes 를 upload 가 + /// 받았는지 검증할 수 있도록 호출 기록 보존. + struct InMemoryStorage { + objects: Mutex, Option)>>, + upload_log: Mutex, String)>>, + public_url: String, + } + + impl InMemoryStorage { + fn new(public_url: &str) -> Self { + Self { + objects: Mutex::new(HashMap::new()), + upload_log: Mutex::new(Vec::new()), + public_url: public_url.to_string(), + } + } + + fn put(&self, key: &str, bytes: Vec, ct: Option) { + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (bytes, ct)); + } + + fn upload_count(&self) -> usize { + self.upload_log.lock().unwrap().len() + } + } + + #[async_trait] + impl StorageClient for InMemoryStorage { + async fn upload( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result { + self.upload_log.lock().unwrap().push(( + key.to_string(), + data.clone(), + content_type.to_string(), + )); + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (data, Some(content_type.to_string()))); + Ok(self.get_url(key)) + } + + async fn delete(&self, key: &str) -> Result<(), AppError> { + self.objects.lock().unwrap().remove(key); + Ok(()) + } + + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + self.objects + .lock() + .unwrap() + .get(key) + .cloned() + .ok_or_else(|| AppError::NotFound(format!("not found: {key}"))) + } + + fn get_url(&self, key: &str) -> String { + format!("{}/{}", self.public_url.trim_end_matches('/'), key) + } + } + + #[tokio::test] + async fn relocate_happy_path_passes_bytes_and_content_type() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + raw.put( + "starstyle/92/926700.jpg", + vec![0xff, 0xd8, 0xff, 0xe0], + Some("image/jpeg".to_string()), + ); + + let new_url = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/92/926700.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc-123", + "image/jpeg", + ) + .await + .unwrap(); + + assert_eq!(new_url, "https://r2.decoded.style/posts/abc-123"); + let log = op.upload_log.lock().unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "posts/abc-123"); + assert_eq!(log[0].1, vec![0xff, 0xd8, 0xff, 0xe0]); + assert_eq!(log[0].2, "image/jpeg"); + } + + #[tokio::test] + async fn relocate_uses_fallback_content_type_when_missing() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + raw.put("starstyle/x.bin", vec![0u8; 8], None); + + relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/x.bin", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/png", + ) + .await + .unwrap(); + + let log = op.upload_log.lock().unwrap(); + assert_eq!(log[0].2, "image/png"); + } + + #[tokio::test] + async fn relocate_rejects_non_raw_url() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://i.pinimg.com/anything/abc.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } + + #[tokio::test] + async fn relocate_propagates_download_not_found() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + // raw.put 안 함 — download 시 NotFound + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/missing.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } +} diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 1cc47f72..72be7d4d 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -671,7 +671,7 @@ pub async fn delete_item( /// → Some("starstyle/92/926700.jpg") /// /// 다른 도메인 / 빈 public_url / 키가 비면 None — caller 가 cleanup skip. -fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { +pub(crate) fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { let prefix = public_url.trim_end_matches('/'); if prefix.is_empty() { return None; @@ -1284,9 +1284,63 @@ pub async fn verify_raw_post( } } + // 3. R2 relocate (raw → operation) — prod_txn 시작 전 (#466 follow-up). + // raw bucket 의 hero / item thumbnail 객체를 operation bucket 의 정규 + // 키 (`posts/{post_id}`, `items/{solution_id}`) 로 복사하고, 새 URL 만 + // DB 에 들어가도록 한다. copy 도중 실패하면 verify 거부 (DB 부작용 0). + let raw_url = raw_post.image_url.clone().ok_or_else(|| { + AppError::BadRequest(format!("raw_post {id} has no image_url — cannot verify")) + })?; + let assets_pub = state.config.assets_storage.public_url.clone(); + let op_pub = state.config.storage.public_url.clone(); + let post_id = Uuid::new_v4(); + let new_post_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + &raw_url, + &assets_pub, + &op_pub, + &format!("posts/{post_id}"), + "image/jpeg", + ) + .await?; + + // 각 visible item 에 대해 solution_id 사전 생성 + thumbnail copy. None 인 + // thumbnail 은 fallback path (raw 객체 자체가 없음) 라 그대로 None. + let mut prepared_items: Vec<(usize, Uuid, Option, Option)> = Vec::new(); + if let Some(payload) = &parse_payload { + for (idx, item) in payload.items.iter().enumerate().filter(|(_, i)| i.visible) { + let solution_id = Uuid::new_v4(); + let new_thumb = match item.thumbnail_url.as_deref() { + Some(url) if !url.trim().is_empty() => { + let new_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + url, + &assets_pub, + &op_pub, + &format!("items/{solution_id}"), + "image/jpeg", + ) + .await?; + Some(new_url) + } + _ => None, + }; + // raw thumbnail key 는 두 트랜잭션 commit 후 best-effort delete 용으로 보관. + let raw_thumb_key = item + .thumbnail_url + .as_deref() + .and_then(|u| extract_assets_r2_key(u, &assets_pub)); + prepared_items.push((idx, solution_id, new_thumb, raw_thumb_key)); + } + } + let prod_txn = state.db.begin().await.map_err(AppError::DatabaseError)?; let post = crate::domains::posts::service::create_post_from_raw( &prod_txn, + post_id, + new_post_url.clone(), admin_id, &raw_post, dto, @@ -1368,13 +1422,22 @@ pub async fn verify_raw_post( Some(t.to_string()) } }); + // prepared_items[idx] 에서 사전 생성한 solution_id + 새 operation + // thumbnail_url 가져오기. 정상 흐름이면 매칭되지만, payload.items + // 와 prepared_items 의 idx 가 어긋나면 fallback (현재 동작 유지). + let prepared = prepared_items.iter().find(|(i, _, _, _)| *i == idx); + let (solution_id, thumb_for_db) = match prepared { + Some((_, sid, new_thumb, _)) => (*sid, new_thumb.clone()), + None => (Uuid::new_v4(), item.thumbnail_url.clone()), + }; crate::domains::solutions::service::create_solution_for_verify( &prod_txn, + solution_id, spot.id, admin_id, title, Some(serde_json::Value::Object(metadata)), - item.thumbnail_url.clone(), + thumb_for_db, original_url, ) .await?; @@ -1398,6 +1461,9 @@ pub async fn verify_raw_post( active.verified_at = Set(Some(now)); active.verified_by = Set(Some(admin_id)); active.updated_at = Set(now); + // raw_post.image_url 도 operation URL 로 갱신 (#466 옵션 C). raw bucket + // 객체는 곧 삭제 예정이라 stale URL 이 되지 않게 단일 진실 소스로 통일. + active.image_url = Set(Some(new_post_url.clone())); active.update(&txn).await.map_err(|e| { tracing::error!(raw_post_id=%id, post_id=%post.id, error=%e, "assets status=VERIFIED write FAILED after prod INSERT succeeded — \ @@ -1416,6 +1482,24 @@ pub async fn verify_raw_post( .await?; txn.commit().await.map_err(AppError::DatabaseError)?; + + // 4. raw 객체 best-effort delete (#466 follow-up). 두 트랜잭션 commit 완료 + // 후라 rollback 위험 없음. 실패는 warn 로깅만 — orphan 은 lifecycle rule + // 또는 별도 GC 잡 (out of scope). + if let Some(raw_hero_key) = extract_assets_r2_key(&raw_url, &assets_pub) { + if let Err(e) = state.assets_storage.delete(&raw_hero_key).await { + tracing::warn!(raw_post_id=%id, key=%raw_hero_key, error=%e, + "raw hero delete failed (post-verify cleanup) — orphan"); + } + } + for (_, _, _, raw_thumb_key) in &prepared_items { + if let Some(key) = raw_thumb_key { + if let Err(e) = state.assets_storage.delete(key).await { + tracing::warn!(raw_post_id=%id, key=%key, error=%e, + "raw thumbnail delete failed (post-verify cleanup) — orphan"); + } + } + } Ok(post) } diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs index 22210122..019bd849 100644 --- a/packages/api-server/src/domains/solutions/service.rs +++ b/packages/api-server/src/domains/solutions/service.rs @@ -39,6 +39,7 @@ use super::dto::{ /// 반환값은 row id (#350). pub async fn create_solution_for_verify( db: &C, + solution_id: Uuid, spot_id: Uuid, user_id: Uuid, title: String, @@ -46,7 +47,7 @@ pub async fn create_solution_for_verify( thumbnail_url: Option, original_url: Option, ) -> AppResult { - let id = Uuid::new_v4(); + let id = solution_id; let now = chrono::Utc::now().fixed_offset(); let model = ActiveModel { id: Set(id), diff --git a/packages/api-server/src/services/storage/client.rs b/packages/api-server/src/services/storage/client.rs index b8422898..3aceaa71 100644 --- a/packages/api-server/src/services/storage/client.rs +++ b/packages/api-server/src/services/storage/client.rs @@ -33,6 +33,16 @@ pub trait StorageClient: Send + Sync { /// * `key` - 삭제할 파일 키 async fn delete(&self, key: &str) -> Result<(), AppError>; + /// 파일 다운로드 (#466 verify-time relocate) + /// + /// # Arguments + /// * `key` - 다운로드할 파일 키 + /// + /// # Returns + /// `(bytes, content_type)`. content_type 은 R2 의 Content-Type 헤더 — + /// 없으면 None (caller 가 fallback). + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError>; + /// 공개 URL 생성 /// /// # Arguments @@ -78,6 +88,10 @@ impl StorageClient for DummyStorageClient { Ok(()) } + async fn download(&self, _key: &str) -> Result<(Vec, Option), AppError> { + Ok((vec![1, 2, 3], Some("image/jpeg".to_string()))) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.base_url, key) } diff --git a/packages/api-server/src/services/storage/r2.rs b/packages/api-server/src/services/storage/r2.rs index d0a7d40f..9af9c9d4 100644 --- a/packages/api-server/src/services/storage/r2.rs +++ b/packages/api-server/src/services/storage/r2.rs @@ -95,6 +95,32 @@ impl StorageClient for CloudflareR2Client { Ok(()) } + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + let resp = self + .client + .get_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("NotFound") || msg.contains("NoSuchKey") || msg.contains("404") { + AppError::NotFound(format!("R2 object not found: {}", key)) + } else { + AppError::ExternalService(format!("Failed to download from R2: {}", e)) + } + })?; + + let content_type = resp.content_type().map(String::from); + let body = resp + .body + .collect() + .await + .map_err(|e| AppError::ExternalService(format!("Failed to read R2 body: {}", e)))?; + Ok((body.into_bytes().to_vec(), content_type)) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.public_url, key) }