refactor(api-server): narrow puzzle state surface
This commit is contained in:
@@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -109,13 +110,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode
|
||||
}
|
||||
|
||||
pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
) -> Result<PuzzleVectorEngineSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
let base_url = state.vector_engine_base_url().trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
@@ -127,9 +124,7 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.vector_engine_api_key()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
@@ -147,11 +142,11 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_image_http_client(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
image_model: PuzzleImageModel,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
let provider = image_model.provider_name();
|
||||
let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms;
|
||||
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
@@ -397,11 +392,19 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
]);
|
||||
if let Some(reference_image) = reference_image
|
||||
&& let Some(reference_data_url) =
|
||||
if let Some(reference_image) = reference_image {
|
||||
if let Some(signed_read_url) = reference_image
|
||||
.signed_read_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
body.insert("image".to_string(), json!([signed_read_url]));
|
||||
} else if let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(body)
|
||||
@@ -462,6 +465,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b
|
||||
pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in reference_image_asset_object_id
|
||||
.into_iter()
|
||||
.chain(reference_image_asset_object_ids.iter().map(String::as_str))
|
||||
.map(|asset_object_id| {
|
||||
asset_object_id
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.unwrap_or_else(|| asset_object_id.trim())
|
||||
})
|
||||
.filter(|asset_object_id| !asset_object_id.is_empty())
|
||||
.map(|asset_object_id| format!("asset-object:{asset_object_id}"))
|
||||
.chain(
|
||||
legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
.map(str::to_string),
|
||||
)
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
@@ -488,9 +533,16 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> bool {
|
||||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||||
.is_empty()
|
||||
!collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src,
|
||||
reference_image_srcs,
|
||||
reference_image_asset_object_id,
|
||||
reference_image_asset_object_ids,
|
||||
)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn should_use_puzzle_reference_image_edit(
|
||||
@@ -546,10 +598,19 @@ pub(crate) async fn download_puzzle_images_from_urls(
|
||||
Ok(PuzzleGeneratedImages { task_id, images })
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &AppState,
|
||||
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
|
||||
source
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -562,6 +623,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) {
|
||||
return resolve_puzzle_reference_asset_object(
|
||||
state,
|
||||
http_client,
|
||||
asset_object_id,
|
||||
owner_user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
@@ -579,6 +650,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,7 +659,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||||
"message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -598,7 +670,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||||
"message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -615,8 +687,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let signed_read_url = signed.signed_url;
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed_read_url,
|
||||
object_key,
|
||||
None,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
resolve_puzzle_reference_image(state, http_client, source, None).await
|
||||
}
|
||||
|
||||
async fn resolve_puzzle_reference_asset_object(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
asset_object_id: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.get_asset_object(asset_object_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object_id,
|
||||
"message": "参考图资产不存在或当前账号不可见。",
|
||||
}))
|
||||
})?;
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
validate_puzzle_reference_asset_object(
|
||||
&asset_object,
|
||||
owner_user_id,
|
||||
oss_client.config_bucket(),
|
||||
)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: asset_object.object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let content_type = asset_object.content_type.clone();
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed.signed_url,
|
||||
asset_object.object_key.as_str(),
|
||||
content_type.as_deref(),
|
||||
"referenceImageAssetObjectId",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
asset_object: &module_assets::AssetObjectRecord,
|
||||
owner_user_id: Option<&str>,
|
||||
oss_bucket: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if asset_object.bucket.trim() != oss_bucket.trim() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产 bucket 与当前服务 OSS 配置不一致。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.asset_kind.trim() != "puzzle_cover_image" {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产类型不属于拼图图片。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let content_type = asset_object
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !content_type.starts_with("image/") {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不是图片类型。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产大小不符合拼图生成要求。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let actual_owner_user_id = asset_object
|
||||
.owner_user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if actual_owner_user_id != Some(expected_owner_user_id) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不属于当前账号。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_signed_puzzle_reference_image(
|
||||
http_client: &reqwest::Client,
|
||||
signed_read_url: String,
|
||||
object_key: &str,
|
||||
fallback_content_type: Option<&str>,
|
||||
field: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let response = http_client
|
||||
.get(signed.signed_url)
|
||||
.get(signed_read_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?;
|
||||
@@ -625,6 +848,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.or(fallback_content_type)
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
@@ -636,6 +860,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取参考图失败,状态码:{status}"),
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -645,6 +870,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": "读取参考图失败:对象内容为空",
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -655,6 +881,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,7 +920,7 @@ pub(crate) async fn download_puzzle_remote_image(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_generated_asset(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
@@ -805,7 +1032,7 @@ pub(crate) async fn persist_puzzle_generated_asset(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
|
||||
Reference in New Issue
Block a user