This commit is contained in:
2026-05-03 00:17:50 +08:00
parent 5831703156
commit 801d1d534a
16 changed files with 1337 additions and 449 deletions

View File

@@ -1,7 +1,4 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use std::collections::BTreeMap;
use axum::{
Json,
@@ -38,17 +35,20 @@ use crate::{
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings,
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
use tokio::time::sleep;
const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro";
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL;
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)]
@@ -79,7 +79,7 @@ pub async fn generate_character_visual(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
let candidate_count = payload.candidate_count.clamp(1, 4);
@@ -94,8 +94,8 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
@@ -121,6 +121,7 @@ pub async fn generate_character_visual(
"sourceMode": payload.source_mode,
"size": size,
"referenceImageCount": payload.reference_image_data_urls.len(),
"provider": "apimart",
})
.to_string(),
),
@@ -192,7 +193,7 @@ pub async fn generate_character_visual(
),
structured_payload_json: Some(
json!({
"provider": "dashscope",
"provider": "apimart",
"taskId": generated.task_id,
"model": model,
"imageCount": generated.images.len(),
@@ -307,7 +308,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let model = resolve_character_visual_model(payload.image_model.as_str());
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
create_visual_task(
state,
@@ -317,8 +318,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
@@ -768,47 +769,17 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
}
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
fn resolve_character_visual_model(value: &str) -> String {
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-proRPG 主图当前统一归一到 gpt-image-2
let trimmed = value.trim();
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
tracing::warn!(
requested_model = trimmed,
effective_model = CHARACTER_VISUAL_MODEL,
"角色主形象图片模型已归一到 gpt-image-2"
);
}
let api_key = state
.config
.dashscope_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(DashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
}))
})
CHARACTER_VISUAL_MODEL.to_string()
}
async fn resolve_reference_image_as_data_url(
@@ -868,9 +839,7 @@ async fn resolve_reference_image_as_data_url(
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}"))
})?;
.map_err(|error| map_image_request_error(format!("读取角色主形象参考图失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
@@ -879,7 +848,7 @@ async fn resolve_reference_image_as_data_url(
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
map_image_request_error(format!("读取角色主形象参考图内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
@@ -911,7 +880,7 @@ async fn resolve_reference_image_as_data_url(
async fn create_character_visual_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
fallback_prompt: &str,
@@ -922,12 +891,13 @@ async fn create_character_visual_generation(
let mut active_prompt = prompt;
let mut moderation_fallback_applied = false;
let mut last_moderation_error = String::new();
let model = resolve_character_visual_model(model);
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
match create_character_visual_generation_once(
http_client,
settings,
model,
model.as_str(),
active_prompt,
size,
candidate_count,
@@ -944,7 +914,7 @@ async fn create_character_visual_generation(
if attempt_index == 0
&& !fallback_prompt.trim().is_empty()
&& fallback_prompt.trim() != prompt.trim()
&& is_dashscope_moderation_error(&error) =>
&& is_image_moderation_error(&error) =>
{
last_moderation_error = error.body_text();
active_prompt = fallback_prompt;
@@ -954,7 +924,7 @@ async fn create_character_visual_generation(
}
}
Err(map_dashscope_request_error(format!(
Err(map_image_request_error(format!(
"角色主形象安全兜底重试未返回结果:{}",
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
)))
@@ -962,183 +932,44 @@ async fn create_character_visual_generation(
async fn create_character_visual_generation_once(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
settings: &OpenAiImageSettings,
_model: &str,
prompt: &str,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let mut content = vec![json!({ "text": prompt })];
for image in reference_images {
content.push(json!({ "image": image }));
}
let response = http_client
.post(format!(
"{}/services/aigc/image-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": {
"n": candidate_count,
"size": size,
"negative_prompt": build_character_visual_negative_prompt(),
"prompt_extend": true,
"watermark": false,
},
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
"创建角色主形象任务失败。",
));
}
let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?;
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("查询角色主形象任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"查询角色主形象任务失败。",
));
}
let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?;
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象生成成功,但没有返回可下载图片。",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls {
images.push(
download_generated_image(
http_client,
image_url.as_str(),
"下载角色主形象候选图失败。",
)
.await?,
);
}
return Ok(GeneratedCharacterVisuals {
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
"角色主形象任务执行失败。",
));
}
sleep(Duration::from_millis(
CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS,
))
.await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "角色主形象任务执行超时,请稍后重试。",
})),
let generated = create_openai_image_generation(
http_client,
settings,
prompt,
Some(build_character_visual_negative_prompt().as_str()),
size,
candidate_count,
reference_images,
"角色主形象生成失败",
)
.await?;
Ok(GeneratedCharacterVisuals {
task_id: generated.task_id,
actual_prompt: generated.actual_prompt,
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images: generated
.images
.into_iter()
.map(downloaded_openai_to_character_visual_image)
.collect(),
})
}
async fn download_generated_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
) -> Result<DownloadedGeneratedImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
let mut bytes = body.to_vec();
let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string();
let mut mime_type = normalized_mime_type;
fn downloaded_openai_to_character_visual_image(
image: DownloadedOpenAiImage,
) -> DownloadedGeneratedImage {
let mut bytes = image.bytes;
let mut extension = image.extension;
let mut mime_type = image.mime_type;
if mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice())
@@ -1148,11 +979,11 @@ async fn download_generated_image(
mime_type = "image/png".to_string();
}
Ok(DownloadedGeneratedImage {
DownloadedGeneratedImage {
bytes,
mime_type,
extension,
})
}
}
/// 统一的 PNG 透明背景后处理入口。
@@ -1339,79 +1170,32 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn parse_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
if let Some(message) = parsed
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(code) = parsed
.pointer("/error/code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
if let Some(code) = parsed
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
raw_text.trim().to_string()
}
fn map_dashscope_request_error(message: String) -> AppError {
fn map_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"provider": "apimart",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
#[cfg(test)]
fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
let message = match raw_text.trim() {
"" => fallback_message.to_string(),
value => value.to_string(),
};
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message),
"provider": "apimart",
"message": message,
"raw": raw_text.trim(),
}))
}
fn is_dashscope_moderation_error(error: &AppError) -> bool {
#[cfg(test)]
fn is_image_test_moderation_error(error: &AppError) -> bool {
is_image_moderation_error(error)
}
fn is_image_moderation_error(error: &AppError) -> bool {
let text = error.body_text();
let normalized = text.to_ascii_lowercase();
normalized.contains("ipinfringementsuspect")
@@ -1424,77 +1208,6 @@ fn is_dashscope_moderation_error(error: &AppError) -> bool {
|| text.contains("知识产权")
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key
&& let Some(text) = nested_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
continue;
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
collect_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let body = value.trim().strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
@@ -2012,12 +1725,6 @@ impl EmptyFallback for String {
}
}
struct DashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct GeneratedCharacterVisuals {
task_id: String,
actual_prompt: Option<String>,
@@ -2032,10 +1739,6 @@ struct DownloadedGeneratedImage {
extension: String,
}
struct ParsedJsonPayload {
payload: Value,
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
@@ -2066,13 +1769,22 @@ mod tests {
}
#[test]
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
let error = map_dashscope_upstream_error(
fn legacy_character_visual_model_normalizes_to_gpt_image_2() {
assert_eq!(
resolve_character_visual_model("wan2.7-image-pro"),
"gpt-image-2"
);
assert_eq!(resolve_character_visual_model(""), "gpt-image-2");
}
#[test]
fn image_ip_infringement_error_uses_moderation_fallback() {
let error = map_image_upstream_error(
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
"角色主形象任务执行失败。",
);
assert!(is_dashscope_moderation_error(&error));
assert!(is_image_test_moderation_error(&error));
}
#[test]