refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -0,0 +1,160 @@
use std::collections::BTreeMap;
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use super::helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, merge_generated_image_asset_metadata,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapterBoundary;
impl GeneratedImageAssetAdapterBoundary {
pub const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence.";
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapter;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetPersistInput {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_stem: String,
pub image: GeneratedImageAssetDataUrl,
pub access: OssObjectAccess,
pub metadata: GeneratedImageAssetAdapterMetadata,
pub extra_metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedImageAssetAdapterMetadata {
pub asset_kind: Option<String>,
pub owner_user_id: Option<String>,
pub entity_kind: Option<String>,
pub entity_id: Option<String>,
pub slot: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetPreparedPut {
pub request: OssPutObjectRequest,
pub storage_paths: GeneratedImageAssetStoragePaths,
pub format: GeneratedImageAssetImageFormat,
}
impl GeneratedImageAssetAdapter {
pub fn prepare_put_object(
input: GeneratedImageAssetPersistInput,
) -> Result<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
let file_name = format!(
"{}.{}",
input.file_stem.trim(),
input.image.format.extension
);
let storage_paths = build_generated_image_asset_storage_paths(
input.prefix,
&input.path_segments,
file_name.as_str(),
)?;
let metadata = merge_generated_image_asset_metadata(
build_generated_image_asset_metadata(input.metadata.into()),
input.extra_metadata,
);
let format = input.image.format.clone();
Ok(GeneratedImageAssetPreparedPut {
request: OssPutObjectRequest {
prefix: input.prefix,
path_segments: input.path_segments,
file_name,
content_type: Some(format.mime_type.clone()),
access: input.access,
metadata,
body: input.image.bytes,
},
storage_paths,
format,
})
}
}
impl From<GeneratedImageAssetAdapterMetadata> for super::helpers::GeneratedImageAssetMetadataInput {
fn from(value: GeneratedImageAssetAdapterMetadata) -> Self {
Self {
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
provider: value.provider,
task_id: value.task_id,
}
}
}
#[cfg(test)]
mod generated_image_assets_adapter_tests {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use super::*;
use crate::generated_assets::helpers::decode_generated_image_asset_data_url;
#[test]
fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() {
let image = decode_generated_image_asset_data_url(&format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"png bytes")
))
.expect("image should decode");
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec!["work/1".to_string(), "cover".to_string()],
file_stem: "image".to_string(),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some("square-hole-cover".to_string()),
owner_user_id: Some("user-1".to_string()),
entity_kind: Some("work".to_string()),
entity_id: Some("work-1".to_string()),
slot: Some("cover".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some("task-1".to_string()),
},
extra_metadata: BTreeMap::from([("caller".to_string(), "unit-test".to_string())]),
})
.expect("put object should be prepared");
assert_eq!(
GeneratedImageAssetAdapterBoundary::BILLING_BOUNDARY_COMMENT,
"generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."
);
assert_eq!(prepared.request.prefix, LegacyAssetPrefix::SquareHoleAssets);
assert_eq!(prepared.request.file_name, "image.png");
assert_eq!(prepared.request.content_type, Some("image/png".to_string()));
assert_eq!(prepared.request.body, b"png bytes");
assert_eq!(
prepared.storage_paths.object_key,
"generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.storage_paths.legacy_public_path,
"/generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.request.metadata.get("asset_kind"),
Some(&"square-hole-cover".to_string())
);
assert_eq!(
prepared.request.metadata.get("caller"),
Some(&"unit-test".to_string())
);
}
}

View File

@@ -0,0 +1,306 @@
use std::collections::BTreeMap;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::LegacyAssetPrefix;
const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetImageFormat {
pub mime_type: String,
pub extension: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetDataUrl {
pub format: GeneratedImageAssetImageFormat,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedImageAssetMetadataInput {
pub asset_kind: Option<String>,
pub owner_user_id: Option<String>,
pub entity_kind: Option<String>,
pub entity_id: Option<String>,
pub slot: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedImageAssetStoragePaths {
pub object_key: String,
pub legacy_public_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GeneratedImageAssetHelperError {
InvalidDataUrl,
UnsupportedEncoding,
DecodeBase64(String),
InvalidFileName,
}
pub fn normalize_generated_image_asset_mime(
raw_content_type: impl AsRef<str>,
) -> GeneratedImageAssetImageFormat {
let mime_type = raw_content_type
.as_ref()
.split(';')
.next()
.map(str::trim)
.unwrap_or(DEFAULT_IMAGE_MIME)
.to_ascii_lowercase();
match mime_type.as_str() {
"image/png" => image_format("image/png", "png"),
"image/webp" => image_format("image/webp", "webp"),
"image/gif" => image_format("image/gif", "gif"),
"image/jpeg" | "image/jpg" | "application/octet-stream" | "" => {
image_format(DEFAULT_IMAGE_MIME, "jpg")
}
_ => image_format(DEFAULT_IMAGE_MIME, "jpg"),
}
}
pub fn decode_generated_image_asset_data_url(
raw_data_url: &str,
) -> Result<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
let (metadata, encoded) = raw_data_url
.trim()
.split_once(',')
.ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?;
let metadata = metadata.trim();
if !metadata.to_ascii_lowercase().starts_with("data:") {
return Err(GeneratedImageAssetHelperError::InvalidDataUrl);
}
let header = &metadata["data:".len()..];
let mut parts = header
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty());
let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME);
let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64"));
if !is_base64 {
return Err(GeneratedImageAssetHelperError::UnsupportedEncoding);
}
let bytes = BASE64_STANDARD
.decode(encoded.trim())
.map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?;
Ok(GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(mime_type),
bytes,
})
}
pub fn build_generated_image_asset_storage_paths(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> Result<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
let file_name = sanitize_generated_image_asset_file_name(file_name)?;
let mut parts = vec![prefix.as_str().to_string()];
parts.extend(
path_segments
.iter()
.map(|segment| sanitize_generated_image_asset_path_segment(segment))
.filter(|segment| !segment.is_empty()),
);
parts.push(file_name);
let object_key = parts.join("/");
Ok(GeneratedImageAssetStoragePaths {
legacy_public_path: format!("/{object_key}"),
object_key,
})
}
pub fn build_generated_image_asset_metadata(
input: GeneratedImageAssetMetadataInput,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::new();
insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind);
insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id);
insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind);
insert_optional_metadata(&mut metadata, "entity_id", input.entity_id);
insert_optional_metadata(&mut metadata, "slot", input.slot);
insert_optional_metadata(&mut metadata, "provider", input.provider);
insert_optional_metadata(&mut metadata, "task_id", input.task_id);
metadata
}
pub fn merge_generated_image_asset_metadata(
base: BTreeMap<String, String>,
overlay: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
for (key, value) in base.into_iter().chain(overlay) {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
merged.insert(key.to_string(), value.to_string());
}
merged
}
fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat {
GeneratedImageAssetImageFormat {
mime_type: mime_type.to_string(),
extension: extension.to_string(),
}
}
fn insert_optional_metadata(
metadata: &mut BTreeMap<String, String>,
key: &str,
value: Option<String>,
) {
if let Some(value) = value {
let value = value.trim();
if !value.is_empty() {
metadata.insert(key.to_string(), value.to_string());
}
}
}
fn sanitize_generated_image_asset_path_segment(raw: &str) -> String {
raw.trim()
.trim_matches('/')
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
ch if ch.is_control() => '-',
ch => ch,
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn sanitize_generated_image_asset_file_name(
raw: &str,
) -> Result<String, GeneratedImageAssetHelperError> {
let sanitized = sanitize_generated_image_asset_path_segment(raw);
if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') {
return Err(GeneratedImageAssetHelperError::InvalidFileName);
}
Ok(sanitized)
}
#[cfg(test)]
mod generated_image_assets_tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn generated_image_assets_normalize_mime_and_extension() {
assert_eq!(
normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "),
GeneratedImageAssetImageFormat {
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}
);
assert_eq!(
normalize_generated_image_asset_mime("image/jpg"),
GeneratedImageAssetImageFormat {
mime_type: "image/jpeg".to_string(),
extension: "jpg".to_string(),
}
);
assert_eq!(
normalize_generated_image_asset_mime("text/plain"),
GeneratedImageAssetImageFormat {
mime_type: "image/jpeg".to_string(),
extension: "jpg".to_string(),
}
);
}
#[test]
fn generated_image_assets_decode_data_url_base64() {
let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=")
.expect("data url should decode");
assert_eq!(
decoded.format,
GeneratedImageAssetImageFormat {
mime_type: "image/webp".to_string(),
extension: "webp".to_string(),
}
);
assert_eq!(decoded.bytes, b"hello");
}
#[test]
fn generated_image_assets_reject_non_base64_data_url() {
assert_eq!(
decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(),
GeneratedImageAssetHelperError::UnsupportedEncoding
);
}
#[test]
fn generated_image_assets_build_object_key_and_legacy_path() {
let paths = build_generated_image_asset_storage_paths(
LegacyAssetPrefix::BigFishAssets,
&[" world/001 ".to_string(), "slot:cover".to_string()],
" image.png ",
)
.expect("paths should build");
assert_eq!(
paths.object_key,
"generated-big-fish-assets/world-001/slot-cover/image.png"
);
assert_eq!(
paths.legacy_public_path,
"/generated-big-fish-assets/world-001/slot-cover/image.png"
);
}
#[test]
fn generated_image_assets_merge_metadata_trims_and_overlay_wins() {
let base = BTreeMap::from([
("asset_kind".to_string(), " old ".to_string()),
("empty".to_string(), " ".to_string()),
]);
let overlay = BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
(" task_id ".to_string(), " task-1 ".to_string()),
]);
assert_eq!(
merge_generated_image_asset_metadata(base, overlay),
BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
("task_id".to_string(), "task-1".to_string()),
])
);
}
#[test]
fn generated_image_assets_build_metadata_omits_blank_values() {
let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput {
asset_kind: Some(" scene ".to_string()),
owner_user_id: Some("".to_string()),
entity_kind: Some("world".to_string()),
entity_id: None,
slot: Some(" cover ".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some(" task-1 ".to_string()),
});
assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string()));
assert_eq!(metadata.get("owner_user_id"), None);
assert_eq!(metadata.get("slot"), Some(&"cover".to_string()));
assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string()));
}
}