合并最新master

同步精选图片读取与审核返还修复
This commit is contained in:
2026-07-15 10:52:39 +00:00
7 changed files with 180 additions and 48 deletions
@@ -225,6 +225,7 @@
- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false``showcase_category=null`,运营可按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 返回已通过、展示开启且媒体非空的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。
- 验证方式:运行 `npm run spacetime:generate``npm run check:spacetime-schema``cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。
- 后续修正:精选批准、确定性返还流水和返还完成标记必须由同一个 SpacetimeDB procedure 在单事务内落地,失败时不得先留下 `approved`;已公开精选私有对象通过同 owner 的精确 `assetObjectId` / `objectKey` 派生匿名读取授权,不把 `generated-*` 前缀整体公开。
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库
@@ -58,7 +58,7 @@ npm run check:server-rs-ddd
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-url``/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算 `public_work_asset_read_grant`;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的公开作品派生授权精确读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-url``/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算公开作品或公开精选派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner同 owner 的公开作品派生授权,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 精确授权读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品和公开精选只获得与正式公开快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
- 外部 OpenAPI`/api/external/v1/openapi.json``/api/external/v1/assets/direct-upload-tickets``/api/external/v1/assets/objects/confirm``/api/external/v1/assets/read-url``/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/frontend-config``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*``/api/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/api/runtime/frontend-config``api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
@@ -75,7 +75,7 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId``objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。
+3 -45
View File
@@ -55,9 +55,9 @@ use spacetime_client::{
AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, AdminEditorAssetListRecordInput,
AdminEditorAssetRecord, EditorShowcaseAssetAdminListRecordInput,
EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
EditorShowcaseCampaignConfigUpsertRecordInput, SpacetimeClientError,
EditorShowcaseAssetRecord, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput,
SpacetimeClientError,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::{OnceCell, Semaphore};
@@ -724,7 +724,6 @@ pub async fn admin_review_editor_showcase_asset(
})
.await
.map_err(map_admin_spacetime_error)?;
let record = refund_approved_editor_showcase_asset_if_needed(&state, record).await?;
Ok(json_success_body(
Some(&request_context),
AdminEditorShowcaseAssetResponse {
@@ -943,47 +942,6 @@ fn map_admin_feature_gate_config(
}
}
async fn refund_approved_editor_showcase_asset_if_needed(
state: &AppState,
record: EditorShowcaseAssetRecord,
) -> Result<EditorShowcaseAssetRecord, AppError> {
if record.review_status != "approved"
|| record.refund_mud_points == 0
|| record.refund_completed_at.is_some()
{
return Ok(record);
}
let Some(ledger_id) = record.refund_ledger_id.clone() else {
return Ok(record);
};
state
.spacetime_client()
.refund_profile_wallet_points_with_metadata(
record.owner_user_id.clone(),
record.refund_mud_points,
ledger_id.clone(),
current_utc_micros(),
serde_json::json!({
"source": "editor_showcase_approval",
"showcaseId": record.showcase_id,
"assetId": record.asset_id,
"generationCostMudPoints": record.generation_cost_mud_points,
})
.to_string(),
)
.await
.map_err(map_admin_spacetime_error)?;
state
.spacetime_client()
.mark_editor_showcase_asset_refunded(EditorShowcaseAssetRefundMarkRecordInput {
showcase_id: record.showcase_id,
refund_ledger_id: ledger_id,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_admin_spacetime_error)
}
fn admin_editor_showcase_asset_payload_from_record(
state: &AppState,
record: EditorShowcaseAssetRecord,
@@ -136,6 +136,10 @@ pub fn get_asset_read_access_by_location_and_return(
let record = find_asset_object_by_location(tx, &input)?;
let public_work_granted = record.as_ref().is_some_and(|asset_object| {
crate::public_asset_access::asset_object_has_public_work_read_grant(tx, asset_object)
|| crate::editor_project_storage::asset_object_has_public_showcase_read_grant(
tx,
asset_object,
)
});
Ok((record, public_work_granted))
}) {
@@ -1156,7 +1156,10 @@ pub fn admin_review_editor_showcase_asset_and_return(
ctx: &mut ProcedureContext,
input: EditorShowcaseAssetAdminReviewInput,
) -> EditorShowcaseAssetProcedureResult {
match ctx.try_with_tx(|tx| admin_review_editor_showcase_asset(tx, input.clone())) {
match ctx.try_with_tx(|tx| {
let asset = admin_review_editor_showcase_asset(tx, input.clone())?;
refund_approved_editor_showcase_asset(tx, asset, input.updated_at_micros)
}) {
Ok(asset) => editor_showcase_asset_ok(Some(asset)),
Err(message) => editor_showcase_asset_error(message),
}
@@ -2133,6 +2136,45 @@ fn list_public_editor_showcase_assets(
Ok(assets)
}
pub(crate) fn asset_object_has_public_showcase_read_grant(
ctx: &ReducerContext,
asset_object: &module_assets::AssetObjectUpsertSnapshot,
) -> bool {
let asset_object = module_assets::build_asset_object_record(asset_object.clone());
let Some(owner_user_id) = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
ctx.db
.editor_showcase_asset()
.by_editor_showcase_asset_owner_user_id()
.filter(owner_user_id)
.filter(is_public_showcase_asset)
.any(|asset| {
module_assets::asset_object_matches_public_read_grant(
&asset_object,
&module_assets::PublicAssetReadGrant {
owner_user_id: asset.owner_user_id,
asset_object_id: asset.asset_object_id,
object_key: asset.object_key,
},
)
})
}
fn is_public_showcase_asset(asset: &EditorShowcaseAsset) -> bool {
asset.review_status == EDITOR_SHOWCASE_STATUS_APPROVED
&& asset.display_enabled
&& !asset.asset_deleted_while_pending
&& (asset.refund_mud_points == 0 || asset.refund_completed_at.is_some())
&& !asset.image_src.trim().is_empty()
}
fn admin_list_editor_showcase_assets(
ctx: &ReducerContext,
input: EditorShowcaseAssetAdminListInput,
@@ -2238,6 +2280,48 @@ fn admin_review_editor_showcase_asset(
.ok_or_else(|| "精选审核记录更新失败".to_string())
}
fn refund_approved_editor_showcase_asset(
ctx: &ReducerContext,
asset: EditorShowcaseAssetSnapshot,
updated_at_micros: i64,
) -> Result<EditorShowcaseAssetSnapshot, String> {
if asset.review_status != EDITOR_SHOWCASE_STATUS_APPROVED
|| asset.refund_mud_points == 0
|| asset.refund_completed_at_micros.is_some()
{
return Ok(asset);
}
let refund_ledger_id = asset
.refund_ledger_id
.clone()
.ok_or_else(|| "精选返还流水缺失".to_string())?;
let refunded_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
let metadata_json = serde_json::json!({
"source": "editor_showcase_approval",
"showcaseId": asset.showcase_id.as_str(),
"assetId": asset.asset_id.as_str(),
"generationCostMudPoints": asset.generation_cost_mud_points,
})
.to_string();
crate::runtime::grant_profile_wallet_points_with_metadata(
ctx,
asset.owner_user_id.as_str(),
asset.refund_mud_points,
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
refund_ledger_id.as_str(),
refunded_at,
metadata_json.as_str(),
)?;
mark_editor_showcase_asset_refunded(
ctx,
EditorShowcaseAssetRefundMarkInput {
showcase_id: asset.showcase_id,
refund_ledger_id,
updated_at_micros,
},
)
}
fn update_editor_showcase_asset_display(
ctx: &ReducerContext,
input: EditorShowcaseAssetDisplayUpdateInput,
@@ -4276,6 +4360,47 @@ mod tests {
use super::*;
use serde_json::json;
fn public_showcase_asset() -> EditorShowcaseAsset {
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
EditorShowcaseAsset {
showcase_id: "editor-showcase:asset-1".to_string(),
asset_id: "asset-1".to_string(),
owner_user_id: "user-owner".to_string(),
label: "精选素材".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated-characters/editor/asset-1.png".to_string(),
object_key: Some("generated-characters/editor/asset-1.png".to_string()),
width: 1024,
height: 1024,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some("character".to_string()),
generation_inputs_json: None,
thumbnail_src: None,
generation_cost_mud_points: 5,
refund_mud_points: 3,
review_status: EDITOR_SHOWCASE_STATUS_APPROVED.to_string(),
display_enabled: true,
like_count: 0,
asset_deleted_while_pending: false,
reviewed_by_admin_user_id: Some("admin".to_string()),
review_note: None,
refund_ledger_id: Some("editor-showcase-refund:editor-showcase:asset-1".to_string()),
refund_completed_at: Some(now),
submitted_at: now,
reviewed_at: Some(now),
approved_at: Some(now),
rejected_at: None,
updated_at: now,
showcase_category: Some("characters".to_string()),
}
}
fn tiered_pricing(model: &str, unit: &str, tiers: &[&str]) -> EditorGenerationModelPricing {
EditorGenerationModelPricing {
model: model.to_string(),
@@ -4652,6 +4777,28 @@ mod tests {
));
}
#[test]
fn public_showcase_read_grant_requires_approved_displayed_refunded_media() {
assert!(is_public_showcase_asset(&public_showcase_asset()));
assert!(!is_public_showcase_asset(&EditorShowcaseAsset {
review_status: EDITOR_SHOWCASE_STATUS_PENDING.to_string(),
..public_showcase_asset()
}));
assert!(!is_public_showcase_asset(&EditorShowcaseAsset {
display_enabled: false,
..public_showcase_asset()
}));
assert!(!is_public_showcase_asset(&EditorShowcaseAsset {
refund_completed_at: None,
..public_showcase_asset()
}));
assert!(!is_public_showcase_asset(&EditorShowcaseAsset {
image_src: String::new(),
..public_showcase_asset()
}));
}
#[test]
fn editor_asset_task_group_uses_final_output_as_primary() {
let assets = vec![
@@ -8680,6 +8680,28 @@ pub(crate) fn grant_profile_wallet_points(
)
}
pub(crate) fn grant_profile_wallet_points_with_metadata(
ctx: &ReducerContext,
user_id: &str,
amount_delta: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
metadata_json: &str,
) -> Result<u64, String> {
apply_profile_wallet_signed_delta(
ctx,
user_id,
convert_runtime_profile_wallet_unsigned_delta(amount_delta)
.map_err(|error| error.to_string())?,
source_type,
ledger_id,
created_at,
true,
metadata_json,
)
}
fn apply_profile_wallet_adjustment(
ctx: &ReducerContext,
input: RuntimeProfileWalletAdjustmentInput,