1
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user