This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -24,7 +24,7 @@ use crate::{
},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_history, get_asset_read_url,
create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url,
},
auth::{
AuthenticatedAccessToken, attach_refresh_session_token, inspect_auth_claims,
@@ -572,6 +572,7 @@ pub fn build_router(state: AppState) -> Router {
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/assets/read-bytes", get(get_asset_read_bytes))
.route(
"/api/assets/hyper3d/text-to-model",
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(

View File

@@ -1,7 +1,9 @@
use axum::{
Json,
body::Body,
extract::{Extension, Query, State},
http::StatusCode,
http::{StatusCode, header},
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION,
@@ -42,6 +44,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;
const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300;
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
@@ -150,6 +154,94 @@ pub async fn get_asset_read_url(
))
}
pub async fn get_asset_read_bytes(
State(state): State<AppState>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Response, AppError> {
// 中文注释:浏览器可以用签名 URL 渲染图片,但不能稳定跨域 fetch 私有 OSS 字节Rodin 图生模型参考图转 Data URL 走同源中转。
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let object_key = resolve_object_key_from_query(&query).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "objectKey",
"reason": "必须提供 objectKey 或 legacyPublicPath",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: Some(
query
.expire_seconds
.unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS),
),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let upstream = reqwest::Client::new()
.get(signed.signed_url.as_str())
.send()
.await
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
let upstream_status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
if upstream_status == reqwest::StatusCode::NOT_FOUND {
return Err(
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": "资源不存在",
"objectKey": signed.object_key,
})),
);
}
if !upstream_status.is_success() {
return Err(map_asset_read_bytes_upstream_error(format!(
"OSS 读取返回非成功状态:{}",
upstream_status.as_u16()
)));
}
if upstream
.content_length()
.is_some_and(|size| size > ASSET_READ_BYTES_MAX_SIZE_BYTES)
{
return Err(map_asset_read_bytes_too_large());
}
let bytes = upstream
.bytes()
.await
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
if bytes.len() as u64 > ASSET_READ_BYTES_MAX_SIZE_BYTES {
return Err(map_asset_read_bytes_too_large());
}
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "private, max-age=60")
.body(Body::from(bytes))
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "asset-read-bytes",
"message": format!("构造资源内容响应失败:{error}"),
}))
})
}
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -517,6 +609,23 @@ fn map_confirm_asset_object_error(error: SpacetimeClientError) -> AppError {
}))
}
fn map_asset_read_bytes_upstream_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取资源内容失败:{message}"),
}))
}
fn map_asset_read_bytes_too_large() -> AppError {
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "aliyun-oss",
"message": format!(
"资源内容超过读取上限:{}MB",
ASSET_READ_BYTES_MAX_SIZE_BYTES / 1024 / 1024
),
}))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -866,6 +975,51 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn read_bytes_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-bytes?legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png")
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn read_bytes_rejects_missing_identifier() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-bytes")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn sts_upload_credentials_are_disabled_for_browser_writes() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -136,7 +136,7 @@ async fn submit_text_to_model(
)?)
}
async fn submit_image_to_model(
pub(crate) async fn submit_image_to_model(
state: &AppState,
payload: contract::Hyper3dImageToModelRequest,
) -> Result<contract::Hyper3dTaskSubmitResponse, AppError> {
@@ -212,14 +212,15 @@ async fn submit_image_to_model(
)?)
}
async fn query_task_status(
pub(crate) async fn query_task_status(
state: &AppState,
payload: contract::Hyper3dTaskStatusRequest,
) -> Result<contract::Hyper3dTaskStatusResponse, AppError> {
let settings = require_hyper3d_settings(state)?;
let http_client = build_hyper3d_http_client(&settings)?;
// 中文注释Hyper3D 返回的 subscriptionKey 是上游 opaque token只做非空校验不做人为 256 字符截断。
let subscription_key =
normalize_required_text(&payload.subscription_key, "subscriptionKey", 256)?;
normalize_required_opaque_text(&payload.subscription_key, "subscriptionKey")?;
let response = post_hyper3d_json(
&http_client,
&settings,
@@ -246,7 +247,7 @@ async fn query_task_status(
})
}
async fn query_downloads(
pub(crate) async fn query_downloads(
state: &AppState,
payload: contract::Hyper3dDownloadRequest,
) -> Result<contract::Hyper3dDownloadResponse, AppError> {
@@ -380,6 +381,7 @@ fn require_hyper3d_settings(state: &AppState) -> Result<Hyper3dSettings, AppErro
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": HYPER3D_PROVIDER,
"reason": "HYPER3D_BASE_URL 未配置",
"message": "Hyper3D Rodin 服务地址未配置,请设置 HYPER3D_BASE_URL 或 RODIN_BASE_URL 后重启 api-server。",
})),
);
}
@@ -394,6 +396,7 @@ fn require_hyper3d_settings(state: &AppState) -> Result<Hyper3dSettings, AppErro
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": HYPER3D_PROVIDER,
"reason": "HYPER3D_API_KEY 未配置",
"message": "Hyper3D Rodin API Key 未配置,请在本地私密环境设置 HYPER3D_API_KEY 或 RODIN_API_KEY 后重启 api-server。",
}))
})?;
@@ -689,6 +692,21 @@ fn normalize_required_text(
Ok(normalized)
}
fn normalize_required_opaque_text(value: &str, field: &'static str) -> Result<String, AppError> {
let normalized = value.trim().to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": HYPER3D_PROVIDER,
"field": field,
"message": format!("{field} 不能为空"),
})),
);
}
Ok(normalized)
}
fn normalize_optional_limited_text(
value: Option<&str>,
max_chars: usize,
@@ -997,6 +1015,16 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn accepts_opaque_subscription_key_without_length_cap() {
let long_key = "a".repeat(300);
let normalized =
normalize_required_opaque_text(&format!(" {long_key} "), "subscriptionKey")
.expect("subscription key should be accepted");
assert_eq!(normalized, long_key);
}
#[test]
fn decodes_png_data_url() {
let data_url = format!(

View File

@@ -1,6 +1,7 @@
use std::{
collections::BTreeMap,
convert::Infallible,
time::{SystemTime, UNIX_EPOCH},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -12,18 +13,25 @@ use axum::{
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{GenericImageView, ImageFormat};
use module_match3d::{
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
MATCH3D_SESSION_ID_PREFIX,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::{
hyper3d as hyper3d_contract,
match3d_agent::{
CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest,
Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse,
Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse,
Match3DCreatorConfigResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
Match3DCreatorConfigResponse,
Match3DGeneratedItemAssetResponse as Match3DAgentGeneratedItemAssetResponse,
Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
},
match3d_runtime::{
ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse,
@@ -51,6 +59,12 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
hyper3d_generation::{query_downloads, query_task_status, submit_image_to_model},
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::{map_llm_error, map_oss_error},
request_context::RequestContext,
state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
@@ -62,6 +76,12 @@ const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具";
const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 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_POLL_INTERVAL_MS: u64 = 5_000;
const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024;
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作的关卡是难度几";
@@ -73,6 +93,70 @@ struct Match3DConfigJson {
reference_image_src: Option<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
asset_style_id: Option<String>,
#[serde(default)]
asset_style_label: Option<String>,
#[serde(default)]
asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemAsset {
item_id: String,
item_name: String,
image_src: Option<String>,
image_object_key: Option<String>,
model_src: Option<String>,
model_object_key: Option<String>,
model_file_name: Option<String>,
task_uuid: Option<String>,
subscription_key: Option<String>,
status: String,
error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemAssetJson {
item_id: String,
item_name: String,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
model_src: Option<String>,
#[serde(default)]
model_object_key: Option<String>,
#[serde(default)]
model_file_name: Option<String>,
#[serde(default)]
task_uuid: Option<String>,
#[serde(default)]
subscription_key: Option<String>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DAssetUpload {
src: String,
object_key: String,
}
struct Match3DRodinModelAsset {
task_uuid: String,
subscription_key: String,
model_file_name: String,
upload: Match3DAssetUpload,
}
struct Match3DDownloadedModel {
bytes: Vec<u8>,
file_name: String,
content_type: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -265,7 +349,7 @@ pub async fn execute_match3d_agent_action(
));
}
let session = compile_match3d_draft_for_session(
let (session, generated_item_assets) = compile_match3d_draft_for_session(
&state,
&request_context,
&authenticated,
@@ -280,7 +364,10 @@ pub async fn execute_match3d_agent_action(
Ok(json_success_body(
Some(&request_context),
Match3DAgentActionResponse {
session: map_match3d_agent_session_response(session),
session: map_match3d_agent_session_response_with_assets(
session,
&generated_item_assets,
),
},
))
}
@@ -307,7 +394,7 @@ pub async fn compile_match3d_agent_draft(
"sessionId",
)?;
let session = compile_match3d_draft_for_session(
let (session, generated_item_assets) = compile_match3d_draft_for_session(
&state,
&request_context,
&authenticated,
@@ -322,7 +409,10 @@ pub async fn compile_match3d_agent_draft(
Ok(json_success_body(
Some(&request_context),
Match3DAgentActionResponse {
session: map_match3d_agent_session_response(session),
session: map_match3d_agent_session_response_with_assets(
session,
&generated_item_assets,
),
},
))
}
@@ -876,7 +966,7 @@ async fn compile_match3d_draft_for_session(
summary: Option<String>,
tags: Option<Vec<String>>,
cover_image_src: Option<String>,
) -> Result<Match3DAgentSessionRecord, Response> {
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
@@ -889,7 +979,14 @@ async fn compile_match3d_draft_for_session(
map_match3d_client_error(error),
)
})?;
if session.current_turn < 3 || session.progress_percent < 100 {
let mut config = resolve_config_or_default(session.config.as_ref());
config.clear_count = MATCH3D_GENERATED_CLEAR_COUNT;
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
let has_complete_form_config = !config.theme_text.trim().is_empty()
&& config.clear_count > 0
&& (1..=10).contains(&config.difficulty);
if !has_complete_form_config && (session.current_turn < 3 || session.progress_percent < 100) {
return Err(match3d_bad_request(
request_context,
MATCH3D_AGENT_PROVIDER,
@@ -897,17 +994,27 @@ async fn compile_match3d_draft_for_session(
));
}
let config = resolve_config_or_default(session.config.as_ref());
let tags_json = tags
.as_ref()
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
state
let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX);
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
)
.await?;
let session = state
.spacetime_client()
.compile_match3d_draft(Match3DCompileDraftRecordInput {
session_id,
owner_user_id,
profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX),
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,
@@ -915,6 +1022,9 @@ async fn compile_match3d_draft_for_session(
cover_image_src,
cover_asset_id: None,
compiled_at_micros: current_utc_micros(),
generated_item_assets_json: serialize_match3d_generated_item_assets(
&generated_item_assets,
),
})
.await
.map_err(|error| {
@@ -923,7 +1033,9 @@ async fn compile_match3d_draft_for_session(
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
})?;
Ok((session, generated_item_assets))
}
fn map_match3d_agent_session_response(
@@ -952,6 +1064,21 @@ fn map_match3d_agent_session_response(
}
}
fn map_match3d_agent_session_response_with_assets(
session: Match3DAgentSessionRecord,
generated_item_assets: &[Match3DGeneratedItemAsset],
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect();
}
response
}
fn map_match3d_anchor_pack_response_for_turn(
anchor: Match3DAnchorPackRecord,
current_turn: u32,
@@ -1015,6 +1142,9 @@ fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCre
reference_image_src: config.reference_image_src,
clear_count: config.clear_count,
difficulty: config.difficulty,
asset_style_id: config.asset_style_id,
asset_style_label: config.asset_style_label,
asset_style_prompt: config.asset_style_prompt,
}
}
@@ -1033,6 +1163,43 @@ fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultD
total_item_count: draft.total_item_count,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
generated_item_assets: Vec::new(),
}
}
fn map_match3d_generated_item_asset_for_agent(
asset: Match3DGeneratedItemAsset,
) -> Match3DAgentGeneratedItemAssetResponse {
Match3DAgentGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
status: asset.status,
error: asset.error,
}
}
fn map_match3d_generated_item_asset_for_work(
asset: Match3DGeneratedItemAssetJson,
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
status: asset.status,
error: asset.error,
}
}
@@ -1047,6 +1214,11 @@ fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAg
}
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
let generated_item_assets =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref())
.into_iter()
.map(map_match3d_generated_item_asset_for_work)
.collect();
Match3DWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
@@ -1065,6 +1237,7 @@ fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DW
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
generated_item_assets,
}
}
@@ -1156,14 +1329,14 @@ fn build_config_from_create_request(
.unwrap_or(MATCH3D_DEFAULT_THEME)
.to_string(),
reference_image_src: payload.reference_image_src.clone(),
clear_count: payload
.clear_count
.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT)
.max(1),
clear_count: MATCH3D_GENERATED_CLEAR_COUNT,
difficulty: payload
.difficulty
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
.clamp(1, 10),
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
}
}
@@ -1186,6 +1359,9 @@ fn build_config_from_message(
let mut theme_text = current.theme_text;
let mut clear_count = current.clear_count.max(1);
let mut difficulty = current.difficulty.clamp(1, 10);
let asset_style_id = current.asset_style_id;
let asset_style_label = current.asset_style_label;
let asset_style_prompt = current.asset_style_prompt;
match session.current_turn {
0 => {
@@ -1219,6 +1395,9 @@ fn build_config_from_message(
reference_image_src,
clear_count,
difficulty,
asset_style_id,
asset_style_label,
asset_style_prompt,
}
}
@@ -1229,15 +1408,28 @@ fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Mat
reference_image_src: config.reference_image_src.clone(),
clear_count: config.clear_count.max(1),
difficulty: config.difficulty.clamp(1, 10),
asset_style_id: config.asset_style_id.clone(),
asset_style_label: config.asset_style_label.clone(),
asset_style_prompt: config.asset_style_prompt.clone(),
})
.unwrap_or_else(|| Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
})
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
serde_json::to_string(config).ok()
}
@@ -1349,6 +1541,44 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
result
}
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
return None;
}
let items = assets
.iter()
.cloned()
.map(Match3DGeneratedItemAssetJson::from)
.collect::<Vec<_>>();
serde_json::to_string(&items).ok()
}
fn parse_match3d_generated_item_assets(value: Option<&str>) -> Vec<Match3DGeneratedItemAssetJson> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<Vec<Match3DGeneratedItemAssetJson>>(value).ok())
.unwrap_or_default()
}
impl From<Match3DGeneratedItemAsset> for Match3DGeneratedItemAssetJson {
fn from(asset: Match3DGeneratedItemAsset) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
status: asset.status,
error: asset.error,
}
}
}
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
@@ -1363,6 +1593,376 @@ fn resolve_author_display_name(
.unwrap_or_else(|| "玩家".to_string())
}
async fn generate_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
// 中文注释:外部模型、下载和 OSS 写入都留在 api-serverSpacetimeDB reducer 只保存确定性草稿。
let item_names = generate_match3d_item_names(state, config)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let material_sheet = generate_match3d_material_sheet(state, config, &item_names)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let generated_at_micros = current_utc_micros();
let _sheet_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["material-sheet", material_sheet.task_id.as_str()],
"sheet.png",
"image/png",
material_sheet.image.bytes.clone(),
"match3d_material_sheet",
Some(material_sheet.task_id.as_str()),
generated_at_micros,
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
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());
for (index, item_image) in item_images.into_iter().enumerate() {
let item_name = item_names
.get(index)
.cloned()
.unwrap_or_else(|| format!("物品{}", index + 1));
let item_id = format!("match3d-item-{}", index + 1);
let item_slug = format!(
"{item_id}-{}",
sanitize_match3d_asset_segment(&item_name, "item")
);
let image_bytes = item_image.bytes;
let image_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["items", item_slug.as_str(), "image"],
"image.png",
"image/png",
image_bytes.clone(),
"match3d_item_image",
Some(material_sheet.task_id.as_str()),
generated_at_micros.saturating_add(index as i64 + 1),
)
.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 {
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,
});
}
// 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。
Ok(item_assets)
}
struct Match3DMaterialSheet {
task_id: String,
image: DownloadedOpenAiImage,
}
struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
async fn generate_match3d_item_names(
state: &AppState,
config: &Match3DConfigJson,
) -> Result<Vec<String>, AppError> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return Ok(fallback_match3d_item_names(config.theme_text.as_str()));
};
let system_prompt = "你是抓大鹅游戏的物品命名编辑,只返回 JSON 字符串数组。";
let user_prompt = format!(
"题材:{}\n请生成 {} 个适合抓大鹅点击消除玩法的短中文物品名称。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
config.theme_text, MATCH3D_GENERATED_ITEM_COUNT
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_responses_api(),
)
.await
.map_err(map_llm_error)?;
let parsed = parse_match3d_item_names(response.content.as_str());
if parsed.len() == MATCH3D_GENERATED_ITEM_COUNT {
return Ok(parsed);
}
Ok(fallback_match3d_item_names(config.theme_text.as_str()))
}
fn parse_match3d_item_names(raw: &str) -> Vec<String> {
let raw = raw.trim();
let parsed_array = serde_json::from_str::<Vec<String>>(raw)
.ok()
.or_else(|| {
let start = raw.find('[')?;
let end = raw.rfind(']')?;
serde_json::from_str::<Vec<String>>(&raw[start..=end]).ok()
})
.unwrap_or_default();
let mut names = Vec::new();
for name in parsed_array {
let normalized = normalize_match3d_item_name(name.as_str());
if !normalized.is_empty() && !names.contains(&normalized) {
names.push(normalized);
}
if names.len() >= MATCH3D_GENERATED_ITEM_COUNT {
break;
}
}
names
}
fn normalize_match3d_item_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(12)
.collect::<String>()
.trim()
.to_string()
}
fn fallback_match3d_item_names(theme_text: &str) -> Vec<String> {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
["小物件", "徽章", "摆件"]
.into_iter()
.map(|suffix| format!("{normalized_theme}{suffix}"))
.take(MATCH3D_GENERATED_ITEM_COUNT)
.collect()
}
async fn generate_match3d_material_sheet(
state: &AppState,
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、UI、边框、网格线、标签、人物手部、复杂背景"),
"1:1",
1,
&[],
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
image,
})
}
fn build_match3d_material_sheet_prompt(
config: &Match3DConfigJson,
item_names: &[String],
) -> String {
let asset_style_prompt = resolve_match3d_asset_style_prompt(config);
let style_clause = asset_style_prompt
.as_ref()
.map(|prompt| format!("整体画风遵循:{prompt}"))
.unwrap_or_default();
format!(
"生成一张1:1图片。生成{grid}*{grid}网格素材图,画面是{theme}题材的抓大鹅游戏素材。{style_clause}只绘制这些物品:{items}。每个格子一个独立居中的完整物体统一柔和光照正交或轻微俯视角清晰轮廓适合后续切割成图生3D模型参考。不要出现文字、水印、UI、边框、网格线、标签。",
grid = MATCH3D_MATERIAL_GRID_SIZE,
theme = config.theme_text,
style_clause = style_clause,
items = item_names.join(""),
)
}
fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option<String> {
config
.asset_style_prompt
.as_deref()
.or(config.asset_style_label.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Match3DSlicedItemImage>, AppError> {
// 中文注释:当前 3 件物品使用 2x2 素材图,按阅读顺序取前三格,第四格作为生成冗余。
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅素材图解码失败:{error}"),
}))
})?;
let (width, height) = source.dimensions();
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
let cell_height = height / MATCH3D_MATERIAL_GRID_SIZE;
if cell_width == 0 || cell_height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": "抓大鹅素材图尺寸过小,无法切割",
})),
);
}
let mut slices = Vec::with_capacity(item_names.len());
for index in 0..item_names.len().min(MATCH3D_GENERATED_ITEM_COUNT) {
let col = (index as u32) % MATCH3D_MATERIAL_GRID_SIZE;
let row = (index as u32) / MATCH3D_MATERIAL_GRID_SIZE;
let cropped = source.crop_imm(col * cell_width, row * cell_height, cell_width, cell_height);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅素材图切割失败:{error}"),
}))
})?;
slices.push(Match3DSlicedItemImage {
bytes: cursor.into_inner(),
});
}
Ok(slices)
}
#[allow(clippy::too_many_arguments)]
async fn persist_match3d_generated_bytes(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
path_segments: &[&str],
file_name: &str,
content_type: &str,
bytes: Vec<u8>,
asset_kind: &str,
source_job_id: Option<&str>,
generated_at_micros: i64,
) -> Result<Match3DAssetUpload, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let mut metadata = BTreeMap::new();
metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string());
metadata.insert(
"x-oss-meta-owner-user-id".to_string(),
owner_user_id.to_string(),
);
metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string());
if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) {
metadata.insert(
"x-oss-meta-source-job-id".to_string(),
source_job_id.to_string(),
);
}
let put_result = oss_client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Match3DAssets,
path_segments: std::iter::once(session_id)
.chain(std::iter::once(profile_id))
.chain(path_segments.iter().copied())
.map(|segment| sanitize_match3d_asset_segment(segment, "asset"))
.collect(),
file_name: file_name.to_string(),
content_type: Some(content_type.to_string()),
access: OssObjectAccess::Private,
metadata,
body: bytes,
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let _ = generated_at_micros;
Ok(Match3DAssetUpload {
src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(64).collect()
}
}
fn normalize_match3d_run_status(value: &str) -> &str {
match value {
"Running" => "running",
@@ -1529,6 +2129,9 @@ mod tests {
reference_image_src: None,
clear_count,
difficulty,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}
}
@@ -1595,4 +2198,66 @@ mod tests {
assert_eq!(response.difficulty.value, "");
assert_eq!(response.difficulty.status, "missing");
}
#[test]
fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
let item_names = ["草莓", "苹果", "香蕉"];
let slugs = item_names
.iter()
.enumerate()
.map(|(index, item_name)| {
let item_id = format!("match3d-item-{}", index + 1);
format!(
"{item_id}-{}",
sanitize_match3d_asset_segment(item_name, "item")
)
})
.collect::<Vec<_>>();
assert_eq!(
slugs,
vec![
"match3d-item-1-item",
"match3d-item-2-item",
"match3d-item-3-item",
]
);
}
#[test]
fn match3d_work_summary_maps_persisted_generated_item_assets() {
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"#
.to_string(),
),
});
assert_eq!(response.generated_item_assets.len(), 1);
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
assert_eq!(response.generated_item_assets[0].status, "image_ready");
assert_eq!(
response.generated_item_assets[0].image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
);
}
}

View File

@@ -116,6 +116,8 @@ const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -197,6 +199,7 @@ pub async fn generate_puzzle_onboarding_work(
level_name.as_str(),
prompt_text.as_str(),
None,
false,
Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2),
1,
0,
@@ -886,6 +889,7 @@ pub async fn execute_puzzle_agent_action(
&target_level.level_name,
&prompt,
payload.reference_image_src.as_deref(),
payload.ai_redraw.unwrap_or(true),
payload.image_model.as_deref(),
candidate_count,
candidate_start_index,
@@ -2349,6 +2353,7 @@ fn map_puzzle_leaderboard_entry_response(
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
visible_tags: entry.visible_tags,
is_current_player: entry.is_current_player,
}
}
@@ -2809,6 +2814,7 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result<Option
"level_id": level.level_id,
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"candidates": level
.candidates
.iter()
@@ -3098,8 +3104,9 @@ fn build_puzzle_levels_with_primary_update(
.or_else(|| (!levels.is_empty()).then_some(0))
{
levels[index].level_name = target_level.level_name.clone();
if let Some(picture_reference) =
picture_reference.map(str::trim).filter(|value| !value.is_empty())
if let Some(picture_reference) = picture_reference
.map(str::trim)
.filter(|value| !value.is_empty())
{
levels[index].picture_reference = Some(picture_reference.to_string());
}
@@ -3145,6 +3152,7 @@ async fn compile_puzzle_draft_with_initial_cover(
&target_level.level_name,
&image_prompt,
reference_image_src,
true,
image_model,
1,
target_level.candidates.len(),
@@ -4059,6 +4067,7 @@ async fn generate_puzzle_image_candidates(
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
image_model: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
@@ -4066,12 +4075,14 @@ async fn generate_puzzle_image_candidates(
let total_started_at = Instant::now();
let count = candidate_count.clamp(1, 1);
let resolved_model = resolve_puzzle_image_model(image_model);
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
let has_reference_image = reference_image_src
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false);
let has_reference_image = has_puzzle_reference_image(reference_image_src);
let should_use_reference_image_edit =
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
build_puzzle_image_prompt(level_name, prompt).as_str(),
should_use_reference_image_edit,
);
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
@@ -4080,12 +4091,14 @@ async fn generate_puzzle_image_candidates(
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
"拼图图片生成请求已准备"
);
let reference_image_started_at = Instant::now();
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|_| should_use_reference_image_edit)
{
Some(source) => {
let resolved =
@@ -4104,12 +4117,14 @@ async fn generate_puzzle_image_candidates(
}
None => None,
};
if !has_reference_image {
if !should_use_reference_image_edit {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析跳过"
);
@@ -4118,19 +4133,36 @@ async fn generate_puzzle_image_candidates(
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let settings = require_puzzle_vector_engine_settings(state)?;
let vector_engine_started_at = Instant::now();
let generated = create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image
.as_ref()
.map(|image| image.data_url.as_str()),
)
.await
let generated = if should_use_reference_image_edit {
let reference_image = reference_image.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "AI 重绘需要提供参考图。",
}))
})?;
create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
.await
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
)
.await
}
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
@@ -4219,14 +4251,13 @@ mod tests {
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
Some("data:image/png;base64,abcd"),
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
assert_eq!(body["n"], 1);
assert!(body.get("official_fallback").is_none());
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
assert!(body.get("image").is_none());
assert!(
body["prompt"]
.as_str()
@@ -4235,6 +4266,61 @@ mod tests {
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
let images = puzzle_images_from_base64(
"edit-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
assert!(prompt.contains("参考图作为第一优先级"));
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
assert!(prompt.contains("请生成雨夜猫街。"));
}
#[test]
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
assert_eq!(prompt, "请生成雨夜猫街。");
}
#[test]
fn puzzle_reference_image_edit_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_edit(None, true));
assert!(!should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
false
));
assert!(should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
true
));
}
#[test]
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
@@ -4583,9 +4669,9 @@ struct PuzzleGeneratedImages {
}
struct PuzzleResolvedReferenceImage {
data_url: String,
mime_type: String,
bytes_len: usize,
bytes: Vec<u8>,
}
struct GeneratedPuzzleImageCandidate {
@@ -4721,7 +4807,6 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&str>,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
@@ -4729,13 +4814,8 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt,
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let has_reference_image = reference_image
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false);
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
@@ -4762,7 +4842,7 @@ async fn create_puzzle_vector_engine_image_generation(
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image,
has_reference_image = false,
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
@@ -4811,15 +4891,114 @@ async fn create_puzzle_vector_engine_image_generation(
)
}
async fn create_puzzle_vector_engine_image_edit(
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &PuzzleResolvedReferenceImage,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_url = puzzle_vector_engine_images_edit_url(settings);
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let file_name = format!(
"puzzle-reference.{}",
puzzle_mime_to_extension(reference_image.mime_type.as_str())
);
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(file_name)
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"构造拼图 VectorEngine 图片编辑参考图失败:{error}"
))
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string())
.text(
"prompt",
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
)
.text("n", candidate_count.clamp(1, 1).to_string())
.text("size", size.to_string());
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
))
})?;
let status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL,
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片编辑 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片编辑响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_puzzle_vector_engine_upstream_error(
status,
response_text.as_str(),
"创建拼图 VectorEngine 图片编辑任务失败",
));
}
let payload = parse_puzzle_json_payload(
response_text.as_str(),
"解析拼图 VectorEngine 图片编辑响应失败",
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count)
.await;
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
task_id,
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 VectorEngine 图片编辑未返回图片",
})),
)
}
fn build_puzzle_vector_engine_image_request_body(
image_model: PuzzleImageModel,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&str>,
) -> Value {
let mut body = Map::from_iter([
Value::Object(Map::from_iter([
(
"model".to_string(),
Value::String(image_model.request_model_name().to_string()),
@@ -4830,16 +5009,37 @@ fn build_puzzle_vector_engine_image_request_body(
),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]);
]))
}
if let Some(reference_image) = reference_image
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("image".to_string(), json!([reference_image]));
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
let prompt = prompt.trim();
if !has_reference_image {
return prompt.to_string();
}
Value::Object(body)
format!(
concat!(
"请以随请求提供的参考图作为第一优先级生成依据,严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围;",
"允许按下面文字要求做风格化和细节增强,但不要改成与参考图无关的新画面。\n",
"{prompt}"
),
prompt = prompt,
)
}
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
reference_image_src
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
}
fn should_use_puzzle_reference_image_edit(
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
) -> bool {
use_reference_image_edit && has_puzzle_reference_image(reference_image_src)
}
fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
@@ -4860,6 +5060,14 @@ fn puzzle_vector_engine_images_generation_url(settings: &PuzzleVectorEngineSetti
}
}
fn puzzle_vector_engine_images_edit_url(settings: &PuzzleVectorEngineSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
format!("{}/v1/images/edits", settings.base_url)
}
}
async fn download_puzzle_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
@@ -4894,15 +5102,21 @@ async fn resolve_puzzle_reference_image_as_data_url(
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
let bytes_len = parsed.bytes.len();
let data_url = format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(&parsed.bytes)
);
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图过大,请压缩后重试。",
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": bytes_len,
})),
);
}
return Ok(PuzzleResolvedReferenceImage {
data_url,
mime_type: parsed.mime_type,
bytes_len,
bytes: parsed.bytes,
});
}
@@ -4976,9 +5190,9 @@ async fn resolve_puzzle_reference_image_as_data_url(
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
let bytes_len = body.len();
Ok(PuzzleResolvedReferenceImage {
data_url: format!("data:{};base64,{}", mime_type, BASE64_STANDARD.encode(body)),
mime_type,
bytes_len,
bytes: body.to_vec(),
})
}
@@ -5228,6 +5442,36 @@ fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
deduped
}
fn extract_puzzle_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_puzzle_strings_by_key(payload, "b64_json", &mut values);
values
}
fn puzzle_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> PuzzleGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
.filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str()))
.collect();
PuzzleGeneratedImages { task_id, images }
}
fn decode_puzzle_generated_image_base64(raw: &str) -> Option<PuzzleDownloadedImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_puzzle_image_mime_type(bytes.as_slice());
Some(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_puzzle_strings_by_key(payload, target_key, &mut results);
@@ -5265,6 +5509,22 @@ fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<String>) {
}
}
fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')

View File

@@ -675,6 +675,7 @@ 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,
@@ -1123,6 +1124,7 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
granted_user_tags: record.granted_user_tags,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),

View File

@@ -305,6 +305,8 @@ pub struct PuzzleLeaderboardEntry {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
#[serde(default)]
pub visible_tags: Vec<String>,
pub is_current_player: bool,
}

View File

@@ -715,6 +715,7 @@ pub fn build_runtime_profile_invite_code_record(
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
granted_user_tags: snapshot.granted_user_tags,
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
starts_at_micros: snapshot.starts_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),

View File

@@ -13,6 +13,9 @@ use crate::domain::*;
use crate::errors::*;
use crate::{format_utc_micros, runtime_profile_recharge_product_by_id};
pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8;
pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16;
// 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。
fn normalize_runtime_settings_user_id(
user_id: String,
@@ -425,6 +428,7 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
granted_user_tags: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
@@ -433,6 +437,7 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
let granted_user_tags = normalize_profile_user_tags(granted_user_tags)?;
crate::commands::validate_runtime_profile_invite_code_validity_window(
starts_at_micros,
expires_at_micros,
@@ -442,6 +447,7 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id,
invite_code,
metadata_json,
granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros,
@@ -770,6 +776,27 @@ pub fn normalize_invite_code_metadata_json(
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
pub fn normalize_profile_user_tags(
values: Vec<String>,
) -> Result<Vec<String>, RuntimeProfileFieldError> {
let mut tags = Vec::new();
for value in values {
let Some(tag) = normalize_optional_string(Some(value)) else {
continue;
};
if tag.chars().count() > PROFILE_USER_TAG_MAX_CHARS {
return Err(RuntimeProfileFieldError::InvalidUserTag);
}
if !tags.iter().any(|existing| existing == &tag) {
tags.push(tag);
}
if tags.len() > PROFILE_USER_TAG_MAX_COUNT {
return Err(RuntimeProfileFieldError::InvalidUserTag);
}
}
Ok(tags)
}
pub fn validate_runtime_profile_invite_code_validity_window(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,

View File

@@ -1121,6 +1121,7 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: i64,
@@ -1138,6 +1139,7 @@ pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub created_at_micros: i64,
@@ -1510,6 +1512,7 @@ pub struct RuntimeProfileInviteCodeRecord {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub granted_user_tags: Vec<String>,
pub starts_at: Option<String>,
pub starts_at_micros: Option<i64>,
pub expires_at: Option<String>,

View File

@@ -57,6 +57,7 @@ pub enum RuntimeProfileFieldError {
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
InvalidInviteCodeMetadata,
InvalidUserTag,
InvalidInviteCodeValidityWindow,
MissingTaskId,
MissingTaskTitle,
@@ -115,6 +116,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是合法 JSON object")
}
Self::InvalidUserTag => f.write_str("用户标签格式无效"),
Self::InvalidInviteCodeValidityWindow => f.write_str("邀请码开始时间不能晚于截止时间"),
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),

View File

@@ -146,6 +146,13 @@ pub fn runtime_profile_recharge_product_by_id(
.find(|product| product.product_id == product_id)
}
pub fn visible_runtime_profile_user_tags(tags: &[String]) -> Vec<String> {
tags.iter()
.filter(|tag| tag.as_str() == "北科")
.cloned()
.collect()
}
fn build_points_recharge_product(
product_id: &str,
title: &str,

View File

@@ -48,6 +48,7 @@ fn invite_code_record_formats_window_and_status() {
user_id: "user-1".to_string(),
invite_code: "SY00000001".to_string(),
metadata_json: "{}".to_string(),
granted_user_tags: Vec::new(),
starts_at_micros: Some(0),
expires_at_micros: Some(1_000_000),
created_at_micros: 0,

View File

@@ -20,12 +20,13 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 9] = [
pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
"generated-big-fish-assets",
"generated-square-hole-assets",
"generated-match3d-assets",
"generated-puzzle-assets",
"generated-custom-world-scenes",
"generated-custom-world-covers",
@@ -46,6 +47,7 @@ pub enum LegacyAssetPrefix {
Animations,
BigFishAssets,
SquareHoleAssets,
Match3DAssets,
PuzzleAssets,
CustomWorldScenes,
CustomWorldCovers,
@@ -232,6 +234,7 @@ impl LegacyAssetPrefix {
"generated-animations" => Some(Self::Animations),
"generated-big-fish-assets" => Some(Self::BigFishAssets),
"generated-square-hole-assets" => Some(Self::SquareHoleAssets),
"generated-match3d-assets" => Some(Self::Match3DAssets),
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
@@ -247,6 +250,7 @@ impl LegacyAssetPrefix {
Self::Animations => "generated-animations",
Self::BigFishAssets => "generated-big-fish-assets",
Self::SquareHoleAssets => "generated-square-hole-assets",
Self::Match3DAssets => "generated-match3d-assets",
Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
@@ -1305,7 +1309,12 @@ mod tests {
LegacyAssetPrefix::parse("/generated-puzzle-assets/*"),
Some(LegacyAssetPrefix::PuzzleAssets)
);
assert_eq!(
LegacyAssetPrefix::parse("/generated-match3d-assets/*"),
Some(LegacyAssetPrefix::Match3DAssets)
);
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets"));
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
}

View File

@@ -13,6 +13,12 @@ pub struct CreateMatch3DAgentSessionRequest {
pub clear_count: Option<u32>,
#[serde(default)]
pub difficulty: Option<u32>,
#[serde(default)]
pub asset_style_id: Option<String>,
#[serde(default)]
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -52,6 +58,12 @@ pub struct Match3DCreatorConfigResponse {
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub asset_style_id: Option<String>,
#[serde(default)]
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -73,6 +85,32 @@ pub struct Match3DResultDraftResponse {
pub total_item_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemAssetResponse {
pub item_id: String,
pub item_name: String,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default)]
pub model_src: Option<String>,
#[serde(default)]
pub model_object_key: Option<String>,
#[serde(default)]
pub model_file_name: Option<String>,
#[serde(default)]
pub task_uuid: Option<String>,
#[serde(default)]
pub subscription_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -147,6 +185,9 @@ mod tests {
reference_image_src: Some("data:image/png;base64,abc".to_string()),
clear_count: Some(4),
difficulty: Some(3),
asset_style_id: Some("clay-toy".to_string()),
asset_style_label: Some("黏土手作".to_string()),
asset_style_prompt: Some("圆润黏土手作风".to_string()),
})
.expect("payload should serialize");
@@ -157,5 +198,6 @@ mod tests {
json!("data:image/png;base64,abc")
);
assert_eq!(payload["clearCount"], json!(4));
assert_eq!(payload["assetStyleId"], json!("clay-toy"));
}
}

View File

@@ -40,6 +40,32 @@ pub struct Match3DWorkSummaryResponse {
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemAssetResponse {
pub item_id: String,
pub item_name: String,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default)]
pub model_src: Option<String>,
#[serde(default)]
pub model_object_key: Option<String>,
#[serde(default)]
pub model_file_name: Option<String>,
#[serde(default)]
pub task_uuid: Option<String>,
#[serde(default)]
pub subscription_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -85,6 +85,8 @@ pub struct PuzzleLeaderboardEntryResponse {
pub nickname: String,
pub elapsed_ms: u64,
#[serde(default)]
pub visible_tags: Vec<String>,
#[serde(default)]
pub is_current_player: bool,
}

View File

@@ -486,6 +486,8 @@ pub struct AdminUpsertProfileInviteCodeRequest {
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub granted_user_tags: Vec<String>,
#[serde(default)]
pub starts_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
@@ -524,6 +526,7 @@ pub struct ProfileInviteCodeAdminResponse {
pub user_id: String,
pub invite_code: String,
pub metadata: serde_json::Value,
pub granted_user_tags: Vec<String>,
pub starts_at: Option<String>,
pub expires_at: Option<String>,
pub status: String,

View File

@@ -320,6 +320,7 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
admin_user_id: input.admin_user_id,
invite_code: input.invite_code,
metadata_json: input.metadata_json,
granted_user_tags: input.granted_user_tags,
starts_at_micros: input.starts_at_micros,
expires_at_micros: input.expires_at_micros,
updated_at_micros: input.updated_at_micros,
@@ -2315,6 +2316,7 @@ pub(crate) fn map_runtime_profile_invite_code_snapshot(
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
granted_user_tags: snapshot.granted_user_tags,
starts_at_micros: snapshot.starts_at_micros,
expires_at_micros: snapshot.expires_at_micros,
created_at_micros: snapshot.created_at_micros,
@@ -2935,6 +2937,9 @@ fn map_match3d_creator_config(
reference_image_src: snapshot.reference_image_src,
clear_count: snapshot.clear_count,
difficulty: snapshot.difficulty,
asset_style_id: snapshot.asset_style_id,
asset_style_label: snapshot.asset_style_label,
asset_style_prompt: snapshot.asset_style_prompt,
}
}
@@ -2993,6 +2998,7 @@ fn map_match3d_work_snapshot(snapshot: Match3DWorkJsonRecord) -> Match3DWorkProf
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
publish_ready: snapshot.publish_ready,
generated_item_assets_json: snapshot.generated_item_assets_json,
}
}
@@ -3745,6 +3751,7 @@ pub(crate) fn map_puzzle_leaderboard_entry(
rank: snapshot.rank,
nickname: snapshot.nickname,
elapsed_ms: snapshot.elapsed_ms,
visible_tags: snapshot.visible_tags,
is_current_player: snapshot.is_current_player,
}
}
@@ -5997,6 +6004,7 @@ pub struct Match3DCompileDraftRecordInput {
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub compiled_at_micros: i64,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6075,6 +6083,9 @@ pub struct Match3DCreatorConfigRecord {
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
pub asset_style_id: Option<String>,
pub asset_style_label: Option<String>,
pub asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6138,6 +6149,7 @@ pub struct Match3DWorkProfileRecord {
pub updated_at: String,
pub published_at: Option<String>,
pub publish_ready: bool,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -6201,6 +6213,12 @@ struct Match3DCreatorConfigJsonRecord {
reference_image_src: Option<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
asset_style_id: Option<String>,
#[serde(default)]
asset_style_label: Option<String>,
#[serde(default)]
asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -6269,6 +6287,8 @@ struct Match3DWorkJsonRecord {
play_count: u32,
updated_at_micros: i64,
published_at_micros: Option<i64>,
#[serde(default)]
generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
@@ -7309,6 +7329,7 @@ pub struct PuzzleLeaderboardEntryRecord {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub visible_tags: Vec<String>,
pub is_current_player: bool,
}
@@ -7870,6 +7891,53 @@ mod tests {
assert_eq!(items[0].remix_count, 0);
assert_eq!(items[0].like_count, 0);
}
#[test]
fn match3d_work_mapper_keeps_generated_item_assets_json() {
let result = Match3DWorkProcedureResult {
ok: true,
work_json: Some(
r#"{
"profileId":"match3d-profile-1",
"ownerUserId":"user-1",
"sourceSessionId":"match3d-session-1",
"authorDisplayName":"测试作者",
"gameName":"水果抓大鹅",
"themeText":"水果",
"summaryText":"水果主题",
"tags":["水果"],
"coverImageSrc":"",
"coverAssetId":"",
"clearCount":3,
"difficulty":3,
"config":{
"themeText":"水果",
"referenceImageSrc":null,
"clearCount":3,
"difficulty":3
},
"publicationStatus":"Draft",
"publishReady":false,
"playCount":0,
"updatedAtMicros":123000000,
"publishedAtMicros":null,
"generatedItemAssetsJson":"[{\"itemId\":\"match3d-item-1\",\"itemName\":\"草莓\",\"imageSrc\":\"/generated-match3d-assets/session/profile/items/item/image.png\",\"status\":\"image_ready\"}]"
}"#
.to_string(),
),
error_message: None,
};
let item = map_match3d_work_procedure_result(result)
.expect("match3d work JSON 应保留生成素材 JSON");
assert_eq!(
item.generated_item_assets_json.as_deref(),
Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
)
);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -124,6 +124,7 @@ impl SpacetimeClient {
cover_image_src: input.cover_image_src,
cover_asset_id: input.cover_asset_id,
compiled_at_micros: input.compiled_at_micros,
generated_item_assets_json: input.generated_item_assets_json,
};
self.call_after_connect(move |connection, sender| {

View File

@@ -17,6 +17,7 @@ pub struct Match3DDraftCompileInput {
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub compiled_at_micros: i64,
pub generated_item_assets_json: Option<String>,
}
impl __sdk::InModule for Match3DDraftCompileInput {

View File

@@ -24,6 +24,7 @@ pub struct Match3DWorkProfileRow {
pub play_count: u32,
pub updated_at: __sdk::Timestamp,
pub published_at: Option<__sdk::Timestamp>,
pub generated_item_assets_json: Option<String>,
}
impl __sdk::InModule for Match3DWorkProfileRow {
@@ -51,6 +52,8 @@ pub struct Match3DWorkProfileRowCols {
pub play_count: __sdk::__query_builder::Col<Match3DWorkProfileRow, u32>,
pub updated_at: __sdk::__query_builder::Col<Match3DWorkProfileRow, __sdk::Timestamp>,
pub published_at: __sdk::__query_builder::Col<Match3DWorkProfileRow, Option<__sdk::Timestamp>>,
pub generated_item_assets_json:
__sdk::__query_builder::Col<Match3DWorkProfileRow, Option<String>>,
}
impl __sdk::__query_builder::HasCols for Match3DWorkProfileRow {
@@ -77,6 +80,10 @@ impl __sdk::__query_builder::HasCols for Match3DWorkProfileRow {
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
published_at: __sdk::__query_builder::Col::new(table_name, "published_at"),
generated_item_assets_json: __sdk::__query_builder::Col::new(
table_name,
"generated_item_assets_json",
),
}
}
}

View File

@@ -652,6 +652,7 @@ impl SpacetimeClient {
admin_user_id: String,
invite_code: String,
metadata_json: String,
granted_user_tags: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
@@ -660,6 +661,7 @@ impl SpacetimeClient {
admin_user_id,
invite_code,
metadata_json,
granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros,

View File

@@ -57,6 +57,8 @@ pub(super) struct AuthUserSnapshot {
pub(super) binding_status: String,
pub(super) wechat_bound: bool,
pub(super) token_version: u64,
#[serde(default)]
pub(super) user_tags: Vec<String>,
}
#[derive(Deserialize, Serialize)]

View File

@@ -210,6 +210,8 @@ fn import_auth_store_snapshot_tx(
password_hash: stored_user.password_hash,
password_login_enabled: stored_user.password_login_enabled,
token_version: user.token_version,
user_tags: module_runtime::normalize_profile_user_tags(user.user_tags)
.map_err(|error| error.to_string())?,
});
imported_user_count += 1;
@@ -339,6 +341,7 @@ fn export_auth_store_snapshot_from_tables_tx(
binding_status: user.binding_status,
wechat_bound: user.wechat_bound,
token_version: user.token_version,
user_tags: user.user_tags,
};
users_by_username.insert(
user.username,

View File

@@ -28,6 +28,8 @@ pub struct UserAccount {
pub(crate) password_hash: String,
pub(crate) password_login_enabled: bool,
pub(crate) token_version: u64,
#[default(Vec::<String>::new())]
pub(crate) user_tags: Vec<String>,
}
#[spacetimedb::table(

View File

@@ -19,6 +19,8 @@ use module_match3d::{
use serde::Serialize;
use serde::de::DeserializeOwned;
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
ctx: &mut ProcedureContext,
@@ -439,7 +441,7 @@ fn compile_match3d_draft_tx(
) -> Result<Match3DAgentSessionSnapshot, String> {
require_non_empty(&input.profile_id, "match3d profile_id")?;
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
let config = parse_config(&session.config_json)?;
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
validate_config(&config)?;
let tags = input
.tags_json
@@ -480,6 +482,9 @@ fn compile_match3d_draft_tx(
play_count: 0,
updated_at: compiled_at,
published_at: None,
generated_item_assets_json: normalize_generated_item_assets_json(
input.generated_item_assets_json.as_deref(),
)?,
};
upsert_work(ctx, work);
replace_session(
@@ -514,9 +519,12 @@ fn update_match3d_work_tx(
let tags = parse_tags(&input.tags_json)?;
let config = Match3DCreatorConfigSnapshot {
theme_text: clean_string(&input.theme_text, "经典消除"),
reference_image_src: parse_config_or_default(&current.config_json).reference_image_src,
..parse_config_or_default(&current.config_json)
};
let config = Match3DCreatorConfigSnapshot {
clear_count: input.clear_count,
difficulty: input.difficulty,
..config
};
validate_config(&config)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
@@ -538,6 +546,7 @@ fn update_match3d_work_tx(
play_count: current.play_count,
updated_at,
published_at: current.published_at,
generated_item_assets_json: current.generated_item_assets_json.clone(),
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
@@ -944,6 +953,9 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapsho
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
generated_item_assets_json: normalize_generated_item_assets_json(
row.generated_item_assets_json.as_deref(),
)?,
})
}
@@ -1157,6 +1169,7 @@ fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow {
play_count: row.play_count,
updated_at: row.updated_at,
published_at: row.published_at,
generated_item_assets_json: row.generated_item_assets_json.clone(),
}
}
@@ -1189,6 +1202,9 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
reference_image_src: None,
clear_count: 12,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}
}
@@ -1202,15 +1218,43 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
config.difficulty = config
.difficulty
.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
config.asset_style_id = normalize_optional_text(config.asset_style_id);
config.asset_style_label = normalize_optional_text(config.asset_style_label);
config.asset_style_prompt = normalize_optional_text(config.asset_style_prompt);
config
})
}
fn normalize_match3d_generated_item_config(
mut config: Match3DCreatorConfigSnapshot,
) -> Match3DCreatorConfigSnapshot {
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
config
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
let parsed = parse_json::<Vec<String>>(value, "match3d tags_json")?;
Ok(normalize_tags(parsed))
}
fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<String>, String> {
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let parsed = parse_json::<serde_json::Value>(trimmed, "match3d generated_item_assets_json")?;
if !parsed.is_array() {
return Err("match3d generated_item_assets_json 必须是数组".to_string());
}
Ok(Some(to_json_string(&parsed)))
}
fn default_tags(theme_text: &str) -> Vec<String> {
normalize_tags(vec![
theme_text.to_string(),
@@ -1557,17 +1601,81 @@ mod tests {
reference_image_src: None,
clear_count: 4,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: None,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
assert_eq!(snapshot.total_item_count, 12);
assert_eq!(snapshot.items.len(), 12);
}
#[test]
fn match3d_work_snapshot_keeps_generated_item_assets_json() {
let work = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags_json: "[\"水果\"]".to_string(),
cover_image_src: "/cover.png".to_string(),
cover_asset_id: String::new(),
clear_count: 3,
difficulty: 3,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 3,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
};
let snapshot = build_work_snapshot(&work).expect("work snapshot should build");
assert_eq!(
snapshot.generated_item_assets_json.as_deref(),
Some(
r#"[{"imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#
)
);
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 20,
difficulty: 8,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
});
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
assert_eq!(config.difficulty, 8);
}
#[test]
fn match3d_domain_click_bridge_clears_three_items() {
let snapshot = Match3DRunSnapshot {

View File

@@ -58,6 +58,8 @@ pub struct Match3DWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) generated_item_assets_json: Option<String>,
}
#[spacetimedb::table(

View File

@@ -89,6 +89,7 @@ pub struct Match3DDraftCompileInput {
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub compiled_at_micros: i64,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -223,6 +224,12 @@ pub struct Match3DCreatorConfigSnapshot {
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub asset_style_id: Option<String>,
#[serde(default)]
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -287,6 +294,7 @@ pub struct Match3DWorkSnapshot {
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

@@ -1134,6 +1134,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("avatar_url".to_string())
.or_insert(serde_json::Value::Null);
// 中文注释:账号标签字段晚于认证表加入,旧迁移包默认无标签。
object
.entry("user_tags".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
}
}
if table_name == "profile_invite_code" {
@@ -1142,6 +1146,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("metadata_json".to_string())
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
// 中文注释:邀请码授予标签字段晚于邀请表加入,旧迁移包默认不授予标签。
object
.entry("granted_user_tags".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
}
}
if table_name == "big_fish_creation_session" {
@@ -1214,6 +1222,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(fallback_description);
}
}
if table_name == "match3d_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:抓大鹅生成素材字段晚于基础作品表加入,旧迁移包按未生成素材兼容。
object
.entry("generated_item_assets_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
next_value
}

View File

@@ -3218,6 +3218,13 @@ fn list_puzzle_leaderboard_entries(
.take(limit)
.enumerate()
.map(|(index, row)| PuzzleLeaderboardEntry {
visible_tags: ctx
.db
.user_account()
.user_id()
.find(&row.user_id)
.map(|account| visible_runtime_profile_user_tags(&account.user_tags))
.unwrap_or_default(),
rank: index as u32 + 1,
nickname: row.nickname,
elapsed_ms: row.best_elapsed_ms,
@@ -3483,12 +3490,14 @@ mod tests {
rank: 0,
nickname: "玩家 B".to_string(),
elapsed_ms: 5200,
visible_tags: Vec::new(),
is_current_player: false,
},
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 A".to_string(),
elapsed_ms: 3100,
visible_tags: Vec::new(),
is_current_player: true,
},
];

View File

@@ -191,6 +191,8 @@ pub struct ProfileInviteCode {
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
#[default(Vec::<String>::new())]
pub(crate) granted_user_tags: Vec<String>,
}
#[spacetimedb::table(
@@ -2031,6 +2033,16 @@ fn redeem_profile_referral_invite_code_record(
let invitee_user_id = validated_input.user_id;
let invite_code = validated_input.invite_code;
if ctx
.db
.user_account()
.user_id()
.find(&invitee_user_id)
.is_none()
{
return Err("用户不存在".to_string());
}
if ctx
.db
.profile_referral_relation()
@@ -2051,6 +2063,7 @@ fn redeem_profile_referral_invite_code_record(
if inviter_code.user_id == invitee_user_id {
return Err("不能填写自己的邀请码".to_string());
}
let granted_user_tags = inviter_code.granted_user_tags.clone();
let invitee_balance_after = apply_profile_wallet_delta(
ctx,
@@ -2097,6 +2110,7 @@ fn redeem_profile_referral_invite_code_record(
invitee_reward_granted: true,
bound_at,
});
merge_user_account_tags(ctx, &invitee_user_id, granted_user_tags)?;
Ok(RuntimeReferralRedeemSnapshot {
center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id),
@@ -2270,6 +2284,7 @@ fn admin_upsert_profile_invite_code_record(
input.admin_user_id,
input.invite_code,
input.metadata_json,
input.granted_user_tags,
input.starts_at_micros,
input.expires_at_micros,
input.updated_at_micros,
@@ -2306,6 +2321,7 @@ fn admin_upsert_profile_invite_code_record(
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
granted_user_tags: validated_input.granted_user_tags,
});
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
@@ -2322,6 +2338,7 @@ fn admin_upsert_profile_invite_code_record(
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
granted_user_tags: validated_input.granted_user_tags,
});
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
@@ -2448,9 +2465,33 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
updated_at: ctx.timestamp,
starts_at: None,
expires_at: None,
granted_user_tags: Vec::new(),
})
}
fn merge_user_account_tags(
ctx: &ReducerContext,
user_id: &str,
granted_tags: Vec<String>,
) -> Result<(), String> {
let granted_tags =
normalize_profile_user_tags(granted_tags).map_err(|error| error.to_string())?;
if granted_tags.is_empty() {
return Ok(());
}
let Some(mut account) = ctx.db.user_account().user_id().find(&user_id.to_string()) else {
return Err("用户不存在".to_string());
};
account.user_tags.extend(granted_tags);
account.user_tags =
normalize_profile_user_tags(account.user_tags).map_err(|error| error.to_string())?;
ctx.db.user_account().user_id().delete(&account.user_id);
ctx.db.user_account().insert(account);
Ok(())
}
fn validate_profile_invite_code_redeem_time(
invite_code: &ProfileInviteCode,
now_micros: i64,
@@ -3475,6 +3516,7 @@ fn build_profile_invite_code_snapshot_from_row(
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
granted_user_tags: row.granted_user_tags.clone(),
starts_at_micros: row
.starts_at
.map(|value| value.to_micros_since_unix_epoch()),