合并 master 最新变更

同步远端 master 至游戏场景功能分支
保留场景自定义画风控件并接入 master 的自适应文本输入组件
解决图片画布基础生成表单的唯一合并冲突
This commit is contained in:
2026-08-05 11:02:58 +08:00
139 changed files with 37973 additions and 3016 deletions
@@ -543,18 +543,20 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
);
let is_insufficient_balance = matches!(
&error,
SpacetimeClientError::Procedure(message) if message.contains("泥点余额不足")
SpacetimeClientError::Procedure(message)
if message.contains("泥点余额不足") || message.contains("可消费泥点不足:")
);
let status = if is_insufficient_balance {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
};
let public_message = is_insufficient_balance.then(|| message.clone());
let public_message = is_insufficient_balance.then_some("泥点余额不足");
let public_detail_message = public_message.unwrap_or(message.as_str());
let app_error = AppError::from_status(status).with_details(json!({
"provider": "profile-wallet",
"message": message,
"message": public_detail_message,
}));
if let Some(public_message) = public_message {
@@ -740,20 +742,25 @@ mod tests {
#[test]
fn asset_operation_wallet_insufficient_balance_is_public_message() {
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
"泥点余额不足".to_string(),
));
for domain_message in [
"泥点余额不足",
"可消费泥点不足:需要 10,扣除退款占用后可用 2",
] {
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
domain_message.to_string(),
));
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(error.code(), "CONFLICT");
assert_eq!(error.message(), "泥点余额不足");
assert_eq!(
error
.details()
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str),
Some("泥点余额不足"),
);
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(error.code(), "CONFLICT");
assert_eq!(error.message(), "泥点余额不足");
assert_eq!(
error
.details()
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str),
Some("泥点余额不足"),
);
}
}
#[test]
+178 -11
View File
@@ -358,6 +358,7 @@ pub struct EditorIconSpritesheetGenerationRequest {
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceRequest {
pub(crate) project_id: String,
pub(crate) source_layer_id: String,
pub(crate) source_resource_id: String,
pub(crate) asset_folder_id: Option<String>,
pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
@@ -2863,8 +2864,20 @@ fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
"targetLayerId": target_layer_id,
}))
})?;
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
return Ok(Some(asset_kind));
match target_layer.get("assetKindOverride") {
Some(Value::String(value)) if !value.trim().is_empty() => {
return Ok(Some(value.trim()));
}
Some(Value::Null) | None => {}
Some(_) => {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "快速编辑目标图层的素材类型覆盖无效",
"targetLayerId": target_layer_id,
})),
);
}
}
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
Ok(resource_id.and_then(|resource_id| {
@@ -5539,6 +5552,61 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
))
}
fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>(
layers: &'a Value,
source_layer_id: &str,
source_resource_id: &str,
resource_asset_kind: Option<&'a str>,
) -> Result<Option<&'a str>, AppError> {
let source_layer = layers
.as_array()
.and_then(|items| {
items
.iter()
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(source_layer_id))
})
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集图层。",
"sourceLayerId": source_layer_id,
}))
})?;
let layer_resource_id = source_layer
.get("resourceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层缺少有效的资源引用。",
"sourceLayerId": source_layer_id,
}))
})?;
if layer_resource_id != source_resource_id {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层与请求资源不一致。",
"sourceLayerId": source_layer_id,
"sourceResourceId": source_resource_id,
})),
);
}
match source_layer.get("assetKindOverride") {
Some(Value::String(value)) => Ok(Some(value.trim())),
Some(Value::Null) | None => Ok(resource_asset_kind),
Some(_) => Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层的素材类型覆盖无效。",
"sourceLayerId": source_layer_id,
})),
),
}
}
pub async fn split_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -5547,12 +5615,13 @@ pub async fn split_editor_icon_spritesheet(
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project_id = payload.project_id.trim().to_string();
let source_layer_id = payload.source_layer_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_resource_id.is_empty() {
if project_id.is_empty() || source_layer_id.is_empty() || source_resource_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "projectId 和 sourceResourceId 不能为空。",
"message": "projectId、sourceLayerId 和 sourceResourceId 不能为空。",
})),
);
}
@@ -5587,7 +5656,13 @@ pub async fn split_editor_icon_spritesheet(
})),
);
}
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
let effective_asset_kind = resolve_editor_icon_spritesheet_source_layer_asset_kind(
&project.layers,
source_layer_id.as_str(),
source_resource_id.as_str(),
source_resource.asset_kind.as_deref(),
)?;
if effective_asset_kind != Some("icon-spritesheet") {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
@@ -13485,20 +13560,50 @@ mod tests {
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
let layers = json!([
{ "layerId": "layer-icon", "assetKind": "icon" },
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
{
"layerId": "layer-icon",
"resourceId": "resource-default-spec",
"assetKind": "character",
"assetKindOverride": "icon"
},
{
"layerId": "layer-icon-spec",
"resourceId": "resource-icon-spec",
"assetKind": "icon"
}
]);
let resources = vec![
editor_project_resource_for_canvas_test("resource-default-spec", "spec", 512, 512),
editor_project_resource_for_canvas_test("resource-icon-spec", "icon-spec", 512, 512),
];
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
resolve_editor_image_edit_target_layer_asset_kind(&layers, &resources, "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
let icon_spec_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
.expect("icon spec layer should exist");
let icon_spec_kind = resolve_editor_image_edit_target_layer_asset_kind(
&layers,
&resources,
"layer-icon-spec",
)
.expect("icon spec layer should exist");
assert_eq!(icon_spec_kind, Some("icon-spec"));
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
);
let invalid_override = json!([{
"layerId": "layer-invalid",
"resourceId": "resource-icon-spec",
"assetKindOverride": ""
}]);
assert!(
resolve_editor_image_edit_target_layer_asset_kind(
&invalid_override,
&resources,
"layer-invalid",
)
.is_err()
);
}
#[test]
@@ -13668,6 +13773,7 @@ mod tests {
fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() {
let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({
"projectId": "project-1",
"sourceLayerId": "layer-atlas-1",
"sourceResourceId": "resource-atlas-1",
"assetFolderId": "project",
"canvasCompletion": {
@@ -13685,12 +13791,73 @@ mod tests {
.expect("spritesheet slice request should deserialize");
assert_eq!(request.project_id, "project-1");
assert_eq!(request.source_layer_id, "layer-atlas-1");
assert_eq!(request.source_resource_id, "resource-atlas-1");
assert_eq!(request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(request.canvas_completion.title, "拆分图集");
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
}
#[test]
fn editor_icon_spritesheet_slice_uses_authoritative_layer_override() {
let layers = json!([
{
"layerId": "layer-default",
"resourceId": "resource-atlas"
},
{
"layerId": "layer-override",
"resourceId": "resource-image",
"assetKindOverride": "icon-spritesheet"
},
{
"layerId": "layer-blocked",
"resourceId": "resource-atlas",
"assetKindOverride": "character"
}
]);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-default",
"resource-atlas",
Some("icon-spritesheet"),
)
.expect("resource default should be used"),
Some("icon-spritesheet")
);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-override",
"resource-image",
Some("character"),
)
.expect("layer override should win"),
Some("icon-spritesheet")
);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-blocked",
"resource-atlas",
Some("icon-spritesheet"),
)
.expect("blocking override should win"),
Some("character")
);
assert!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-default",
"resource-other",
Some("icon-spritesheet"),
)
.is_err()
);
}
#[test]
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
let mut copied_resource = editor_project_resource_for_canvas_test(
@@ -1276,8 +1276,12 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else {
continue;
};
resource
.retain(|key, _| matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId"));
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
if let Some(icon_image_srcs) = object
.get_mut("iconImageSrcs")
@@ -1289,7 +1293,10 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
};
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
resource.retain(|key, _| {
matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId")
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
icon.retain(|key, _| {
@@ -1417,6 +1424,7 @@ fn compact_external_generation_resource(resource: &mut serde_json::Map<String, V
| "sourceType"
| "assetKind"
| "taskId"
| "sourceResourceId"
)
});
remove_unstable_external_generation_media_fields(resource);
@@ -2088,6 +2096,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
},
"asset": { "assetId": "icon-asset-1" },
@@ -2101,6 +2110,7 @@ mod tests {
"resourceId": "icon-resource-2",
"objectKey": "users/user-1/map.png",
"assetObjectId": "icon-object-2",
"sourceResourceId": "sheet-resource",
},
},
],
@@ -2122,6 +2132,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
})
);
assert!(
@@ -2263,6 +2274,7 @@ mod tests {
"projectId": "project-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"sourceResourceId": "source-resource-main",
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
"width": 1024,
"height": 1024,
@@ -2318,6 +2330,10 @@ mod tests {
);
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
assert_eq!(
result["resource"]["sourceResourceId"],
json!("source-resource-main")
);
assert_eq!(
result["resource"]["objectKey"],
json!("users/user-1/generated/main.png")
@@ -31,6 +31,7 @@ pub struct EditorCanvasLayer {
pub item_json: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub asset_kind_override: Option<String>,
}
impl __sdk::InModule for EditorCanvasLayer {
@@ -65,6 +66,7 @@ pub struct EditorCanvasLayerCols {
pub item_json: __sdk::__query_builder::Col<EditorCanvasLayer, String>,
pub created_at: __sdk::__query_builder::Col<EditorCanvasLayer, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorCanvasLayer, __sdk::Timestamp>,
pub asset_kind_override: __sdk::__query_builder::Col<EditorCanvasLayer, Option<String>>,
}
impl __sdk::__query_builder::HasCols for EditorCanvasLayer {
@@ -95,6 +97,10 @@ impl __sdk::__query_builder::HasCols for EditorCanvasLayer {
item_json: __sdk::__query_builder::Col::new(table_name, "item_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
asset_kind_override: __sdk::__query_builder::Col::new(
table_name,
"asset_kind_override",
),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1390,6 +1390,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_canvas_layer" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:图层素材类型覆盖晚于结构化画布图层表加入,旧迁移包默认继承资源类型。
object
.entry("asset_kind_override".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_showcase_campaign_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:活动卡图片 Object Key 晚于活动卡表加入,旧迁移包按仅有图片地址兼容。
@@ -1732,6 +1740,16 @@ mod migration_bootstrap_secret_tests {
assert_eq!(normalized["background_color"], serde_json::Value::Null);
}
#[test]
fn old_editor_canvas_layer_rows_default_to_no_asset_kind_override() {
let normalized = normalize_migration_row(
"editor_canvas_layer",
&serde_json::json!({ "layer_key": "layer-1" }),
);
assert_eq!(normalized["asset_kind_override"], serde_json::Value::Null);
}
#[test]
fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() {
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";