This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -19,13 +19,12 @@ use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,
AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload,
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -196,13 +195,15 @@ pub async fn admin_list_database_table_rows(
Ok(json_success_body(Some(&request_context), response))
}
pub async fn admin_get_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?;
let config = state
.get_creation_entry_config()
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
@@ -1328,8 +1329,10 @@ mod tests {
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminUpsertCreationEntryTypeConfigRequest,
};
#[test]
fn normalize_debug_path_rejects_absolute_url() {

View File

@@ -16,8 +16,8 @@ use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview,
admin_upsert_creation_entry_config, require_admin_auth,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me,
admin_overview, admin_upsert_creation_entry_config, require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
@@ -90,10 +90,10 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run,
start_match3d_run, stop_match3d_run, stream_match3d_agent_message,
submit_match3d_agent_message,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -924,6 +924,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/tags",
post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}",
get(get_match3d_work_detail)
@@ -1510,7 +1517,7 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/password/reset", post(reset_password))
// 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问
// 后端 runtime/API 路由只按 open 做熔断visible 仅控制创作页入口展示
.layer(middleware::from_fn_with_state(
state.clone(),
require_creation_entry_route_enabled,
@@ -1993,7 +2000,10 @@ mod tests {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled");
assert_eq!(
body["error"]["details"]["reason"],
"creation_entry_disabled"
);
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
}

View File

@@ -19,8 +19,9 @@ use shared_contracts::assets::{
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadObjectAccess,
DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse,
GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
@@ -44,7 +45,8 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
"square_hole_shape_image",
"square_hole_hole_image",
];
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024;
// 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览Rodin GLB 可能明显超过图片上限。
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024;
const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300;
pub async fn create_direct_upload_ticket(
@@ -73,7 +75,10 @@ pub async fn create_direct_upload_ticket(
path_segments: payload.path_segments,
file_name: payload.file_name,
content_type: payload.content_type,
access: payload.access.unwrap_or(OssObjectAccess::Private),
access: payload
.access
.map(direct_upload_access_to_oss)
.unwrap_or(OssObjectAccess::Private),
metadata: payload.metadata,
max_size_bytes: payload.max_size_bytes,
expire_seconds: payload.expire_seconds,
@@ -85,7 +90,7 @@ pub async fn create_direct_upload_ticket(
"message": error.to_string(),
}))
})?;
let upload = DirectUploadTicketPayload::from(signed);
let upload = direct_upload_ticket_payload_from_oss(signed);
record_asset_tracking_event(
&state,
@@ -149,11 +154,76 @@ pub async fn get_asset_read_url(
Ok(json_success_body(
Some(&request_context),
GetAssetReadUrlResponse {
read: AssetReadUrlPayload::from(signed),
read: asset_read_url_payload_from_oss(signed),
},
))
}
fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess {
match value {
DirectUploadObjectAccess::Public => OssObjectAccess::Public,
DirectUploadObjectAccess::Private => OssObjectAccess::Private,
}
}
fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess {
match value {
OssObjectAccess::Public => DirectUploadObjectAccess::Public,
OssObjectAccess::Private => DirectUploadObjectAccess::Private,
}
}
fn direct_upload_ticket_payload_from_oss(
value: platform_oss::OssPostObjectResponse,
) -> DirectUploadTicketPayload {
DirectUploadTicketPayload {
signature_version: value.signature_version.to_string(),
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
legacy_public_path: value.legacy_public_path,
content_type: value.content_type,
access: direct_upload_access_from_oss(value.access),
key_prefix: value.key_prefix,
expires_at: value.expires_at,
max_size_bytes: value.max_size_bytes,
success_action_status: value.success_action_status,
form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields),
}
}
fn direct_upload_ticket_form_fields_from_oss(
value: platform_oss::OssPostObjectFormFields,
) -> DirectUploadTicketFormFields {
DirectUploadTicketFormFields {
key: value.key,
policy: value.policy,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
metadata: value.metadata,
}
}
fn asset_read_url_payload_from_oss(
value: platform_oss::OssSignedGetObjectUrlResponse,
) -> AssetReadUrlPayload {
AssetReadUrlPayload {
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
expires_at: value.expires_at,
signed_url: value.signed_url,
}
}
pub async fn get_asset_read_bytes(
State(state): State<AppState>,
Query(query): Query<GetReadUrlQuery>,

View File

@@ -8,6 +8,9 @@ use axum::{
};
use serde_json::{Value, json};
#[cfg(test)]
use module_runtime::build_creation_entry_config_response;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
@@ -92,8 +95,8 @@ fn creation_entry_error_response(request_context: &RequestContext, error: AppErr
}
#[cfg(test)]
pub(crate) fn test_creation_entry_config_response(
) -> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
pub(crate) fn test_creation_entry_config_response()
-> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot {
config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: module_runtime::CreationEntryStartCardSnapshot {
@@ -111,7 +114,7 @@ pub(crate) fn test_creation_entry_config_response(
test_creation_type("big-fish", false, true, 20),
test_creation_type("puzzle", true, true, 30),
test_creation_type("match3d", true, true, 40),
test_creation_type("square-hole", true, true, 50),
test_creation_type("square-hole", false, true, 50),
test_creation_type("visual-novel", true, true, 60),
test_creation_type("airp", true, false, 70),
test_creation_type("creative-agent", false, true, 80),

View File

@@ -231,12 +231,7 @@ pub(crate) async fn query_task_status(
.await?;
let jobs = extract_job_statuses(&response);
let status = normalize_task_status(
find_first_string_by_key(&response, "status")
.or_else(|| jobs.first().map(|job| job.status.clone()))
.as_deref()
.unwrap_or("unknown"),
);
let status = resolve_hyper3d_overall_status(&response, &jobs);
Ok(contract::Hyper3dTaskStatusResponse {
ok: true,
@@ -539,6 +534,33 @@ fn extract_job_statuses(payload: &Value) -> Vec<contract::Hyper3dJobStatusPayloa
.collect()
}
fn resolve_hyper3d_overall_status(
payload: &Value,
jobs: &[contract::Hyper3dJobStatusPayload],
) -> String {
if !jobs.is_empty() {
if jobs.iter().any(|job| job.status == "failed") {
return "failed".to_string();
}
if jobs.iter().all(|job| job.status == "done") {
return "done".to_string();
}
if jobs.iter().any(|job| job.status == "generating") {
return "generating".to_string();
}
if jobs.iter().any(|job| job.status == "waiting") {
return "waiting".to_string();
}
return "unknown".to_string();
}
normalize_task_status(
find_first_string_by_key(payload, "status")
.as_deref()
.unwrap_or("unknown"),
)
}
fn extract_job_uuids(payload: &Value) -> Vec<String> {
let mut job_uuids = Vec::new();
if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) {
@@ -580,6 +602,12 @@ fn collect_download_files(value: &Value, output: &mut Vec<contract::Hyper3dDownl
.get("url")
.or_else(|| object.get("download_url"))
.or_else(|| object.get("downloadUrl"))
.or_else(|| object.get("file_url"))
.or_else(|| object.get("fileUrl"))
.or_else(|| object.get("signed_url"))
.or_else(|| object.get("signedUrl"))
.or_else(|| object.get("presigned_url"))
.or_else(|| object.get("presignedUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"));
@@ -588,6 +616,9 @@ fn collect_download_files(value: &Value, output: &mut Vec<contract::Hyper3dDownl
.get("name")
.or_else(|| object.get("file_name"))
.or_else(|| object.get("filename"))
.or_else(|| object.get("fileName"))
.or_else(|| object.get("display_name"))
.or_else(|| object.get("displayName"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -1070,6 +1101,28 @@ mod tests {
assert_eq!(files[0].name, "model.glb");
}
#[test]
fn extracts_download_files_from_file_url_aliases() {
let files = extract_download_files(&json!({
"result": {
"files": [
{
"fileName": "rodin-result.glb",
"fileUrl": "https://cdn.example/rodin-result.glb?token=1"
},
{
"displayName": "preview.png",
"signedUrl": "https://cdn.example/preview.png?token=1"
}
]
}
}));
assert_eq!(files.len(), 2);
assert_eq!(files[0].name, "rodin-result.glb");
assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1");
}
#[test]
fn normalizes_status_values() {
assert_eq!(normalize_task_status("Waiting"), "waiting");
@@ -1077,4 +1130,34 @@ mod tests {
assert_eq!(normalize_task_status("Done"), "done");
assert_eq!(normalize_task_status("Failed"), "failed");
}
#[test]
fn resolves_status_done_only_when_all_jobs_done() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Generating" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs),
"generating"
);
}
#[test]
fn resolves_status_failed_when_any_job_failed() {
let jobs = extract_job_statuses(&json!({
"jobs": [
{ "uuid": "preview", "status": "Done" },
{ "uuid": "model", "status": "Failed", "message": "bad input" }
]
}));
assert_eq!(
resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs),
"failed"
);
}
}

View File

@@ -14,6 +14,7 @@ use axum::{
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::future::try_join_all;
use image::{GenericImageView, ImageFormat};
use module_match3d::{
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
@@ -79,9 +80,12 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_GENERATED_ITEM_COUNT: usize = 3;
const MATCH3D_GENERATED_CLEAR_COUNT: u32 = 3;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 2;
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 36;
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 120;
const MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS: u64 = 5_000;
const MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS: usize = 60;
const MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS: u64 = 5_000;
const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024;
const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作的关卡是难度几";
@@ -116,6 +120,12 @@ struct Match3DGeneratedItemAsset {
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedWorkMetadata {
game_name: String,
tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemAssetJson {
@@ -146,6 +156,16 @@ struct Match3DAssetUpload {
object_key: String,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemModelSeed {
item_id: String,
item_name: String,
item_slug: String,
image_upload: Match3DAssetUpload,
image_bytes: Vec<u8>,
generated_at_micros: i64,
}
struct Match3DRodinModelAsset {
task_uuid: String,
subscription_key: String,
@@ -172,6 +192,19 @@ pub(crate) struct CompileMatch3DDraftRequest {
cover_image_src: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsRequest {
game_name: String,
theme_text: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsResponse {
tags: Vec<String>,
}
pub async fn create_match3d_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -571,6 +604,26 @@ pub async fn put_match3d_work(
))
}
pub async fn generate_match3d_work_tags(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
let tags = generate_match3d_work_tags_for_profile(
&state,
payload.game_name.as_str(),
payload.theme_text.as_str(),
)
.await;
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DWorkTagsResponse { tags },
))
}
pub async fn publish_match3d_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -994,11 +1047,8 @@ async fn compile_match3d_draft_for_session(
));
}
let tags_json = tags
.as_ref()
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX);
let generated_work_metadata = generate_match3d_work_metadata(state, &config).await;
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
@@ -1008,6 +1058,13 @@ async fn compile_match3d_draft_for_session(
&config,
)
.await?;
let resolved_game_name =
normalize_optional_match3d_text(game_name).unwrap_or(generated_work_metadata.game_name);
let resolved_tags = tags
.map(normalize_tags)
.filter(|items| !items.is_empty())
.unwrap_or(generated_work_metadata.tags);
let tags_json = Some(serde_json::to_string(&resolved_tags).unwrap_or_default());
let session = state
.spacetime_client()
@@ -1016,8 +1073,8 @@ async fn compile_match3d_draft_for_session(
owner_user_id,
profile_id,
author_display_name: resolve_author_display_name(state, authenticated),
game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))),
summary_text: summary,
game_name: Some(resolved_game_name),
summary_text: normalize_optional_match3d_text(summary).or_else(|| Some(String::new())),
tags_json,
cover_image_src,
cover_asset_id: None,
@@ -1535,11 +1592,11 @@ fn first_positive_integer(text: &str) -> Option<u32> {
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result = Vec::new();
let mut result: Vec<String> = Vec::new();
for tag in tags {
let trimmed = tag.trim();
if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) {
result.push(trimmed.to_string());
let trimmed = normalize_match3d_tag(tag.as_str());
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
result.push(trimmed);
}
if result.len() >= 6 {
break;
@@ -1548,6 +1605,138 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
result
}
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_match3d_tag(value: &str) -> String {
let trimmed = value.trim();
let without_number_prefix = trimmed
.char_indices()
.find_map(|(index, ch)| {
if index == 0 || !matches!(ch, '.' | '、' | ')' | '') {
return None;
}
let prefix = &trimmed[..index];
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
Some(trimmed[index + ch.len_utf8()..].trim_start())
} else {
None
}
})
.unwrap_or(trimmed);
without_number_prefix
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.collect::<String>()
.chars()
.take(6)
.collect::<String>()
}
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_match3d_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= 6 {
break;
}
}
for fallback in ["抓大鹅", "经典消除", "3D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
) -> Vec<String> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_work_tags(game_name, theme_text);
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text, game_name
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = parse_match3d_tags_from_text(response.content.as_str());
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
return tags;
}
fallback_match3d_work_tags(game_name, theme_text)
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
fallback_match3d_work_tags(game_name, theme_text)
}
}
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('[')
&& let Some(end) = raw.rfind(']')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "3D素材"])
}
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
return None;
@@ -1634,7 +1823,7 @@ async fn generate_match3d_item_assets(
let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names)
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let mut item_assets = Vec::with_capacity(item_images.len());
let mut model_seeds = Vec::with_capacity(item_images.len());
for (index, item_image) in item_images.into_iter().enumerate() {
let item_name = item_names
.get(index)
@@ -1661,36 +1850,61 @@ async fn generate_match3d_item_assets(
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let model_asset = generate_match3d_rodin_model_asset(
state,
owner_user_id,
session_id,
profile_id,
&item_slug,
&item_name,
config,
image_bytes,
generated_at_micros.saturating_add(100 + index as i64),
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
item_assets.push(Match3DGeneratedItemAsset {
model_seeds.push(Match3DGeneratedItemModelSeed {
item_id,
item_name,
image_src: Some(image_upload.src),
image_object_key: Some(image_upload.object_key),
model_src: Some(model_asset.upload.src),
model_object_key: Some(model_asset.upload.object_key),
model_file_name: Some(model_asset.model_file_name),
task_uuid: Some(model_asset.task_uuid),
subscription_key: Some(model_asset.subscription_key),
status: "model_ready".to_string(),
error: None,
item_slug,
image_upload,
image_bytes,
generated_at_micros: generated_at_micros.saturating_add(100 + index as i64),
});
}
// 中文注释Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型,
// 避免多个物品的排队和轮询时间串行叠加导致 action 超时。
let model_results = try_join_all(model_seeds.into_iter().map(|seed| {
async move {
let Match3DGeneratedItemModelSeed {
item_id,
item_name,
item_slug,
image_upload,
image_bytes,
generated_at_micros,
} = seed;
let model_asset = generate_match3d_rodin_model_asset(
state,
owner_user_id,
session_id,
profile_id,
item_slug.as_str(),
item_name.as_str(),
config,
image_bytes,
generated_at_micros,
)
.await?;
Ok::<_, AppError>(Match3DGeneratedItemAsset {
item_id,
item_name,
image_src: Some(image_upload.src),
image_object_key: Some(image_upload.object_key),
model_src: Some(model_asset.upload.src),
model_object_key: Some(model_asset.upload.object_key),
model_file_name: Some(model_asset.model_file_name),
task_uuid: Some(model_asset.task_uuid),
subscription_key: Some(model_asset.subscription_key),
status: "model_ready".to_string(),
error: None,
})
}
}))
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
// 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。
Ok(item_assets)
Ok(model_results)
}
struct Match3DMaterialSheet {
@@ -1702,6 +1916,93 @@ struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
async fn generate_match3d_work_metadata(
state: &AppState,
config: &Match3DConfigJson,
) -> Match3DGeneratedWorkMetadata {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_work_metadata(config.theme_text.as_str());
};
let system_prompt = "你是抓大鹅游戏的作品命名编辑,只返回 JSON。";
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏作品元信息。要求:只返回 JSON 对象,字段为 gameName、tags。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字。不要生成描述。",
config.theme_text
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => parse_match3d_work_metadata(response.content.as_str())
.unwrap_or_else(|| fallback_match3d_work_metadata(config.theme_text.as_str())),
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
theme_text = config.theme_text.as_str(),
error = %error,
"抓大鹅作品名称生成失败,降级使用本地元信息"
);
fallback_match3d_work_metadata(config.theme_text.as_str())
}
}
}
fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('{')
&& let Some(end) = raw.rfind('}')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let value = serde_json::from_str::<Value>(json_text).ok()?;
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
if game_name.is_empty() {
return None;
}
let tags = value
.get("tags")
.and_then(Value::as_array)
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
.unwrap_or_default();
Some(Match3DGeneratedWorkMetadata {
game_name,
tags: normalize_match3d_tag_candidates(tags),
})
}
fn normalize_match3d_game_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(16)
.collect::<String>()
.trim()
.to_string()
}
fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
Match3DGeneratedWorkMetadata {
game_name: format!("{normalized_theme}抓大鹅"),
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "3D素材"]),
}
}
async fn generate_match3d_item_names(
state: &AppState,
config: &Match3DConfigJson,
@@ -1844,25 +2145,12 @@ async fn generate_match3d_rodin_model_asset(
)
.await?;
wait_for_match3d_rodin_model(
state,
submit_response.subscription_key.as_str(),
item_name,
)
.await?;
let download_response = query_downloads(
state,
hyper3d_contract::Hyper3dDownloadRequest {
task_uuid: submit_response.task_uuid.clone(),
},
)
.await?;
let model_file = select_match3d_glb_download(
&download_response.files,
submit_response.task_uuid.as_str(),
item_name,
)?;
let downloaded_model = download_match3d_rodin_model(model_file).await?;
wait_for_match3d_rodin_model(state, submit_response.subscription_key.as_str(), item_name)
.await?;
let model_file =
wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name)
.await?;
let downloaded_model = download_match3d_rodin_model(&model_file).await?;
let uploaded_model = persist_match3d_generated_bytes(
state,
owner_user_id,
@@ -1940,10 +2228,7 @@ async fn wait_for_match3d_rodin_model(
}
if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS,
))
.await;
tokio::time::sleep(Duration::from_millis(MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS)).await;
}
}
@@ -1952,25 +2237,77 @@ async fn wait_for_match3d_rodin_model(
)))
}
fn select_match3d_glb_download<'a>(
files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload],
async fn wait_for_match3d_rodin_download_file(
state: &AppState,
task_uuid: &str,
item_name: &str,
) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
) -> Result<hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
for attempt in 0..MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
let download_response = query_downloads(
state,
hyper3d_contract::Hyper3dDownloadRequest {
task_uuid: task_uuid.to_string(),
},
)
.await?;
if let Some(model_file) = find_match3d_glb_download(&download_response.files) {
return Ok(model_file.clone());
}
// 中文注释Rodin 状态 Done 后下载列表偶尔会延迟发布,短轮询避免把已完成任务误判失败。
if attempt + 1 < MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS,
))
.await;
}
}
Err(match3d_bad_gateway(format!(
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
)))
}
fn find_match3d_glb_download(
files: &[hyper3d_contract::Hyper3dDownloadFilePayload],
) -> Option<&hyper3d_contract::Hyper3dDownloadFilePayload> {
files
.iter()
.find(|file| {
file.name.to_ascii_lowercase().ends_with(".glb")
|| file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb")
})
.or_else(|| files.first())
.ok_or_else(|| {
match3d_bad_gateway(format!(
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
))
.find(|file| match3d_download_file_has_extension(file, ".glb"))
.or_else(|| {
files
.iter()
.find(|file| !is_match3d_preview_or_image_download(file))
})
}
fn match3d_download_file_has_extension(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
extension: &str,
) -> bool {
file.name.to_ascii_lowercase().ends_with(extension)
|| file
.url
.to_ascii_lowercase()
.split('?')
.next()
.unwrap_or("")
.ends_with(extension)
}
fn is_match3d_preview_or_image_download(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> bool {
let name = file.name.to_ascii_lowercase();
let url_path = file.url.to_ascii_lowercase();
let url_path = url_path.split('?').next().unwrap_or(url_path.as_str());
name.contains("preview")
|| url_path.contains("preview")
|| [".png", ".jpg", ".jpeg", ".webp", ".gif"]
.iter()
.any(|extension| name.ends_with(extension) || url_path.ends_with(extension))
}
async fn download_match3d_rodin_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {
@@ -2010,17 +2347,22 @@ async fn download_match3d_rodin_model(
fn normalize_match3d_model_file_name(raw: &str) -> String {
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
let sanitized = sanitize_match3d_asset_segment(without_query, "model");
if sanitized.to_ascii_lowercase().ends_with(".glb") {
sanitized
} else {
"model.glb".to_string()
}
let normalized = without_query.to_ascii_lowercase();
let stem = without_query
.strip_suffix(".glb")
.or_else(|| {
normalized
.strip_suffix(".glb")
.map(|_| &without_query[..without_query.len().saturating_sub(4)])
})
.unwrap_or(without_query);
let sanitized_stem = sanitize_match3d_asset_segment(stem, "model");
format!("{sanitized_stem}.glb")
}
fn normalize_match3d_model_content_type(raw: &str) -> String {
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
if normalized == "model/gltf-binary" || normalized == "application/octet-stream" {
if normalized == "model/gltf-binary" {
return normalized;
}
"model/gltf-binary".to_string()
@@ -2445,6 +2787,96 @@ mod tests {
);
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅","经典消除","轻量休闲"]}"#,
)
.expect("metadata should parse");
assert_eq!(metadata.game_name, "果园大鹅宴");
assert_eq!(
metadata.tags,
vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "3D素材", "收集"]
);
}
#[test]
fn match3d_work_metadata_fallback_keeps_empty_description_boundary() {
let metadata = fallback_match3d_work_metadata("水果");
assert_eq!(metadata.game_name, "水果抓大鹅");
assert!(metadata.tags.contains(&"水果".to_string()));
assert!(metadata.tags.contains(&"抓大鹅".to_string()));
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲");
}
#[test]
fn match3d_model_download_metadata_normalizes_to_glb() {
assert_eq!(
normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"),
"fruit-model.glb"
);
assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb");
assert_eq!(
normalize_match3d_model_content_type("application/octet-stream"),
"model/gltf-binary"
);
assert_eq!(
normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"),
"model/gltf-binary"
);
}
#[test]
fn match3d_model_download_prefers_glb_file() {
let files = vec![
hyper3d_contract::Hyper3dDownloadFilePayload {
name: "preview.png".to_string(),
url: "https://cdn.example/preview.png".to_string(),
},
hyper3d_contract::Hyper3dDownloadFilePayload {
name: "model".to_string(),
url: "https://cdn.example/model.glb?token=1".to_string(),
},
];
let selected = find_match3d_glb_download(&files).expect("glb download should be selected");
assert_eq!(selected.url, "https://cdn.example/model.glb?token=1");
}
#[test]
fn match3d_model_download_falls_back_to_first_file() {
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
name: "model".to_string(),
url: "https://cdn.example/download?id=1".to_string(),
}];
let selected =
find_match3d_glb_download(&files).expect("opaque download url should be accepted");
assert_eq!(selected.url, "https://cdn.example/download?id=1");
}
#[test]
fn match3d_model_download_does_not_accept_preview_image_only() {
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
name: "preview.png".to_string(),
url: "https://cdn.example/preview.png".to_string(),
}];
let result = find_match3d_glb_download(&files);
assert!(result.is_none());
}
#[test]
fn match3d_work_summary_maps_persisted_generated_item_assets() {
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {

View File

@@ -675,7 +675,6 @@ pub async fn admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
payload.granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros as i64,
@@ -1124,7 +1123,6 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
granted_user_tags: record.granted_user_tags,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),

View File

@@ -11,7 +11,6 @@ use module_auth::{
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use module_runtime::RuntimeSnapshotRecord;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_agent::MockLangChainRustAgentExecutor;
@@ -23,6 +22,7 @@ use platform_auth::{
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
@@ -274,7 +274,7 @@ impl AppState {
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.map(|item| item.visible && item.open)
.map(|item| item.open)
.unwrap_or(true))
}
@@ -291,7 +291,6 @@ impl AppState {
.iter_mut()
.find(|item| item.id == creation_type_id)
{
item.visible = enabled;
item.open = enabled;
} else {
config.creation_types.push(