合并最新 master 分支
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 58s
Project CI / Native shell tests (pull_request) Successful in 2m32s

带入 SpacetimeDB 2.7.0(hotfix3)工具链升级、后台灰度发布控制面恢复、
精选顺序分列瀑布流及后台素材预览统一。冲突仅三处文档:decision-log 与
pitfalls 双方条目并存,运维文档更新时间取新值。BgFilter 分支代码与部署
脚本零冲突;依赖升级后 bgfilter 46 测试全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 07:02:17 +00:00
187 changed files with 11561 additions and 883 deletions
+163 -13
View File
@@ -21,7 +21,7 @@ use reqwest::Client;
use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
ADMIN_TAB_PERMISSIONS, AdminAccountRole,
ADMIN_TAB_PERMISSIONS, AdminAccountRole, AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload,
AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload,
@@ -50,7 +50,8 @@ use shared_contracts::admin::{
AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse,
};
use shared_contracts::assets::{
CreateDirectUploadTicketRequest, DirectUploadObjectAccess, GetReadUrlQuery,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest,
DirectUploadObjectAccess, GetReadUrlQuery,
};
#[cfg(any())]
use shared_contracts::creation_entry_config::{
@@ -71,8 +72,8 @@ use crate::{
admin_accounts::normalize_admin_account_username,
api_response::json_success_body,
assets::{
AssetReadAuthorization, create_direct_upload_ticket_for_owner,
get_asset_read_url_with_query,
AssetReadAuthorization, confirm_asset_object_for_owner,
create_direct_upload_ticket_for_owner, get_asset_read_url_with_query,
},
editor_generation_config::EditorGenerationPricingConfig,
editor_project::current_utc_micros,
@@ -113,6 +114,9 @@ const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366;
const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80;
const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200;
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024;
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX: &str =
"generated-character-drafts/editor/showcase-campaign/";
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND: &str = "editor_showcase_campaign_image";
#[derive(Clone, Debug)]
pub struct AuthenticatedAdmin {
@@ -903,6 +907,69 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
))
}
pub async fn admin_confirm_editor_showcase_campaign_image_upload(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminConfirmEditorShowcaseCampaignImageUploadRequest>,
) -> Result<Json<Value>, AppError> {
let confirm_request = build_admin_editor_showcase_campaign_image_confirm_request(payload)?;
confirm_asset_object_for_owner(
&state,
&request_context,
admin.session().subject.as_str(),
confirm_request,
)
.await
}
fn build_admin_editor_showcase_campaign_image_confirm_request(
payload: AdminConfirmEditorShowcaseCampaignImageUploadRequest,
) -> Result<ConfirmAssetObjectRequest, AppError> {
let bucket = payload.bucket.trim();
if bucket.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("活动卡图片 bucket 不能为空"));
}
let object_key = payload.object_key.trim().trim_start_matches('/');
if !object_key.starts_with(ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX)
|| object_key.len() == ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX.len()
{
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("活动卡图片 Object Key 不属于后台活动卡目录"));
}
let content_type = payload.content_type.trim();
if !content_type.starts_with("image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能确认图片对象")
);
}
if payload.content_length == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能为空")
);
}
if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB")
);
}
Ok(ConfirmAssetObjectRequest {
bucket: Some(bucket.to_string()),
object_key: object_key.to_string(),
content_type: Some(content_type.to_string()),
content_length: Some(payload.content_length),
content_hash: None,
asset_kind: ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND.to_string(),
access_policy: Some(ConfirmAssetObjectAccessPolicy::Private),
source_job_id: None,
owner_user_id: None,
profile_id: None,
entity_id: None,
})
}
#[cfg(any())]
fn build_admin_creation_entry_config_response(
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
@@ -989,6 +1056,7 @@ fn admin_editor_showcase_asset_payload_from_record(
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record.generation_inputs,
thumbnail_src: record.thumbnail_src,
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: record.refund_mud_points,
review_status: record.review_status,
@@ -3940,9 +4008,9 @@ mod tests {
admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_table_row, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, enforce_admin_request_permission,
finalize_admin_database_table_rows_response, group_admin_editor_asset_records,
hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name,
@@ -3961,9 +4029,10 @@ mod tests {
};
use serde_json::json;
use shared_contracts::admin::{
AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
};
use shared_contracts::assets::GetReadUrlQuery;
use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery};
use spacetime_client::{
AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord,
AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord,
@@ -4070,6 +4139,83 @@ mod tests {
assert!(draft.metadata.get("signedUrl").is_none());
}
#[test]
fn campaign_image_confirm_is_scoped_to_private_activity_card_objects() {
let request = build_admin_editor_showcase_campaign_image_confirm_request(
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: " genarrative-release ".to_string(),
object_key:
" /generated-character-drafts/editor/showcase-campaign/current/card.png "
.to_string(),
content_type: " image/png ".to_string(),
content_length: 1024,
},
)
.expect("valid activity card upload should build a confirm request");
assert_eq!(request.bucket.as_deref(), Some("genarrative-release"));
assert_eq!(
request.object_key,
"generated-character-drafts/editor/showcase-campaign/current/card.png"
);
assert_eq!(request.content_type.as_deref(), Some("image/png"));
assert_eq!(request.content_length, Some(1024));
assert_eq!(request.asset_kind, "editor_showcase_campaign_image");
assert_eq!(
request.access_policy,
Some(ConfirmAssetObjectAccessPolicy::Private)
);
assert!(request.owner_user_id.is_none());
}
#[test]
fn campaign_image_confirm_rejects_objects_outside_activity_card_directory() {
let error = build_admin_editor_showcase_campaign_image_confirm_request(
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/asset-library/private.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: 1024,
},
)
.expect_err("unrelated editor objects must not use the activity card confirm route");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn campaign_image_confirm_rejects_invalid_media_and_size() {
for payload in [
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/showcase-campaign/current/card.txt"
.to_string(),
content_type: "text/plain".to_string(),
content_length: 1024,
},
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/showcase-campaign/current/empty.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: 0,
},
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key:
"generated-character-drafts/editor/showcase-campaign/current/oversized.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: super::ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES + 1,
},
] {
let error = build_admin_editor_showcase_campaign_image_confirm_request(payload)
.expect_err("invalid activity card uploads must fail before OSS HEAD");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
}
#[test]
fn admin_editor_showcase_asset_payload_resolves_author_profile() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4078,10 +4224,10 @@ mod tests {
.ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042")
.expect("author should be inserted");
let payload = admin_editor_showcase_asset_payload_from_record(
&state,
test_admin_editor_showcase_asset_record("user-author-1"),
);
let mut record = test_admin_editor_showcase_asset_record("user-author-1");
record.thumbnail_src =
Some("/generated-character-drafts/editor/spec-thumb.png".to_string());
let payload = admin_editor_showcase_asset_payload_from_record(&state, record);
assert_eq!(payload.owner_user_id, "user-author-1");
assert_eq!(payload.author_display_name.as_deref(), Some("作者昵称"));
@@ -4089,6 +4235,10 @@ mod tests {
payload.author_public_user_code.as_deref(),
Some("SY-00000042")
);
assert_eq!(
payload.thumbnail_src.as_deref(),
Some("/generated-character-drafts/editor/spec-thumb.png")
);
}
#[test]
+55 -1
View File
@@ -686,7 +686,23 @@ async fn authorize_asset_read_target(
})
.await
.map_err(map_asset_read_authorization_error)?;
if let Some(asset_object) = asset_object.as_ref() {
resolve_asset_read_access(
configured_bucket,
target,
authorization,
asset_object.as_ref(),
public_work_granted,
)
}
fn resolve_asset_read_access(
configured_bucket: &str,
target: &AssetReadTarget,
authorization: &AssetReadAuthorization,
asset_object: Option<&module_assets::AssetObjectRecord>,
public_work_granted: bool,
) -> Result<AssetReadAccessScope, AppError> {
if let Some(asset_object) = asset_object {
return require_asset_object_read_access(
asset_object,
configured_bucket,
@@ -696,6 +712,12 @@ async fn authorize_asset_read_target(
);
}
// 当前启用的活动卡可能来自历史上传,尚无 asset_object metadata;
// SpacetimeDB 只会对配置中的 exact object key 派生该授权,禁用或换图后立即失效。
if public_work_granted {
return Ok(AssetReadAccessScope::Public);
}
// 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。
if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) {
return Ok(AssetReadAccessScope::Public);
@@ -1120,6 +1142,38 @@ mod tests {
);
}
#[test]
fn current_campaign_grant_allows_exact_object_key_without_legacy_metadata() {
let target = super::AssetReadTarget {
object_key: "generated-character-drafts/editor/showcase-campaign/current/image.png"
.to_string(),
is_legacy_public_path: false,
};
assert!(matches!(
super::resolve_asset_read_access(
"genarrative-assets",
&target,
&super::AssetReadAuthorization::Anonymous,
None,
true,
),
Ok(super::AssetReadAccessScope::Public)
));
assert_eq!(
super::resolve_asset_read_access(
"genarrative-assets",
&target,
&super::AssetReadAuthorization::Anonymous,
None,
false,
)
.expect_err("unregistered object key without an exact grant should stay hidden")
.status_code(),
StatusCode::NOT_FOUND
);
}
#[test]
fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() {
assert_eq!(
@@ -5,6 +5,7 @@ use axum::{
use crate::{
admin::{
admin_confirm_editor_showcase_campaign_image_upload,
admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard,
admin_debug_http, admin_get_asset_read_url, admin_get_editor_generation_pricing,
admin_get_editor_showcase_campaign, admin_get_feature_gate_config,
@@ -174,6 +175,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/editor-showcase/campaign/image-upload-confirm",
post(admin_confirm_editor_showcase_campaign_image_upload).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)