From c8b36cf799f1fad6ff6590d66b9b2e43e74ee5f9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 28 May 2026 14:31:13 +0800 Subject: [PATCH] fix wooden fish author and title display --- packages/shared/src/contracts/auth.ts | 1 + .../crates/api-server/src/auth_payload.rs | 1 + .../crates/api-server/src/wooden_fish.rs | 99 +++++++++++++++++-- server-rs/crates/shared-contracts/src/auth.rs | 1 + .../spacetime-client/src/wooden_fish.rs | 9 +- ...gEntryFlowShell.agent.interaction.test.tsx | 2 + .../RpgEntryHomeView.recharge.test.tsx | 3 + src/components/rpg-entry/RpgEntryHomeView.tsx | 71 +++++++++++-- .../rpg-entry/rpgEntryWorldPresentation.ts | 5 + .../WoodenFishWorkspace.tsx | 6 +- 10 files changed, 176 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 918c4c48..3f90fd98 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -17,6 +17,7 @@ export type AuthUser = { export type PublicUserSummary = { id: string; publicUserCode: string; + username: string; displayName: string; avatarUrl: string | null; }; diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs index cdd52b51..640629fc 100644 --- a/server-rs/crates/api-server/src/auth_payload.rs +++ b/server-rs/crates/api-server/src/auth_payload.rs @@ -20,6 +20,7 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo PublicUserSummaryPayload { id: user.id, public_user_code: user.public_user_code, + username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 28e31f2c..c0a4c216 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -67,6 +67,7 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../public/wooden-fish/default-hit-object.png" )); +const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家"; pub async fn create_wooden_fish_session( State(state): State, @@ -80,7 +81,7 @@ pub async fn create_wooden_fish_session( let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("wooden-fish-session-"); let now = current_utc_micros(); - let draft = build_wooden_fish_draft(&payload); + let draft = build_wooden_fish_draft(&payload, &state).await?; let session = WoodenFishSessionSnapshotResponse { session_id, owner_user_id, @@ -145,6 +146,7 @@ pub async fn execute_wooden_fish_action( let Json(mut payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); + let author_display_name = resolve_author_display_name(&state, &authenticated); maybe_generate_hit_object_asset( &state, &request_context, @@ -156,7 +158,7 @@ pub async fn execute_wooden_fish_action( maybe_generate_hit_sound_asset(&mut payload); let response = state .spacetime_client() - .execute_wooden_fish_action(session_id, owner_user_id, payload) + .execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload) .await .map_err(|error| { wooden_fish_error_response( @@ -366,12 +368,20 @@ pub async fn get_wooden_fish_gallery_detail( )) } -fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> WoodenFishDraftResponse { - WoodenFishDraftResponse { +async fn build_wooden_fish_draft( + payload: &WoodenFishWorkspaceCreateRequest, + state: &AppState, +) -> Result { + Ok(WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, - work_title: payload.work_title.trim().to_string(), + work_title: resolve_wooden_fish_work_title( + state, + &payload.work_description, + &payload.hit_object_prompt, + ) + .await?, work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), @@ -391,14 +401,13 @@ fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> Wooden .or_else(|| Some(default_wooden_fish_hit_sound_asset())), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, - } + }) } fn validate_workspace_request( request_context: &RequestContext, payload: &WoodenFishWorkspaceCreateRequest, ) -> Result<(), Response> { - ensure_non_empty(request_context, &payload.work_title, "workTitle")?; if payload.template_id.trim() != WOODEN_FISH_TEMPLATE_ID { return Err(wooden_fish_error_response( request_context, @@ -412,6 +421,77 @@ fn validate_workspace_request( Ok(()) } +fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME.to_string()) +} + +async fn resolve_wooden_fish_work_title( + state: &AppState, + work_description: &str, + hit_object_prompt: &str, +) -> Result { + let description = clean_string(work_description, hit_object_prompt); + if description.is_empty() { + return Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()); + } + let Some(llm_client) = state.llm_client() else { + return Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()); + }; + let request = platform_llm::LlmTextRequest::new(vec![ + platform_llm::LlmMessage::system( + "你是中文作品标题编辑。请根据敲木鱼作品描述生成一个适合卡片展示的简短中文标题,只输出纯文本,不要 JSON、标点解释或引号。", + ), + platform_llm::LlmMessage::user(format!( + "作品描述:{description}\n\n请生成 2 到 8 个中文字符为主的标题。" + )), + ]) + .with_model(crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(); + let response = llm_client.request_text(request).await; + match response { + Ok(response) => { + let title = normalize_wooden_fish_generated_work_title(response.content.as_str()); + if title.is_empty() { + Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()) + } else { + Ok(title) + } + } + Err(_) => Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()), + } +} + +fn normalize_wooden_fish_generated_work_title(value: &str) -> String { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .chars() + .filter(|ch| !ch.is_control()) + .collect::(); + let chars = normalized.chars().collect::>(); + if chars.len() <= 8 { + normalized + } else { + chars.into_iter().take(8).collect() + } +} + async fn maybe_generate_hit_object_asset( state: &AppState, request_context: &RequestContext, @@ -585,7 +665,10 @@ async fn generate_wooden_fish_image_assets( prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let clean_reference_image_src = hit_object_reference_image_src .map(str::trim) diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 1e7b2f33..46515416 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -32,6 +32,7 @@ pub struct AuthUserPayload { pub struct PublicUserSummaryPayload { pub id: String, pub public_user_code: String, + pub username: String, pub display_name: String, pub avatar_url: Option, } diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index 1aadc15f..66304b09 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -85,6 +85,7 @@ impl SpacetimeClient { &self, session_id: String, owner_user_id: String, + author_display_name: String, payload: WoodenFishActionRequest, ) -> Result { let current = self @@ -93,6 +94,7 @@ impl SpacetimeClient { let (procedure, _) = build_wooden_fish_action_plan( ¤t, &owner_user_id, + &author_display_name, &payload, current_unix_micros(), )?; @@ -416,6 +418,7 @@ enum WoodenFishAssetRefresh { fn build_wooden_fish_action_plan( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, + author_display_name: &str, payload: &WoodenFishActionRequest, now_micros: i64, ) -> Result<(WoodenFishActionProcedure, WoodenFishDraftResponse), SpacetimeClientError> { @@ -440,6 +443,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::Preserve, @@ -450,6 +454,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::HitObject, @@ -460,6 +465,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::HitSound, @@ -577,6 +583,7 @@ fn merge_action_into_draft( fn build_compile_input( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, + author_display_name: &str, profile_id: &str, draft: &mut WoodenFishDraftResponse, refresh: WoodenFishAssetRefresh, @@ -611,7 +618,7 @@ fn build_compile_input( session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), - author_display_name: "敲木鱼玩家".to_string(), + author_display_name: author_display_name.trim().to_string(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index e1641ac3..50cb1c8a 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -187,6 +187,7 @@ const authServiceMocks = vi.hoisted(() => ({ async (publicUserCode: string): Promise => ({ id: `public-user-${publicUserCode}`, publicUserCode, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -195,6 +196,7 @@ const authServiceMocks = vi.hoisted(() => ({ async (userId: string): Promise => ({ id: userId, publicUserCode: `code-${userId}`, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 36eb42f0..7b1a978c 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -328,6 +328,7 @@ const { async (code: string): Promise => ({ id: `id-${code}`, publicUserCode: code, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -336,6 +337,7 @@ const { async (userId: string): Promise => ({ id: userId, publicUserCode: `code-${userId}`, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -3285,6 +3287,7 @@ test('mobile recommend meta loads real author avatar from public user summary', mockGetPublicAuthUserById.mockResolvedValueOnce({ id: 'user-2', publicUserCode: 'SY-00000002', + username: 'puzzle_user', displayName: '拼图玩家', avatarUrl: 'data:image/png;base64,AUTHOR', }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 07ada522..5c6045a2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -611,6 +611,7 @@ function WorldCard({ onClick, className, authorAvatarUrl, + authorUsername, feedCardKey, enableCoverCarousel = false, isCoverCarouselActive = false, @@ -620,6 +621,7 @@ function WorldCard({ onClick: () => void; className?: string; authorAvatarUrl?: string | null; + authorUsername?: string | null; feedCardKey?: string; enableCoverCarousel?: boolean; isCoverCarouselActive?: boolean; @@ -653,7 +655,10 @@ function WorldCard({ const remixCount = getPlatformWorldRemixCount(entry); const likeCount = getPlatformWorldLikeCount(entry); const typeLabel = describePublicGalleryCardKind(entry); - const authorName = entry.authorDisplayName.trim() || '玩家'; + const authorName = resolvePublicEntryAuthorDisplayText( + entry, + authorUsername, + ); const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const cardLabel = `${entry.worldName},${typeLabel},${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(likeCount)}点赞`; @@ -935,6 +940,7 @@ function RecommendRuntimePreviewCard({ function RecommendSwipeCard({ entry, authorAvatarUrl, + authorUsername, isActive, visual, shareState, @@ -948,6 +954,7 @@ function RecommendSwipeCard({ }: { entry: PlatformPublicGalleryCard; authorAvatarUrl?: string | null; + authorUsername?: string | null; isActive: boolean; visual: ReactNode; shareState?: 'idle' | 'copied' | 'failed'; @@ -972,6 +979,7 @@ function RecommendSwipeCard({ ) => void; onDragPointerMove?: (event: PointerEvent) => void; onDragPointerUp?: (event: PointerEvent) => void; @@ -1014,7 +1024,10 @@ function RecommendRuntimeMeta({ }) { const likeCount = getPlatformWorldLikeCount(entry); const remixCount = getPlatformWorldRemixCount(entry); - const authorName = entry.authorDisplayName.trim() || '玩家'; + const authorName = resolvePublicEntryAuthorDisplayText( + entry, + authorUsername, + ); const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const displayName = formatPlatformWorkDisplayName(entry.worldName); @@ -1890,28 +1903,28 @@ async function getPublicWorkAuthorSummary( function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { if (isBigFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('大鱼吃小鱼'); } if (isPuzzleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('拼图'); } if (isMatch3DGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('抓大鹅'); } if (isSquareHoleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('方洞挑战'); } if (isJumpHopGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('???'); + return formatPlatformWorkDisplayTag('跳一跳'); } if (isWoodenFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('???'); + return formatPlatformWorkDisplayTag('敲木鱼'); } if (isVisualNovelGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('视觉小说'); } if (isBarkBattleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('汪汪声浪'); } if (isEdutainmentGalleryEntry(entry)) { return formatPlatformWorkDisplayTag(entry.templateName); @@ -1922,6 +1935,17 @@ function getPublicAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; } +function resolvePublicEntryAuthorDisplayText( + entry: PlatformPublicGalleryCard, + authorUsername?: string | null, +) { + if (isWoodenFishGalleryEntry(entry)) { + return authorUsername?.trim() || entry.authorDisplayName.trim() || '玩家'; + } + + return entry.authorDisplayName.trim() || '玩家'; +} + function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) { return Math.max(0, Math.round(entry.likeCount ?? 0)); } @@ -4178,6 +4202,17 @@ export function RpgEntryHomeView({ }, [publicAuthorSummariesByKey], ); + const getPublicEntryAuthorUsername = useCallback( + (entry: PlatformPublicGalleryCard) => { + const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); + if (!authorLookupKey) { + return null; + } + + return publicAuthorSummariesByKey[authorLookupKey]?.username?.trim() || null; + }, + [publicAuthorSummariesByKey], + ); const activeCategoryGroup = categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? categoryGroups[0] ?? @@ -5523,6 +5558,9 @@ export function RpgEntryHomeView({ authorAvatarUrl={getPublicEntryAuthorAvatarUrl( previousRecommendEntry, )} + authorUsername={getPublicEntryAuthorUsername( + previousRecommendEntry, + )} isActive={false} visual={ @@ -5564,6 +5605,9 @@ export function RpgEntryHomeView({ authorAvatarUrl={getPublicEntryAuthorAvatarUrl( nextRecommendEntry, )} + authorUsername={getPublicEntryAuthorUsername( + nextRecommendEntry, + )} isActive={false} visual={ onOpenGalleryDetail(entry)} className="w-full" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} feedCardKey={cardKey} /> ); @@ -5782,6 +5827,7 @@ export function RpgEntryHomeView({ onClick={() => onOpenGalleryDetail(entry)} className="w-full" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} feedCardKey={cardKey} enableCoverCarousel={mobileFeedCarouselEnabled} isCoverCarouselActive={ @@ -5888,6 +5934,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} /> ))} @@ -5915,6 +5962,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} /> ))} {onOpenChildMotionDemo ? ( @@ -5975,6 +6023,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} /> ))} @@ -6490,6 +6539,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} /> ))} @@ -6660,6 +6710,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorUsername={getPublicEntryAuthorUsername(entry)} /> ))} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 738b1404..308d0903 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -219,6 +219,7 @@ export type PlatformWoodenFishGalleryCard = { sourceSessionId?: string | null; publicWorkCode: string; ownerUserId: string; + authorUsername?: string | null; authorDisplayName: string; worldName: string; subtitle: string; @@ -562,6 +563,10 @@ export function mapWoodenFishWorkToPlatformGalleryCard( ? summary.publicWorkCode : buildWoodenFishPublicWorkCode(summary.profileId), ownerUserId: summary.ownerUserId, + authorUsername: + 'authorUsername' in summary && typeof summary.authorUsername === 'string' + ? summary.authorUsername + : null, authorDisplayName: 'authorDisplayName' in summary ? summary.authorDisplayName : '玩家', worldName: summary.workTitle, diff --git a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx index 9b6e6445..9cd0e387 100644 --- a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx +++ b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx @@ -40,7 +40,6 @@ type WoodenFishWorkspaceFormState = { floatingWords: string[]; }; -const DEFAULT_WORK_TITLE = '今日敲木鱼'; const DEFAULT_THEME_TAGS = ['敲木鱼', '解压']; const DEFAULT_FLOATING_WORDS = ['幸运']; const MAX_FLOATING_WORD_COUNT = 8; @@ -309,8 +308,9 @@ export function WoodenFishWorkspace({ try { const payload: WoodenFishWorkspaceCreateRequest = { templateId: 'wooden-fish', - workTitle: DEFAULT_WORK_TITLE, - workDescription: '', + workTitle: '', + workDescription: + formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT, themeTags: DEFAULT_THEME_TAGS, hitObjectPrompt: formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,