调整精选后端公开契约

新增精选活动卡图片 objectKey 存储字段和迁移兼容。

新增后台活动卡图片 OSS 直传凭证接口。

移除素材包精选分类约束,允许未设置分类素材进入全部精选。

同步 admin 和前台读取活动卡 objectKey 的类型契约。
This commit is contained in:
2026-07-06 18:56:49 +08:00
parent b8e6e9f3ef
commit b5aa68fc6a
12 changed files with 234 additions and 15 deletions
+27
View File
@@ -457,6 +457,7 @@ export interface AdminEditorShowcaseCampaignPayload {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
prompt: string;
author: string;
costText: string;
@@ -471,11 +472,37 @@ export interface AdminUpsertEditorShowcaseCampaignRequest {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
prompt: string;
author: string;
costText: string;
}
export interface AdminDirectUploadTicketPayload {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
contentType?: string | null;
formFields: Record<string, string | null | undefined>;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest {
fileName: string;
contentType: string;
contentLength: number;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: AdminDirectUploadTicketPayload;
}
export interface AdminUploadedEditorShowcaseCampaignImage {
imageSrc: string;
imageObjectKey: string;
legacyPublicPath: string;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
+124
View File
@@ -18,6 +18,9 @@ use reqwest::Client;
use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
AdminDirectUploadTicketPayload,
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload,
AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload,
@@ -38,6 +41,9 @@ use shared_contracts::admin::{
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
};
use shared_contracts::assets::{
CreateDirectUploadTicketRequest, DirectUploadObjectAccess,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
};
@@ -52,6 +58,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{
api_response::json_success_body,
assets::create_direct_upload_ticket_for_owner,
editor_generation_config::EditorGenerationPricingConfig,
editor_project::current_utc_micros,
http_error::AppError,
@@ -83,6 +90,7 @@ 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_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000;
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct AuthenticatedAdmin {
@@ -578,6 +586,7 @@ pub async fn admin_upsert_editor_showcase_campaign(
enabled: payload.enabled,
title: payload.title,
image_src: payload.image_src,
image_object_key: payload.image_object_key,
prompt: payload.prompt,
author: payload.author,
cost_text: payload.cost_text,
@@ -592,6 +601,79 @@ pub async fn admin_upsert_editor_showcase_campaign(
))
}
pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminCreateEditorShowcaseCampaignImageUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
let file_name = payload.file_name.trim();
if file_name.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件名不能为空")
);
}
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")
);
}
let ticket = create_direct_upload_ticket_for_owner(
&state,
&request_context,
admin.session().subject.as_str(),
CreateDirectUploadTicketRequest {
legacy_prefix: "generated-character-drafts".to_string(),
path_segments: vec![
"editor".to_string(),
"showcase-campaign".to_string(),
current_utc_micros().to_string(),
],
file_name: file_name.to_string(),
content_type: Some(content_type.to_string()),
access: Some(DirectUploadObjectAccess::Private),
metadata: BTreeMap::from([
(
"asset_kind".to_string(),
"editor_showcase_campaign_image".to_string(),
),
("source".to_string(), "admin_editor_showcase_campaign".to_string()),
]),
max_size_bytes: Some(payload.content_length),
expire_seconds: None,
success_action_status: Some(200),
},
)
.await?;
let axum::Json(value) = ticket;
let payload_value = value.get("data").cloned().unwrap_or(value);
let response: shared_contracts::assets::CreateDirectUploadTicketResponse =
serde_json::from_value(payload_value).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(serde_json::json!({
"message": format!("活动卡上传凭证响应解析失败:{error}"),
}))
})?;
Ok(json_success_body(
Some(&request_context),
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: admin_direct_upload_ticket_payload_from_asset_upload(response.upload),
},
))
}
fn build_admin_creation_entry_config_response(
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
) -> AdminCreationEntryConfigResponse {
@@ -716,6 +798,7 @@ fn admin_editor_showcase_campaign_payload_from_record(
enabled: record.enabled,
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
@@ -723,6 +806,47 @@ fn admin_editor_showcase_campaign_payload_from_record(
}
}
fn admin_direct_upload_ticket_payload_from_asset_upload(
upload: shared_contracts::assets::DirectUploadTicketPayload,
) -> AdminDirectUploadTicketPayload {
let mut form_fields = BTreeMap::from([
("key".to_string(), upload.form_fields.key),
("policy".to_string(), upload.form_fields.policy),
(
"x-oss-signature-version".to_string(),
upload.form_fields.signature_version,
),
(
"x-oss-credential".to_string(),
upload.form_fields.credential,
),
("x-oss-date".to_string(), upload.form_fields.date),
(
"x-oss-signature".to_string(),
upload.form_fields.signature,
),
(
"success_action_status".to_string(),
upload.form_fields.success_action_status,
),
]);
if let Some(content_type) = upload.form_fields.content_type {
form_fields.insert("Content-Type".to_string(), content_type);
}
if let Some(cache_control) = upload.form_fields.cache_control {
form_fields.insert("Cache-Control".to_string(), cache_control);
}
form_fields.extend(upload.form_fields.metadata);
AdminDirectUploadTicketPayload {
bucket: upload.bucket,
host: upload.host,
object_key: upload.object_key,
legacy_public_path: upload.legacy_public_path,
form_fields,
}
}
fn editor_showcase_admin_cursor(record: &EditorShowcaseAssetRecord) -> Option<String> {
let submitted_at_micros = parse_timestamp_text_to_micros(record.submitted_at.as_str())?;
Some(format!("{submitted_at_micros}:{}", record.showcase_id))
@@ -617,6 +617,7 @@ pub struct EditorShowcaseCampaignPayload {
enabled: bool,
title: String,
image_src: String,
image_object_key: Option<String>,
prompt: String,
author: String,
cost_text: String,
@@ -3350,6 +3351,7 @@ fn editor_showcase_campaign_payload_from_record(
enabled: record.enabled,
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
@@ -6,6 +6,7 @@ use axum::{
use crate::{
admin::{
admin_dashboard, admin_debug_http, admin_get_creation_entry_config,
admin_create_editor_showcase_campaign_image_upload_ticket,
admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign,
admin_list_database_table_rows, admin_list_database_tables,
admin_list_editor_assets, admin_list_editor_showcase_assets, admin_list_tracking_event_keys,
@@ -152,6 +153,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/editor-showcase/campaign/image-upload-ticket",
post(admin_create_editor_showcase_campaign_image_upload_ticket).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -254,6 +256,7 @@ pub struct AdminEditorShowcaseCampaignPayload {
pub enabled: bool,
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub prompt: String,
pub author: String,
pub cost_text: String,
@@ -272,11 +275,36 @@ pub struct AdminUpsertEditorShowcaseCampaignRequest {
pub enabled: bool,
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub prompt: String,
pub author: String,
pub cost_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketRequest {
pub file_name: String,
pub content_type: String,
pub content_length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
pub upload: AdminDirectUploadTicketPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDirectUploadTicketPayload {
pub bucket: String,
pub host: String,
pub object_key: String,
pub legacy_public_path: String,
pub form_fields: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginResponse {
@@ -180,6 +180,7 @@ pub struct EditorShowcaseCampaignConfigRecord {
pub enabled: bool,
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub prompt: String,
pub author: String,
pub cost_text: String,
@@ -426,6 +427,7 @@ pub struct EditorShowcaseCampaignConfigUpsertRecordInput {
pub author: String,
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
}
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
@@ -783,6 +785,7 @@ impl From<EditorShowcaseCampaignConfigUpsertRecordInput>
author: input.author,
cost_text: input.cost_text,
updated_at_micros: input.updated_at_micros,
image_object_key: input.image_object_key,
}
}
}
@@ -1214,6 +1217,7 @@ fn map_editor_showcase_campaign_config_snapshot(
enabled: snapshot.enabled,
title: snapshot.title,
image_src: snapshot.image_src,
image_object_key: snapshot.image_object_key,
prompt: snapshot.prompt,
author: snapshot.author,
cost_text: snapshot.cost_text,
@@ -15,6 +15,7 @@ pub struct EditorShowcaseCampaignConfigSnapshot {
pub cost_text: String,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfigSnapshot {
@@ -16,6 +16,7 @@ pub struct EditorShowcaseCampaignConfig {
pub cost_text: String,
pub updated_by_admin_user_id: Option<String>,
pub updated_at: __sdk::Timestamp,
pub image_object_key: Option<String>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfig {
@@ -36,6 +37,7 @@ pub struct EditorShowcaseCampaignConfigCols {
pub updated_by_admin_user_id:
__sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<String>>,
pub updated_at: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, __sdk::Timestamp>,
pub image_object_key: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig {
@@ -54,6 +56,7 @@ impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig {
"updated_by_admin_user_id",
),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
image_object_key: __sdk::__query_builder::Col::new(table_name, "image_object_key"),
}
}
}
@@ -15,6 +15,7 @@ pub struct EditorShowcaseCampaignConfigUpsertInput {
pub author: String,
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfigUpsertInput {
@@ -13,7 +13,7 @@ const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending";
const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved";
const EDITOR_SHOWCASE_STATUS_REJECTED: &str = "rejected";
const EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID: &str = "global";
const EDITOR_SHOWCASE_CATEGORIES: [&str; 5] = ["packs", "characters", "ui", "music", "marketing"];
const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"];
#[spacetimedb::table(
accessor = editor_project,
@@ -205,6 +205,8 @@ pub struct EditorShowcaseCampaignConfig {
cost_text: String,
updated_by_admin_user_id: Option<String>,
updated_at: Timestamp,
#[default(None::<String>)]
image_object_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
@@ -463,6 +465,7 @@ pub struct EditorShowcaseCampaignConfigSnapshot {
pub cost_text: String,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -617,6 +620,7 @@ pub struct EditorShowcaseCampaignConfigUpsertInput {
pub author: String,
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
@@ -1931,10 +1935,6 @@ fn list_public_editor_showcase_assets(
&& asset.display_enabled
&& !asset.asset_deleted_while_pending
&& (asset.refund_mud_points == 0 || asset.refund_completed_at.is_some())
&& asset
.showcase_category
.as_deref()
.is_some_and(is_valid_showcase_category)
&& !asset.image_src.trim().is_empty()
&& is_after_public_showcase_cursor(
approved_at_micros,
@@ -2077,14 +2077,11 @@ fn update_editor_showcase_asset_display(
if asset.asset_deleted_while_pending {
return Err("素材已删除,不能公开展示".to_string());
}
let showcase_category = input
.showcase_category
.map(|value| normalize_showcase_category(value.as_str()))
.transpose()?
.or_else(|| asset.showcase_category.clone());
if input.display_enabled && showcase_category.is_none() {
return Err("公开展示前必须先设置精选分类".to_string());
}
let showcase_category = match input.showcase_category {
Some(value) if value.trim().is_empty() => None,
Some(value) => Some(normalize_showcase_category(value.as_str())?),
None => asset.showcase_category.clone(),
};
if input.display_enabled && asset.refund_mud_points > 0 && asset.refund_completed_at.is_none() {
return Err("精选返还完成前不能公开展示".to_string());
}
@@ -2203,7 +2200,9 @@ fn upsert_editor_showcase_campaign_config(
"editor_showcase_campaign_config.updated_by_admin_user_id",
)?;
let title = input.title.trim().to_string();
let image_src = input.image_src.trim().to_string();
let image_object_key = normalize_optional(input.image_object_key)
.map(|value| value.trim_start_matches('/').to_string());
let image_src = normalize_campaign_image_src(input.image_src.as_str(), image_object_key.as_deref());
if input.enabled {
if title.is_empty() {
return Err("editor_showcase_campaign_config.title 不能为空".to_string());
@@ -2238,6 +2237,7 @@ fn upsert_editor_showcase_campaign_config(
cost_text: input.cost_text.trim().to_string(),
updated_by_admin_user_id: Some(admin_user_id),
updated_at: now,
image_object_key,
});
ctx.db
.editor_showcase_campaign_config()
@@ -2614,7 +2614,7 @@ fn normalize_showcase_category(value: &str) -> Result<String, String> {
if is_valid_showcase_category(normalized) {
return Ok(normalized.to_string());
}
Err("精选分类只支持 packs、characters、ui、music 或 marketing".to_string())
Err("精选分类只支持 characters、ui、music 或 marketing".to_string())
}
fn is_valid_showcase_category(value: &str) -> bool {
@@ -2712,9 +2712,22 @@ fn showcase_campaign_config_snapshot_from_row(
cost_text: row.cost_text,
updated_by_admin_user_id: row.updated_by_admin_user_id,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
image_object_key: row.image_object_key,
}
}
fn normalize_campaign_image_src(raw_image_src: &str, image_object_key: Option<&str>) -> String {
let image_src = raw_image_src.trim();
if !image_src.is_empty() {
return image_src.to_string();
}
image_object_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("/{}", value.trim_start_matches('/')))
.unwrap_or_default()
}
fn timestamp_option_micros(value: Option<Timestamp>) -> Option<i64> {
value.map(|timestamp| timestamp.to_micros_since_unix_epoch())
}
@@ -1265,6 +1265,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_showcase_campaign_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:活动卡图片 Object Key 晚于活动卡表加入,旧迁移包按仅有图片地址兼容。
object
.entry("image_object_key".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
@@ -138,6 +138,7 @@ export type EditorShowcaseCampaignSnapshot = {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
prompt: string;
author: string;
costText: string;