合并最新 master 分支
带入 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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user