merge master into codex/wechat-mini-program-virtual-payment
This commit is contained in:
@@ -24,7 +24,9 @@ use shared_contracts::admin::{
|
||||
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
@@ -239,6 +241,40 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_list_work_visibility(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let admin_user_id = admin.session().subject.clone();
|
||||
let entries = state
|
||||
.list_admin_work_visibility(admin_user_id)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminWorkVisibilityListResponse { entries },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_update_work_visibility(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpdateWorkVisibilityRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let entry = validate_admin_work_visibility(payload)?;
|
||||
let admin_user_id = admin.session().subject.clone();
|
||||
let record = state
|
||||
.update_admin_work_visibility(admin_user_id, entry.0, entry.1, entry.2)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminUpdateWorkVisibilityResponse { entry: record },
|
||||
))
|
||||
}
|
||||
|
||||
fn map_admin_creation_entry_type_config(
|
||||
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
|
||||
) -> AdminCreationEntryTypeConfigPayload {
|
||||
@@ -284,6 +320,24 @@ fn validate_admin_creation_entry_config(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_admin_work_visibility(
|
||||
payload: AdminUpdateWorkVisibilityRequest,
|
||||
) -> Result<(String, String, bool), AppError> {
|
||||
let source_type = payload.source_type.trim().to_string();
|
||||
if source_type.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("sourceType 不能为空")
|
||||
);
|
||||
}
|
||||
let profile_id = payload.profile_id.trim().to_string();
|
||||
if profile_id.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("profileId 不能为空")
|
||||
);
|
||||
}
|
||||
Ok((source_type, profile_id, payload.visible))
|
||||
}
|
||||
|
||||
fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({
|
||||
"provider": "spacetimedb",
|
||||
|
||||
@@ -658,7 +658,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
|
||||
let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
|
||||
let app =
|
||||
build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
||||
@@ -5,14 +5,12 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
|
||||
AuthUserPayload {
|
||||
id: user.id,
|
||||
public_user_code: user.public_user_code,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
phone_number_masked: user.phone_number_masked,
|
||||
login_method: user.login_method.as_str().to_string(),
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
created_at: user.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +18,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,
|
||||
}
|
||||
|
||||
@@ -185,14 +185,7 @@ fn build_session_summary(
|
||||
session_id: representative.session_id.clone(),
|
||||
session_ids,
|
||||
session_count,
|
||||
client_type: representative.client_info.client_type.clone(),
|
||||
client_runtime: representative.client_info.client_runtime.clone(),
|
||||
client_platform: representative.client_info.client_platform.clone(),
|
||||
client_label,
|
||||
device_display_name: representative.client_info.device_display_name.clone(),
|
||||
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
|
||||
mini_program_env: representative.client_info.mini_program_env.clone(),
|
||||
user_agent: representative.client_info.user_agent.clone(),
|
||||
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
|
||||
is_current,
|
||||
created_at: group_earliest_created(&group).to_string(),
|
||||
|
||||
@@ -311,6 +311,7 @@ pub async fn generate_bark_battle_image_asset(
|
||||
async {
|
||||
generate_and_persist_bark_battle_image_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
&owner_user_id,
|
||||
&slot,
|
||||
draft_id.as_deref(),
|
||||
@@ -1197,6 +1198,7 @@ fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||
|
||||
async fn generate_and_persist_bark_battle_image_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
slot: &BarkBattleAssetSlot,
|
||||
draft_id: Option<&str>,
|
||||
@@ -1204,7 +1206,11 @@ async fn generate_and_persist_bark_battle_image_asset(
|
||||
prompt: &str,
|
||||
size: &str,
|
||||
) -> Result<BarkBattleGeneratedImageAsset, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(draft_id.unwrap_or(asset_id).to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
|
||||
@@ -94,7 +94,13 @@ pub async fn generate_character_visual(
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
let settings = require_openai_image_settings(&state)?;
|
||||
let settings = require_openai_image_settings(&state)?
|
||||
.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.clone()),
|
||||
Some(character_id.clone()),
|
||||
)
|
||||
;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
|
||||
state
|
||||
@@ -318,7 +324,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
||||
&model,
|
||||
&prompt,
|
||||
)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(character_id.clone()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
|
||||
@@ -553,7 +553,12 @@ pub async fn generate_custom_world_scene_image(
|
||||
"scene_image",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_openai_image_settings(&state)?;
|
||||
let settings = require_openai_image_settings(&state)?
|
||||
.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
normalized.profile_id.clone(),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
@@ -675,7 +680,10 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
|
||||
}),
|
||||
};
|
||||
let normalized = normalize_scene_image_request(payload)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
|
||||
Some(owner_user_id.to_string()),
|
||||
normalized.profile_id.clone(),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
@@ -1011,7 +1019,12 @@ pub async fn generate_custom_world_opening_cg(
|
||||
opening_cg_id.as_str(),
|
||||
OPENING_CG_POINTS_COST,
|
||||
async {
|
||||
let image_settings = require_openai_image_settings(&state)?;
|
||||
let image_settings = require_openai_image_settings(&state)?
|
||||
.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.clone()),
|
||||
normalized.profile_id.clone(),
|
||||
);
|
||||
let image_http_client = build_openai_image_http_client(&image_settings)?;
|
||||
let video_settings = require_ark_video_settings(&state)?;
|
||||
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
|
||||
|
||||
@@ -8,9 +8,10 @@ pub(super) async fn generate_opening_cg_storyboard(
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
reference_images: &[String],
|
||||
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
|
||||
let audit_settings = settings.clone();
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
&audit_settings,
|
||||
normalized.storyboard_prompt.as_str(),
|
||||
None,
|
||||
OPENING_CG_STORYBOARD_IMAGE_SIZE,
|
||||
|
||||
@@ -1050,6 +1050,8 @@ mod tests {
|
||||
api_key: "secret".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(crate) struct ExternalApiFailureDraft {
|
||||
pub(crate) prompt_chars: Option<usize>,
|
||||
pub(crate) reference_image_count: Option<usize>,
|
||||
pub(crate) image_model: Option<&'static str>,
|
||||
pub(crate) user_id: Option<String>,
|
||||
pub(crate) profile_id: Option<String>,
|
||||
pub(crate) request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ExternalApiFailureDraft {
|
||||
@@ -53,6 +56,9 @@ impl ExternalApiFailureDraft {
|
||||
prompt_chars: None,
|
||||
reference_image_count: None,
|
||||
image_model: None,
|
||||
user_id: None,
|
||||
profile_id: None,
|
||||
request_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +114,21 @@ impl ExternalApiFailureDraft {
|
||||
self.image_model = image_model;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_user_id(mut self, user_id: Option<String>) -> Self {
|
||||
self.user_id = user_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_profile_id(mut self, profile_id: Option<String>) -> Self {
|
||||
self.profile_id = profile_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_request_id(mut self, request_id: Option<String>) -> Self {
|
||||
self.request_id = request_id;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
|
||||
@@ -130,6 +151,9 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
|
||||
.with_prompt_chars(audit.prompt_chars)
|
||||
.with_reference_image_count(audit.reference_image_count)
|
||||
.with_image_model(audit.image_model)
|
||||
.with_user_id(None)
|
||||
.with_profile_id(None)
|
||||
.with_request_id(None)
|
||||
}
|
||||
|
||||
/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
|
||||
@@ -203,6 +227,9 @@ pub(crate) fn build_external_api_failure_tracking_draft(
|
||||
);
|
||||
draft.scope_kind = RuntimeTrackingScopeKind::Module;
|
||||
draft.scope_id = failure.provider.to_string();
|
||||
draft.user_id = failure.user_id.clone();
|
||||
draft.owner_user_id = failure.user_id.clone();
|
||||
draft.profile_id = failure.profile_id.clone();
|
||||
draft.metadata = build_external_api_failure_metadata(failure);
|
||||
draft
|
||||
}
|
||||
@@ -233,6 +260,30 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val
|
||||
if let Some(image_model) = failure.image_model {
|
||||
metadata["imageModel"] = json!(image_model);
|
||||
}
|
||||
if let Some(user_id) = failure
|
||||
.user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
metadata["userId"] = json!(truncate_field(user_id, 1_000));
|
||||
}
|
||||
if let Some(profile_id) = failure
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
metadata["profileId"] = json!(truncate_field(profile_id, 1_000));
|
||||
}
|
||||
if let Some(request_id) = failure
|
||||
.request_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
metadata["requestId"] = json!(truncate_field(request_id, 1_000));
|
||||
}
|
||||
if let Some(source) = failure
|
||||
.error_source
|
||||
.as_deref()
|
||||
|
||||
@@ -412,9 +412,18 @@ async fn maybe_generate_jump_hop_assets(
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
||||
payload.profile_id = Some(profile_id.clone());
|
||||
|
||||
let settings = require_openai_image_settings(state).map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map(|settings| {
|
||||
settings
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.clone()),
|
||||
)
|
||||
})
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
|
||||
@@ -172,7 +172,9 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
build_spacetime_unavailable_router(message)
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::other(format!("初始化应用状态失败:{error}")));
|
||||
return Err(std::io::Error::other(format!(
|
||||
"初始化应用状态失败:{error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -701,6 +701,7 @@ pub async fn generate_match3d_cover_image(
|
||||
.await?;
|
||||
let generated_cover = generate_match3d_cover_image_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
&context.owner_user_id,
|
||||
context.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -772,6 +773,7 @@ pub async fn generate_match3d_background_image_for_work(
|
||||
async {
|
||||
let generated_background = generate_match3d_background_image(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -883,6 +885,7 @@ pub async fn generate_match3d_container_image_for_work(
|
||||
async {
|
||||
let generated_container = generate_match3d_container_image(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
|
||||
@@ -202,6 +202,7 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
async move {
|
||||
let material_sheet = generate_match3d_material_sheet_from_level_scene(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
@@ -747,13 +748,19 @@ pub(super) struct Match3DSlicedItemImage {
|
||||
|
||||
async fn generate_match3d_material_sheet_from_level_scene(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_item_spritesheet_prompt();
|
||||
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
||||
|
||||
@@ -214,6 +214,7 @@ pub(super) async fn ensure_match3d_background_asset(
|
||||
|
||||
let generated_background = generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
@@ -260,6 +261,7 @@ pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
|
||||
};
|
||||
generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
@@ -292,6 +294,7 @@ pub(super) fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String
|
||||
|
||||
pub(super) async fn generate_match3d_cover_image_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -301,7 +304,12 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
||||
reference_image_srcs: Vec<String>,
|
||||
) -> Result<Match3DAssetUpload, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
||||
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
||||
@@ -422,6 +430,7 @@ pub(super) fn build_match3d_cover_reference_generation_prompt(
|
||||
|
||||
pub(super) async fn generate_match3d_background_image(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -430,6 +439,7 @@ pub(super) async fn generate_match3d_background_image(
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
@@ -441,6 +451,7 @@ pub(super) async fn generate_match3d_background_image(
|
||||
|
||||
pub(super) async fn generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -448,7 +459,12 @@ pub(super) async fn generate_match3d_level_asset_bundle(
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
|
||||
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
||||
@@ -583,6 +599,7 @@ pub(super) async fn generate_match3d_level_asset_bundle(
|
||||
|
||||
pub(super) async fn generate_match3d_container_image(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -590,7 +607,12 @@ pub(super) async fn generate_match3d_container_image(
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image = load_match3d_container_reference_image()?;
|
||||
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
||||
|
||||
@@ -3,8 +3,9 @@ use axum::{Router, middleware, routing::get};
|
||||
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_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -70,6 +71,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
.post(admin_update_work_visibility)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes",
|
||||
get(admin_list_profile_redeem_codes)
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
record_external_api_failure,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
@@ -34,6 +35,9 @@ pub(crate) struct OpenAiImageSettings {
|
||||
pub api_key: String,
|
||||
pub request_timeout_ms: u64,
|
||||
pub external_api_audit_state: Option<AppState>,
|
||||
pub external_api_audit_user_id: Option<String>,
|
||||
pub external_api_audit_profile_id: Option<String>,
|
||||
pub external_api_audit_request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OpenAiImageSettings {
|
||||
@@ -47,6 +51,18 @@ impl std::fmt::Debug for OpenAiImageSettings {
|
||||
"external_api_audit_enabled",
|
||||
&self.external_api_audit_state.is_some(),
|
||||
)
|
||||
.field(
|
||||
"external_api_audit_user_id",
|
||||
&self.external_api_audit_user_id,
|
||||
)
|
||||
.field(
|
||||
"external_api_audit_profile_id",
|
||||
&self.external_api_audit_profile_id,
|
||||
)
|
||||
.field(
|
||||
"external_api_audit_request_id",
|
||||
&self.external_api_audit_request_id,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -87,6 +103,9 @@ pub(crate) fn require_openai_image_settings(
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
external_api_audit_state: Some(state.clone()),
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,6 +259,28 @@ pub(crate) fn build_openai_image_request_body(
|
||||
}
|
||||
|
||||
impl OpenAiImageSettings {
|
||||
pub(crate) fn with_external_api_audit_actor(
|
||||
mut self,
|
||||
user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
) -> Self {
|
||||
self.external_api_audit_user_id = user_id;
|
||||
self.external_api_audit_profile_id = profile_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_external_api_audit_context(
|
||||
mut self,
|
||||
request_context: &RequestContext,
|
||||
user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
) -> Self {
|
||||
self.external_api_audit_user_id = user_id;
|
||||
self.external_api_audit_profile_id = profile_id;
|
||||
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn provider_settings(&self) -> VectorEngineImageSettings {
|
||||
VectorEngineImageSettings {
|
||||
base_url: self.base_url.clone(),
|
||||
@@ -310,6 +351,10 @@ pub(crate) async fn record_openai_image_failure_if_configured(
|
||||
let Some(draft) = build_openai_image_failure_audit_draft(error) else {
|
||||
return;
|
||||
};
|
||||
let draft = draft
|
||||
.with_user_id(settings.external_api_audit_user_id.clone())
|
||||
.with_profile_id(settings.external_api_audit_profile_id.clone())
|
||||
.with_request_id(settings.external_api_audit_request_id.clone());
|
||||
record_external_api_failure(state, draft).await;
|
||||
}
|
||||
|
||||
@@ -422,12 +467,18 @@ mod tests {
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
let v1_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -447,12 +498,18 @@ mod tests {
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
let v1_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -472,6 +529,9 @@ mod tests {
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
|
||||
@@ -1085,6 +1085,7 @@ pub(crate) fn attach_puzzle_level_asset_bundle(
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
@@ -1093,6 +1094,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
@@ -1104,6 +1106,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -1111,6 +1114,7 @@ pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||
generate_puzzle_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
@@ -1175,6 +1179,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1195,6 +1200,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
})?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
@@ -1209,6 +1215,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
let mut candidates = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
Some(profile_id.as_str()),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
&image_prompt,
|
||||
@@ -1262,6 +1269,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
{
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&target_level,
|
||||
@@ -1369,7 +1377,6 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
@@ -1441,6 +1448,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1544,6 +1552,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
.await?;
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&target_level,
|
||||
|
||||
@@ -78,6 +78,7 @@ pub(crate) async fn create_uploaded_puzzle_image_candidate(
|
||||
pub(crate) async fn generate_puzzle_image_candidates(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
profile_id: Option<&str>,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
@@ -150,6 +151,11 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
external_api_audit_user_id: Some(owner_user_id.to_string()),
|
||||
external_api_audit_profile_id: profile_id.map(ToOwned::to_owned),
|
||||
..settings
|
||||
};
|
||||
let vector_engine_started_at = Instant::now();
|
||||
let generated = if should_use_reference_image_generation {
|
||||
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||||
@@ -255,12 +261,18 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
|
||||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state.root_state())?;
|
||||
let settings = require_openai_image_settings(state.root_state())?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(session_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
@@ -292,12 +304,18 @@ pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
puzzle_image: &PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
let settings = require_puzzle_vector_engine_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(session_id.to_string()),
|
||||
);
|
||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
||||
|
||||
@@ -71,12 +71,14 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
|
||||
let now = current_utc_micros();
|
||||
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
|
||||
let onboarding_profile_id = format!("onboarding-profile-{now}");
|
||||
let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
|
||||
let tags =
|
||||
generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await;
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
"onboarding-guest",
|
||||
Some(onboarding_profile_id.as_str()),
|
||||
session_id.as_str(),
|
||||
naming.level_name.as_str(),
|
||||
prompt_text.as_str(),
|
||||
@@ -132,7 +134,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
));
|
||||
let item = PuzzleWorkProfileRecord {
|
||||
work_id: format!("onboarding-work-{now}"),
|
||||
profile_id: format!("onboarding-profile-{now}"),
|
||||
profile_id: onboarding_profile_id,
|
||||
owner_user_id: "onboarding-guest".to_string(),
|
||||
source_session_id: None,
|
||||
author_display_name: "陶泥儿主".to_string(),
|
||||
@@ -675,6 +677,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
@@ -689,6 +692,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
@@ -861,9 +865,11 @@ pub async fn execute_puzzle_agent_action(
|
||||
.await?,
|
||||
]
|
||||
} else {
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
|
||||
generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
Some(profile_id.as_str()),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
@@ -920,6 +926,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
})?;
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level,
|
||||
@@ -1079,6 +1086,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
|
||||
@@ -31,6 +31,9 @@ pub(crate) struct PuzzleVectorEngineSettings {
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) request_timeout_ms: u64,
|
||||
pub(crate) external_api_audit_state: Option<AppState>,
|
||||
pub(crate) external_api_audit_user_id: Option<String>,
|
||||
pub(crate) external_api_audit_profile_id: Option<String>,
|
||||
pub(crate) external_api_audit_request_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct PuzzleGeneratedImages {
|
||||
@@ -100,8 +103,25 @@ impl PuzzleVectorEngineSettings {
|
||||
api_key: self.api_key.clone(),
|
||||
request_timeout_ms: self.request_timeout_ms,
|
||||
external_api_audit_state: self.external_api_audit_state.clone(),
|
||||
external_api_audit_user_id: self.external_api_audit_user_id.clone(),
|
||||
external_api_audit_profile_id: self.external_api_audit_profile_id.clone(),
|
||||
external_api_audit_request_id: self.external_api_audit_request_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_external_api_audit_context(
|
||||
mut self,
|
||||
request_context: &RequestContext,
|
||||
user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
) -> Self {
|
||||
self.external_api_audit_user_id = user_id;
|
||||
self.external_api_audit_profile_id = profile_id;
|
||||
self.external_api_audit_request_id =
|
||||
Some(request_context.request_id().to_string());
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||
@@ -177,6 +197,9 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1),
|
||||
external_api_audit_state: Some(state.root_state().clone()),
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session(
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
@@ -90,6 +91,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session(
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
@@ -118,6 +120,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session(
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
@@ -145,6 +148,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session(
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
@@ -252,6 +256,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||
work.cover_image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -271,6 +276,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||
work.background_image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -301,6 +307,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -331,6 +338,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
@@ -380,6 +388,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||
|
||||
async fn generate_square_hole_image_data_url(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -389,7 +398,12 @@ async fn generate_square_hole_image_data_url(
|
||||
size: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
|
||||
@@ -489,6 +489,29 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_admin_work_visibility(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
) -> Result<Vec<shared_contracts::admin::AdminWorkVisibilityEntryPayload>, SpacetimeClientError>
|
||||
{
|
||||
self.spacetime_client
|
||||
.admin_list_work_visibility(admin_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_admin_work_visibility(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
source_type: String,
|
||||
profile_id: String,
|
||||
visible: bool,
|
||||
) -> Result<shared_contracts::admin::AdminWorkVisibilityEntryPayload, SpacetimeClientError>
|
||||
{
|
||||
self.spacetime_client
|
||||
.admin_update_work_visibility(admin_user_id, source_type, profile_id, visible)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_creation_entry_route_enabled(
|
||||
&self,
|
||||
creation_type_id: &str,
|
||||
@@ -1019,7 +1042,9 @@ impl fmt::Display for AppStateInitError {
|
||||
match self {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => {
|
||||
Self::AuthStore(error)
|
||||
| Self::DependencyUnavailable(error)
|
||||
| Self::WechatPay(error) => {
|
||||
write!(f, "{error}")
|
||||
}
|
||||
Self::SmsProvider(error) => write!(f, "{error}"),
|
||||
|
||||
@@ -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<AppState>,
|
||||
@@ -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<WoodenFishDraftResponse, Response> {
|
||||
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<String, Response> {
|
||||
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::<String>();
|
||||
let chars = normalized.chars().collect::<Vec<_>>();
|
||||
if chars.len() <= 8 {
|
||||
normalized
|
||||
} else {
|
||||
chars.into_iter().take(8).collect()
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_generate_hit_object_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -446,6 +526,7 @@ async fn maybe_generate_hit_object_asset(
|
||||
|
||||
let generated = generate_wooden_fish_image_assets(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id.as_str(),
|
||||
@@ -579,13 +660,18 @@ struct WoodenFishGeneratedImageAssets {
|
||||
|
||||
async fn generate_wooden_fish_image_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
prompt: &str,
|
||||
hit_object_reference_image_src: Option<&str>,
|
||||
) -> Result<WoodenFishGeneratedImageAssets, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_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)
|
||||
|
||||
@@ -98,7 +98,10 @@ pub fn should_rebind_orphan_work_owner(
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_)))
|
||||
!matches!(
|
||||
auth_user_service.get_user_by_id(&owner_user_id),
|
||||
Ok(Some(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,6 +140,9 @@ mod tests {
|
||||
|
||||
assert!(should_rebind_orphan_work_owner(&service, ""));
|
||||
assert!(should_rebind_orphan_work_owner(&service, "user_missing"));
|
||||
assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID));
|
||||
assert!(!should_rebind_orphan_work_owner(
|
||||
&service,
|
||||
ORPHAN_WORK_OWNER_USER_ID
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user