合并 master 最新更新
Project CI / Frontend tests (push) Failing after 24s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Failing after 6m49s

合入 master 的 BgFilter、CI、运维与现役平台改造。

保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。

修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。

同步冲突文档、锁文件和开发脚本。
This commit is contained in:
AIGameCreator App
2026-07-27 18:28:26 +08:00
1304 changed files with 170414 additions and 36096 deletions
+94 -744
View File
File diff suppressed because it is too large Load Diff
+25 -44
View File
@@ -6,32 +6,35 @@ resolver = "2"
default-members = [
"crates/api-server",
]
exclude = [
"crates/module-bark-battle",
"crates/module-big-fish",
"crates/module-combat",
"crates/module-creative-agent",
"crates/module-custom-world",
"crates/module-inventory",
"crates/module-jump-hop",
"crates/module-match3d",
"crates/module-npc",
"crates/module-progression",
"crates/module-puzzle",
"crates/module-puzzle-clear",
"crates/module-quest",
"crates/module-runtime-item",
"crates/module-runtime-story",
"crates/module-square-hole",
"crates/module-story",
"crates/module-visual-novel",
"crates/module-wooden-fish",
"crates/platform-agent",
]
members = [
"crates/api-server",
"crates/module-ai",
"crates/module-assets",
"crates/module-auth",
"crates/module-bark-battle",
"crates/module-big-fish",
"crates/module-combat",
"crates/module-creative-agent",
"crates/module-editor-agent",
"crates/module-inventory",
"crates/module-custom-world",
"crates/module-jump-hop",
"crates/module-wooden-fish",
"crates/module-match3d",
"crates/module-npc",
"crates/module-puzzle",
"crates/module-puzzle-clear",
"crates/module-progression",
"crates/module-quest",
"crates/module-runtime",
"crates/module-runtime-story",
"crates/module-runtime-item",
"crates/module-square-hole",
"crates/module-story",
"crates/module-visual-novel",
"crates/platform-oss",
"crates/platform-auth",
"crates/platform-audio",
@@ -41,7 +44,6 @@ members = [
"crates/platform-matting",
"crates/platform-wechat",
"crates/platform-speech",
"crates/platform-agent",
"crates/platform-editor-agent",
"crates/pingora-gateway",
"crates/server-manager-panel",
@@ -63,28 +65,8 @@ license = "UNLICENSED"
module-ai = { path = "crates/module-ai", default-features = false }
module-assets = { path = "crates/module-assets", default-features = false }
module-auth = { path = "crates/module-auth", default-features = false }
module-bark-battle = { path = "crates/module-bark-battle", default-features = false }
module-big-fish = { path = "crates/module-big-fish", default-features = false }
module-combat = { path = "crates/module-combat", default-features = false }
module-creative-agent = { path = "crates/module-creative-agent", default-features = false }
module-editor-agent = { path = "crates/module-editor-agent", default-features = false }
module-custom-world = { path = "crates/module-custom-world", default-features = false }
module-inventory = { path = "crates/module-inventory", default-features = false }
module-jump-hop = { path = "crates/module-jump-hop", default-features = false }
module-wooden-fish = { path = "crates/module-wooden-fish", default-features = false }
module-match3d = { path = "crates/module-match3d", default-features = false }
module-npc = { path = "crates/module-npc", default-features = false }
module-progression = { path = "crates/module-progression", default-features = false }
module-puzzle = { path = "crates/module-puzzle", default-features = false }
module-puzzle-clear = { path = "crates/module-puzzle-clear", default-features = false }
module-quest = { path = "crates/module-quest", default-features = false }
module-runtime = { path = "crates/module-runtime", default-features = false }
module-runtime-item = { path = "crates/module-runtime-item", default-features = false }
module-runtime-story = { path = "crates/module-runtime-story", default-features = false }
module-square-hole = { path = "crates/module-square-hole", default-features = false }
module-story = { path = "crates/module-story", default-features = false }
module-visual-novel = { path = "crates/module-visual-novel", default-features = false }
platform-agent = { path = "crates/platform-agent", default-features = false }
platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false }
platform-auth = { path = "crates/platform-auth", default-features = false }
platform-audio = { path = "crates/platform-audio", default-features = false }
@@ -120,7 +102,6 @@ httpdate = "1"
hex = "0.4"
image = { version = "0.25", default-features = false }
jsonwebtoken = "9"
langchainrust = "0.2.18"
log = "0.4"
mime_guess = "2.0.5"
pingora = { version = "0.8.1", default-features = false, features = ["proxy", "rustls"] }
@@ -135,9 +116,9 @@ serde_urlencoded = "0.7"
sha1 = "0.10"
sha2 = "0.10"
socket2 = "0.6"
spacetimedb = "=2.6.1"
spacetimedb-sdk = "=2.6.1"
spacetimedb-lib = { version = "=2.6.1", default-features = false }
spacetimedb = "=2.7.0"
spacetimedb-sdk = "=2.7.0"
spacetimedb-lib = { version = "=2.7.0", default-features = false }
time = "0.3"
tokio = "1"
tokio-stream = "0.1"
-16
View File
@@ -20,24 +20,8 @@ webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-bark-battle = { workspace = true }
module-combat = { workspace = true }
module-creative-agent = { workspace = true }
module-custom-world = { workspace = true }
module-editor-agent = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-puzzle-clear = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
platform-editor-agent = { workspace = true }
platform-audio = { workspace = true }
platform-auth = { workspace = true }
+181 -58
View File
@@ -21,10 +21,9 @@ 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, AdminCreationEntryConfigResponse,
AdminCreationEntryTypeConfigPayload, AdminDashboardBreakdownRowPayload,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload,
AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload,
AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload,
AdminDashboardPaymentConversionPayload, AdminDashboardQuery, AdminDashboardRangePayload,
@@ -41,14 +40,20 @@ use shared_contracts::admin::{
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest,
};
#[cfg(any())]
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse,
AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest,
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse,
};
use shared_contracts::assets::{
CreateDirectUploadTicketRequest, DirectUploadObjectAccess, GetReadUrlQuery,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest,
DirectUploadObjectAccess, GetReadUrlQuery,
};
#[cfg(any())]
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
};
@@ -67,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,
@@ -109,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 {
@@ -389,6 +397,7 @@ pub async fn admin_list_database_table_rows(
}
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
#[cfg(any())]
pub async fn admin_get_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -405,6 +414,7 @@ pub async fn admin_get_creation_entry_config(
}
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
#[cfg(any())]
pub async fn admin_upsert_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -423,6 +433,7 @@ pub async fn admin_upsert_creation_entry_config(
}
/// 保存底部加号创作入口页的多公告表单序列化配置。
#[cfg(any())]
pub async fn admin_upsert_creation_entry_event_banners_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -447,6 +458,7 @@ pub async fn admin_upsert_creation_entry_event_banners_config(
}
/// 保存公开作品详情页点赞 / 改造能力配置。
#[cfg(any())]
pub async fn admin_upsert_public_work_interaction_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -545,6 +557,7 @@ pub async fn admin_upsert_editor_generation_pricing(
Ok(json_success_body(Some(&request_context), pricing))
}
#[cfg(any())]
pub async fn admin_list_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -561,6 +574,7 @@ pub async fn admin_list_work_visibility(
))
}
#[cfg(any())]
pub async fn admin_update_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -893,6 +907,70 @@ 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,
) -> AdminCreationEntryConfigResponse {
@@ -907,6 +985,7 @@ fn build_admin_creation_entry_config_response(
}
}
#[cfg(any())]
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {
@@ -977,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,
@@ -1384,6 +1464,7 @@ fn normalize_admin_optional(value: Option<String>) -> Option<String> {
.filter(|item| !item.is_empty())
}
#[cfg(any())]
fn validate_admin_creation_entry_config(
payload: AdminUpsertCreationEntryTypeConfigRequest,
) -> Result<module_runtime::CreationEntryTypeAdminUpsertInput, AppError> {
@@ -1444,6 +1525,7 @@ fn validate_admin_feature_gate_config(
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
}
#[cfg(any())]
fn validate_admin_work_visibility(
payload: AdminUpdateWorkVisibilityRequest,
) -> Result<(String, String, bool), AppError> {
@@ -1629,7 +1711,7 @@ fn enforce_admin_request_permission(
}
}
fn admin_permission_requirement(method: &Method, path: &str) -> AdminPermissionRequirement {
fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermissionRequirement {
use AdminPermissionRequirement::{AnyOf, Authenticated, OwnerOnly};
match path {
@@ -1644,7 +1726,6 @@ fn admin_permission_requirement(method: &Method, path: &str) -> AdminPermissionR
"/admin/api/editor-assets" => AnyOf(&["editor-assets"]),
"/admin/api/assets/read-url" => AnyOf(&["editor-assets", "editor-showcase"]),
path if path.starts_with("/admin/api/editor-showcase/") => AnyOf(&["editor-showcase"]),
"/admin/api/works/visibility" => AnyOf(&["work-visibility"]),
"/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => {
AnyOf(&["redeem"])
}
@@ -1660,14 +1741,7 @@ fn admin_permission_requirement(method: &Method, path: &str) -> AdminPermissionR
"recharge-orders",
"editor-showcase",
"editor-assets",
"work-visibility",
]),
"/admin/api/creation-entry/config/banners" => AnyOf(&["creation-announcement"]),
"/admin/api/creation-entry/config/interactions" => AnyOf(&["creation-entry"]),
"/admin/api/creation-entry/config" if method == Method::GET => {
AnyOf(&["gray-release", "creation-announcement", "creation-entry"])
}
"/admin/api/creation-entry/config" => AnyOf(&["creation-entry"]),
path if path == "/admin/api/database/tables"
|| path.starts_with("/admin/api/database/tables/") =>
{
@@ -3934,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,
@@ -3955,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,
@@ -4064,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");
@@ -4072,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("作者昵称"));
@@ -4083,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]
@@ -5275,24 +5431,6 @@ mod tests {
)
.is_err()
);
assert!(
enforce_admin_request_permission(
"member",
&["creation-announcement".to_string()],
&Method::GET,
"/admin/api/creation-entry/config",
)
.is_ok()
);
assert!(
enforce_admin_request_permission(
"member",
&["creation-announcement".to_string()],
&Method::POST,
"/admin/api/creation-entry/config",
)
.is_err()
);
}
#[test]
@@ -5333,21 +5471,6 @@ mod tests {
"/admin/api/editor-showcase/assets",
),
("editor-assets", Method::GET, "/admin/api/editor-assets"),
(
"creation-announcement",
Method::POST,
"/admin/api/creation-entry/config/banners",
),
(
"creation-entry",
Method::POST,
"/admin/api/creation-entry/config",
),
(
"work-visibility",
Method::GET,
"/admin/api/works/visibility",
),
];
for (permission, method, path) in cases {
File diff suppressed because it is too large Load Diff
+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!(
+32 -100
View File
@@ -8,13 +8,16 @@ use axum::{
middleware::Next,
response::Response,
};
use platform_auth::{AccessTokenClaims, read_refresh_session_token, verify_access_token};
#[cfg(any())]
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY,
RuntimeGuestTokenClaims, RuntimeGuestTokenClaimsInput, read_refresh_session_token,
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, RuntimeGuestTokenClaims, RuntimeGuestTokenClaimsInput,
sign_runtime_guest_token, verify_runtime_guest_token,
};
use serde_json::{Value, json};
#[cfg(any())]
use shared_contracts::auth::RuntimeGuestTokenResponse;
#[cfg(any())]
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
use time::OffsetDateTime;
use tracing::warn;
@@ -24,9 +27,6 @@ use crate::{
state::AppState,
};
const INTERNAL_AUTH_USER_ID_HEADER: &str = "x-genarrative-authenticated-user-id";
const INTERNAL_API_SECRET_HEADER: &str = "x-genarrative-internal-api-secret";
// 统一把已校验的 claims 写入 request extensions,避免后续 handler 再次重复解析 Bearer token。
#[derive(Clone, Debug)]
pub struct AuthenticatedAccessToken {
@@ -39,12 +39,14 @@ pub struct RefreshSessionToken {
}
#[derive(Clone, Debug)]
#[cfg(any())]
pub enum RuntimePrincipal {
User(AuthenticatedAccessToken),
Guest(RuntimeGuestTokenClaims),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg(any())]
pub enum RuntimePrincipalKind {
User,
Guest,
@@ -70,6 +72,7 @@ impl RefreshSessionToken {
}
}
#[cfg(any())]
impl RuntimePrincipal {
pub fn subject(&self) -> &str {
match self {
@@ -86,6 +89,7 @@ impl RuntimePrincipal {
}
}
#[cfg(any())]
impl RuntimePrincipalKind {
pub fn as_str(self) -> &'static str {
match self {
@@ -95,6 +99,7 @@ impl RuntimePrincipalKind {
}
}
#[cfg(any())]
pub async fn issue_runtime_guest_token(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -135,10 +140,9 @@ pub async fn require_bearer_auth(
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let path = request.uri().path().to_string();
let headers = request.headers().clone();
let request_id = request_id_from_request(&request);
let Some(authenticated) = authenticate_request(&state, path, headers, request_id).await? else {
let Some(authenticated) = authenticate_request(&state, headers, request_id).await? else {
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
};
request.extensions_mut().insert(authenticated.clone());
@@ -149,16 +153,15 @@ pub async fn require_bearer_auth(
Ok(response)
}
#[cfg(any())]
pub async fn require_runtime_principal_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let path = request.uri().path().to_string();
let headers = request.headers().clone();
let request_id = request_id_from_request(&request);
let Some(principal) = authenticate_runtime_principal(&state, path, headers, request_id).await?
else {
let Some(principal) = authenticate_runtime_principal(&state, headers, request_id).await? else {
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
};
request.extensions_mut().insert(principal.clone());
@@ -169,9 +172,9 @@ pub async fn require_runtime_principal_auth(
Ok(response)
}
#[cfg(any())]
async fn authenticate_runtime_principal(
state: &AppState,
path: String,
headers: HeaderMap,
request_id: String,
) -> Result<Option<RuntimePrincipal>, AppError> {
@@ -179,7 +182,7 @@ async fn authenticate_runtime_principal(
return Ok(None);
}
match authenticate_request(state, path, headers.clone(), request_id.clone()).await {
match authenticate_request(state, headers.clone(), request_id.clone()).await {
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
Ok(None) => Ok(None),
Err(_) => {
@@ -208,25 +211,18 @@ async fn authenticate_runtime_principal(
pub async fn optional_access_token_from_headers(
state: &AppState,
path: String,
_path: String,
headers: HeaderMap,
request_id: String,
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
authenticate_request(state, path, headers, request_id).await
authenticate_request(state, headers, request_id).await
}
async fn authenticate_request(
state: &AppState,
path: String,
headers: HeaderMap,
request_id: String,
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
if allows_internal_forwarded_auth(&path) {
if let Some(claims) = try_build_internal_forwarded_claims(state, &headers) {
return Ok(Some(AuthenticatedAccessToken::new(claims)));
}
}
if !headers.contains_key(AUTHORIZATION) {
return Ok(None);
}
@@ -377,63 +373,9 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
Ok(token.to_string())
}
fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验,Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/creative-agent/")
|| path.starts_with("/api/runtime/puzzle/")
}
fn try_build_internal_forwarded_claims(
state: &AppState,
headers: &HeaderMap,
) -> Option<AccessTokenClaims> {
let expected_secret = state.config.internal_api_secret.as_ref()?.trim();
if expected_secret.is_empty() {
return None;
}
let provided_secret = headers
.get(INTERNAL_API_SECRET_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?;
if provided_secret != expected_secret {
return None;
}
let user_id = headers
.get(INTERNAL_AUTH_USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
// 这里的 claims 只服务于经 Node 已鉴权后的本地内部转发链路,避免在开发态复制整套账号仓储。
AccessTokenClaims::from_input(
platform_auth::AccessTokenClaimsInput {
user_id: user_id.clone(),
session_id: format!("internal-forwarded-{user_id}"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 0,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: None,
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.ok()
}
#[cfg(test)]
mod tests {
use super::{
INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken,
allows_internal_forwarded_auth, extract_bearer_token, try_build_internal_forwarded_claims,
};
use super::{RefreshSessionToken, extract_bearer_token, optional_access_token_from_headers};
use crate::{config::AppConfig, state::AppState};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
@@ -470,40 +412,30 @@ mod tests {
assert_eq!(token.token(), "refresh-token-01");
}
#[test]
fn internal_forwarded_auth_allows_node_proxy_runtime_paths() {
assert!(allows_internal_forwarded_auth(
"/api/runtime/big-fish/sessions"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/creative-agent/sessions"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}
#[test]
fn internal_forwarded_claims_require_matching_secret() {
#[tokio::test]
async fn internal_forwarding_headers_do_not_authenticate_without_bearer_token() {
let mut config = AppConfig::default();
config.internal_api_secret = Some("bridge-secret".to_string());
let state = AppState::new(config).expect("state should build");
let mut headers = HeaderMap::new();
headers.insert(
INTERNAL_AUTH_USER_ID_HEADER,
"x-genarrative-authenticated-user-id",
HeaderValue::from_static("user_forwarded_01"),
);
headers.insert(
INTERNAL_API_SECRET_HEADER,
"x-genarrative-internal-api-secret",
HeaderValue::from_static("bridge-secret"),
);
let claims =
try_build_internal_forwarded_claims(&state, &headers).expect("claims should resolve");
let authenticated = optional_access_token_from_headers(
&state,
"/api/runtime/puzzle/works".to_string(),
headers,
"req-retired-internal-forward".to_string(),
)
.await
.expect("forwarding headers should be ignored");
assert_eq!(claims.user_id(), "user_forwarded_01");
assert_eq!(claims.token_version(), 0);
assert!(authenticated.is_none());
}
}
@@ -65,6 +65,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::UnsupportedPhoneCountryCode
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidDisplayName
| module_auth::PasswordEntryError::InvalidAvatarDataUrl
+8 -141
View File
@@ -106,43 +106,13 @@ fn should_bypass_backpressure(request: &Request<Body>) -> bool {
}
fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
if is_gallery_list_path(path) {
HttpRequestPermitPoolKind::Gallery
} else if is_gallery_detail_path(path) {
HttpRequestPermitPoolKind::Detail
} else if path.starts_with("/admin/api/") {
if path.starts_with("/admin/api/") {
HttpRequestPermitPoolKind::Admin
} else {
HttpRequestPermitPoolKind::Default
}
}
fn is_gallery_list_path(path: &str) -> bool {
matches!(
path,
"/api/runtime/puzzle/gallery" | "/api/runtime/custom-world-gallery"
)
}
fn is_gallery_detail_path(path: &str) -> bool {
let puzzle_prefix = "/api/runtime/puzzle/gallery/";
if let Some(profile_id) = path.strip_prefix(puzzle_prefix) {
return !profile_id.is_empty() && !profile_id.contains('/');
}
let custom_world_prefix = "/api/runtime/custom-world-gallery/";
if let Some(remainder) = path.strip_prefix(custom_world_prefix) {
let mut segments = remainder.split('/');
return matches!(
(segments.next(), segments.next(), segments.next()),
(Some(owner_user_id), Some(profile_id), None)
if !owner_user_id.is_empty() && !profile_id.is_empty()
);
}
false
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
@@ -211,30 +181,17 @@ mod tests {
fn build_grouped_test_app(
default_max_concurrent_requests: usize,
gallery_max_concurrent_requests: usize,
admin_max_concurrent_requests: usize,
gate: HeldRequestGate,
) -> Router {
let mut config = AppConfig::default();
config.max_concurrent_requests = Some(default_max_concurrent_requests);
config.gallery_max_concurrent_requests = Some(gallery_max_concurrent_requests);
config.admin_max_concurrent_requests = Some(admin_max_concurrent_requests);
let state = AppState::new(config).expect("state should build");
let backpressure_state = BackpressureState::from_ref(&state);
Router::new()
.route("/held", get(held_request))
.route("/api/runtime/puzzle/gallery", get(held_request))
.route("/api/runtime/custom-world-gallery", get(held_request))
.route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
.route(
"/api/runtime/puzzle/gallery/profile-1/like",
get(fast_request),
)
.route(
"/api/runtime/custom-world-gallery/user-1/profile-1",
get(held_request),
)
.route("/admin/api/overview", get(held_request))
.route("/fast", get(fast_request))
.layer(middleware::from_fn_with_state(
@@ -344,99 +301,13 @@ mod tests {
assert_eq!(accepted_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn gallery_pool_rejects_gallery_without_blocking_default_routes() {
let gate = HeldRequestGate {
entered: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
};
let app = build_grouped_test_app(2, 1, 1, gate.clone());
let entered = gate.entered.notified();
let held_response = tokio::spawn(
app.clone()
.oneshot(test_request("/api/runtime/puzzle/gallery")),
);
entered.await;
let rejected_gallery_response = app
.clone()
.oneshot(test_request("/api/runtime/custom-world-gallery"))
.await
.expect("rejected gallery request should complete");
assert_eq!(
rejected_gallery_response.status(),
StatusCode::TOO_MANY_REQUESTS
);
let accepted_default_response = app
.clone()
.oneshot(test_request("/fast"))
.await
.expect("default request should complete");
assert_eq!(accepted_default_response.status(), StatusCode::OK);
gate.release.notify_one();
let completed_response = held_response
.await
.expect("held request task should join")
.expect("held request should complete");
assert_eq!(completed_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn detail_pool_falls_back_to_default_when_unset() {
let gate = HeldRequestGate {
entered: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
};
let mut config = AppConfig::default();
config.max_concurrent_requests = Some(1);
config.detail_max_concurrent_requests = None;
let state = AppState::new(config).expect("state should build");
let backpressure_state = BackpressureState::from_ref(&state);
let app = Router::new()
.route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
.route("/fast", get(fast_request))
.layer(middleware::from_fn_with_state(
backpressure_state,
limit_concurrent_requests,
))
.layer(Extension(gate.clone()))
.with_state(state);
let entered = gate.entered.notified();
let held_response = tokio::spawn(
app.clone()
.oneshot(test_request("/api/runtime/puzzle/gallery/profile-1")),
);
entered.await;
let rejected_default_response = app
.clone()
.oneshot(test_request("/fast"))
.await
.expect("default request should complete");
assert_eq!(
rejected_default_response.status(),
StatusCode::TOO_MANY_REQUESTS
);
gate.release.notify_one();
let completed_response = held_response
.await
.expect("held request task should join")
.expect("held request should complete");
assert_eq!(completed_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_pool_is_isolated_from_default_routes() {
let gate = HeldRequestGate {
entered: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
};
let app = build_grouped_test_app(2, 1, 1, gate.clone());
let app = build_grouped_test_app(2, 1, gate.clone());
let entered = gate.entered.notified();
let held_response = tokio::spawn(app.clone().oneshot(test_request("/admin/api/overview")));
@@ -468,22 +339,18 @@ mod tests {
}
#[test]
fn classifies_only_exact_gallery_detail_paths_as_detail() {
fn classifies_active_api_and_admin_pools() {
assert_eq!(
classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1"),
crate::state::HttpRequestPermitPoolKind::Detail
);
assert_eq!(
classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1/like"),
classify_request_permit_pool("/api/editor/showcase/resources"),
crate::state::HttpRequestPermitPoolKind::Default
);
assert_eq!(
classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1"),
crate::state::HttpRequestPermitPoolKind::Detail
classify_request_permit_pool("/api/runtime/settings"),
crate::state::HttpRequestPermitPoolKind::Default
);
assert_eq!(
classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1/like"),
crate::state::HttpRequestPermitPoolKind::Default
classify_request_permit_pool("/admin/api/overview"),
crate::state::HttpRequestPermitPoolKind::Admin
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,7 @@ const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)]
#[cfg(any())]
pub(crate) struct GeneratedCharacterPrimaryVisual {
pub image_src: String,
pub asset_id: String,
@@ -292,6 +293,7 @@ pub async fn generate_character_visual(
))
}
#[cfg(any())]
pub(crate) async fn generate_character_primary_visual_for_profile(
state: &AppState,
owner_user_id: &str,
File diff suppressed because it is too large Load Diff
@@ -352,7 +352,8 @@ mod tests {
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: phone_number.to_string(),
country_code: None,
pure_phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
@@ -363,7 +364,8 @@ mod tests {
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: phone_number.to_string(),
country_code: None,
pure_phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
@@ -358,9 +358,6 @@ mod tests {
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let success_json = serde_json::json!({
"replyText": "好,我们先把玩具王国定住。",
"progressPercent": 12,
@@ -404,7 +401,8 @@ mod tests {
0,
1,
)
.expect("LLM config should build");
.expect("LLM config should build")
.with_raw_log_dir(log_dir.clone());
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
@@ -436,9 +434,6 @@ mod tests {
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
@@ -2587,9 +2587,6 @@ mod tests {
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
@@ -2604,7 +2601,7 @@ mod tests {
},
],
);
let llm_client = build_test_llm_client(server_url);
let llm_client = build_test_llm_client_with_raw_log_dir(server_url, log_dir.clone());
let parsed = request_foundation_json_stage(
&llm_client,
@@ -2628,9 +2625,6 @@ mod tests {
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
@@ -3256,7 +3250,19 @@ mod tests {
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmClient::new(build_test_llm_config(base_url)).expect("llm client should build")
}
fn build_test_llm_client_with_raw_log_dir(
base_url: String,
raw_log_dir: std::path::PathBuf,
) -> LlmClient {
let config = build_test_llm_config(base_url).with_raw_log_dir(raw_log_dir);
LlmClient::new(config).expect("llm client should build")
}
fn build_test_llm_config(base_url: String) -> LlmConfig {
LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
@@ -3265,9 +3271,7 @@ mod tests {
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
.expect("llm config should build")
}
fn spawn_mock_server(
@@ -1,3 +1,6 @@
use std::future::IntoFuture;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use module_editor_agent::{
@@ -75,8 +78,13 @@ use platform_editor_agent::agent::tools::generate_video::{
GenerateVideoTool, GenerateVideoToolArgs,
};
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
use tokio::time::{Instant, timeout};
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000;
const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18 分钟安全上限";
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
pub async fn editor_agent_message(
State(state): State<AppState>,
@@ -85,6 +93,7 @@ pub async fn editor_agent_message(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAgentMessageRequest>,
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
let message_started_at = Instant::now();
let owner_user_id = authenticated.claims().user_id().to_string();
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
let client_message_id = validate_editor_agent_message_request(&payload)?;
@@ -210,13 +219,17 @@ pub async fn editor_agent_message(
let tool_context = context::build_tool_context(&document);
// Build and run agent
let Some(llm_client) = state.creative_agent_gpt5_client() else {
let Some(llm_client) = state.editor_agent_llm_client() else {
tracing::warn!(
conversation_id = %conversation.conversation_id,
"美术 Agent LLM 客户端未配置"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
"Creative Agent GPT-5 client not configured",
EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE,
)
.await;
};
@@ -224,12 +237,17 @@ pub async fn editor_agent_message(
let pricing = match state.editor_generation_pricing().await {
Ok(pricing) => pricing,
Err(error) => {
tracing::warn!(
conversation_id = %conversation.conversation_id,
error = %error,
"读取美术 Agent 生成定价失败"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
format!("failed to load editor generation pricing: {error}"),
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
)
.await;
}
@@ -264,9 +282,13 @@ pub async fn editor_agent_message(
.memory(memory)
.build();
let agent_result = agent
.prompt(LlmMessage::user(user_message.text.clone()))
.await;
let remaining_prompt_duration =
remaining_editor_agent_prompt_duration(message_started_at.elapsed());
let agent_result = run_editor_agent_prompt_with_timeout(
agent.prompt(LlmMessage::user(user_message.text.clone())),
remaining_prompt_duration,
)
.await;
let assistant_now = now_rfc3339();
@@ -303,6 +325,26 @@ pub async fn editor_agent_message(
}
}
fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration {
Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed)
}
async fn run_editor_agent_prompt_with_timeout<F>(
future: F,
duration: Duration,
) -> Result<Vec<PromptOutput>, PromptError>
where
F: IntoFuture<Output = Result<Vec<PromptOutput>, PromptError>>,
{
timeout(duration, future.into_future())
.await
.unwrap_or_else(|_| {
Err(PromptError::CompletionError(
EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(),
))
})
}
fn build_editor_agent_error_message(
message_id: usize,
error: impl std::fmt::Display,
@@ -512,6 +554,42 @@ mod tests {
assert_eq!(message.text, "ERROR planning failed");
assert!(message.tool_call.is_none());
}
#[test]
fn direct_planning_failures_use_user_facing_chinese_copy() {
assert_eq!(
build_editor_agent_error_message(1, EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE).text,
"ERROR 美术 Agent 服务暂不可用,请稍后重试"
);
assert_eq!(
build_editor_agent_error_message(2, EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE).text,
"ERROR 美术 Agent 生成定价暂不可用,请稍后重试"
);
}
#[tokio::test]
async fn prompt_timeout_applies_to_the_whole_agent_run() {
let error = run_editor_agent_prompt_with_timeout(
std::future::pending::<Result<Vec<PromptOutput>, PromptError>>(),
Duration::from_millis(1),
)
.await
.expect_err("pending agent run should hit the prompt deadline");
assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000);
assert_eq!(
remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)),
Duration::from_secs(60)
);
assert_eq!(
remaining_editor_agent_prompt_duration(Duration::from_secs(18 * 60)),
Duration::ZERO
);
assert_eq!(
error.to_string(),
"美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限"
);
}
}
fn editor_agent_system_prompt() -> &'static str {
r#"
File diff suppressed because it is too large Load Diff
@@ -744,6 +744,7 @@ mod tests {
user_id: Some("user-1".to_string()),
profile_id: Some("project-1".to_string()),
request_id: Some("request-1".to_string()),
external_call_deadline: None,
},
);
let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit);
@@ -859,7 +860,7 @@ mod tests {
);
}
// 真机联调:按 build_creative_agent_gpt5_client 的方式组 VectorEngine 客户端,直接跑
// 真机联调:按 build_editor_agent_llm_client 的方式组 VectorEngine 客户端,直接跑
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
// 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
@@ -899,7 +900,7 @@ mod tests {
let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?;
let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?;
// 与 state.rs build_creative_agent_gpt5_client 一致:规整到以 /v1 结尾。
// 与 state.rs build_editor_agent_llm_client 一致:规整到以 /v1 结尾。
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
base_url.trim_end_matches('/').to_string()
} else {
@@ -1049,6 +1049,7 @@ mod tests {
base_url: "https://vector.example".to_string(),
api_key: "secret".to_string(),
request_timeout_ms: 180_000,
request_deadline: None,
external_api_audit_state: None,
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
@@ -1,3 +1,5 @@
use std::time::Instant;
#[cfg(test)]
use axum::http::StatusCode;
use module_runtime::RuntimeTrackingScopeKind;
@@ -145,6 +147,9 @@ pub(crate) struct ExternalApiAuditContext {
pub(crate) user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) request_id: Option<String>,
/// 父流程允许外部调用占用到的绝对时刻。该字段只在进程内用于预算截断,
/// 不写入 tracking metadata,也不会通过内部协议传递绝对时间。
pub(crate) external_call_deadline: Option<Instant>,
}
/// 抠图供应商(BgFilter / 阿里云通用抠图)调用失败的统一失败审计入口。
@@ -180,6 +185,45 @@ pub(crate) async fn record_matting_external_api_failure(
record_external_api_failure(state, draft).await;
}
/// BgFilter worker 专用入口:保留同一份 OTLP / tracking draft,但只允许写入本进程
/// 独立 outbox。outbox 缺失、满载或写盘失败时丢弃,禁止在受限 worker 中逐条同步
/// 直写 SpacetimeDB。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn record_matting_external_api_failure_outbox_only(
state: &AppState,
context: &ExternalApiAuditContext,
provider: &'static str,
endpoint: String,
operation: &'static str,
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
transport: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
) {
let draft = build_matting_external_api_failure_draft(
provider,
endpoint,
operation,
failure_stage,
status_code,
timeout,
transport,
latency_ms,
error_message,
raw_excerpt,
context,
);
record_external_api_failure_with_policy(
state,
draft,
ExternalApiAuditPersistencePolicy::RequireOutboxDropOnFailure,
)
.await;
}
/// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取,
/// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。
#[allow(clippy::too_many_arguments)]
@@ -347,8 +391,33 @@ pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
status_class(Some(status_code.as_u16()))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ExternalApiAuditPersistencePolicy {
PreferOutboxThenSync,
RequireOutboxDropOnFailure,
}
impl ExternalApiAuditPersistencePolicy {
fn allows_sync_fallback(self) -> bool {
matches!(self, Self::PreferOutboxThenSync)
}
}
/// 中文注释:外部供应商失败同时进入 OTLP 和 tracking_event;失败审计不能反向阻断主业务错误返回。
pub(crate) async fn record_external_api_failure(state: &AppState, draft: ExternalApiFailureDraft) {
record_external_api_failure_with_policy(
state,
draft,
ExternalApiAuditPersistencePolicy::PreferOutboxThenSync,
)
.await;
}
async fn record_external_api_failure_with_policy(
state: &AppState,
draft: ExternalApiFailureDraft,
persistence_policy: ExternalApiAuditPersistencePolicy,
) {
record_external_api_failure_otlp(&draft);
let tracking_event = build_external_api_failure_tracking_draft(&draft);
@@ -361,6 +430,18 @@ pub(crate) async fn record_external_api_failure(state: &AppState, draft: Externa
{
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {}
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
if !persistence_policy.allows_sync_fallback() {
crate::telemetry::record_external_api_audit_dropped(draft.provider, reason);
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
operation = %draft.operation,
failure_stage = draft.failure_stage,
reason,
"外部 API 失败审计写入专用 outbox 被保护阈值拒绝,已丢弃"
);
return;
}
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
@@ -377,6 +458,21 @@ pub(crate) async fn record_external_api_failure(state: &AppState, draft: Externa
.await;
}
Err(error) => {
if !persistence_policy.allows_sync_fallback() {
crate::telemetry::record_external_api_audit_dropped(
draft.provider,
"outbox_error",
);
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
operation = %draft.operation,
failure_stage = draft.failure_stage,
error = %error,
"外部 API 失败审计写入专用 outbox 失败,已丢弃"
);
return;
}
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
@@ -396,6 +492,18 @@ pub(crate) async fn record_external_api_failure(state: &AppState, draft: Externa
return;
}
if !persistence_policy.allows_sync_fallback() {
crate::telemetry::record_external_api_audit_dropped(draft.provider, "outbox_missing");
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
operation = %draft.operation,
failure_stage = draft.failure_stage,
"外部 API 失败审计缺少专用 outbox,已丢弃"
);
return;
}
crate::tracking::record_tracking_event_after_success(
state,
&audit_request_context(),
@@ -567,6 +675,14 @@ mod tests {
use super::*;
#[test]
fn bgfilter_outbox_only_policy_never_allows_sync_fallback() {
assert!(ExternalApiAuditPersistencePolicy::PreferOutboxThenSync.allows_sync_fallback());
assert!(
!ExternalApiAuditPersistencePolicy::RequireOutboxDropOnFailure.allows_sync_fallback()
);
}
#[test]
fn external_api_failure_tracking_draft_uses_module_scope_and_safe_metadata() {
let draft = build_external_api_failure_tracking_draft(
@@ -32,7 +32,8 @@ use crate::{
editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
serialize_editor_asset_metadata, serialize_editor_layers,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
serialize_editor_layers,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
@@ -62,6 +63,7 @@ pub struct ExternalEditorProjectCreateRequest {
pub struct ExternalEditorCanvasSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
expected_revision: Option<u64>,
}
#[derive(Debug, Deserialize)]
@@ -339,17 +341,29 @@ pub async fn save_external_editor_canvas(
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
let project = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json: serialize_editor_layers(payload.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
let project = if let Some(expected_revision) = payload.expected_revision {
save_editor_project_layout_with_revision_and_get(
&state,
project_id.as_str(),
principal.owner_user_id(),
payload.viewport.into_record(),
payload.layers,
expected_revision,
)
.await?
} else {
state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json: serialize_editor_layers(payload.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?
};
Ok(json_success_body(
Some(&request_context),
@@ -854,6 +868,14 @@ mod tests {
.get("priceMudPoints")
.is_none()
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
);
assert_eq!(
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
"array"
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/edits")
File diff suppressed because it is too large Load Diff
@@ -1,77 +1 @@
use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage,
platform_errors::map_oss_error, state::AppState,
};
#[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
};
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
image: &DownloadedOpenAiImage,
item_names: &[String],
grid_size: usize,
views_per_item: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
generated_asset_sheets_impl::slice_generated_asset_sheet_two_items_per_row(
image,
item_names,
grid_size,
views_per_item,
)
.map_err(map_generated_asset_sheet_error)
}
pub(crate) async fn persist_generated_asset_sheet_bytes(
state: &AppState,
input: GeneratedAssetSheetPersistInput,
) -> Result<GeneratedAssetSheetUpload, AppError> {
let oss_client = require_oss_client(state)?;
generated_asset_sheets_impl::persist_generated_asset_sheet_bytes(oss_client, input)
.await
.map_err(map_generated_asset_sheet_error)
}
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(serde_json::json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})
}
fn map_generated_asset_sheet_error(error: GeneratedAssetSheetError) -> AppError {
match error {
GeneratedAssetSheetError::InvalidRequest { message } => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(serde_json::json!({
"provider": "generated-asset-sheets",
"message": message,
}))
}
GeneratedAssetSheetError::DecodeImage { message }
| GeneratedAssetSheetError::EncodeImage { message } => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({
"provider": "generated-asset-sheets",
"message": message,
}))
}
GeneratedAssetSheetError::BuildHttpClient { message } => AppError::from_status(
StatusCode::INTERNAL_SERVER_ERROR,
)
.with_details(serde_json::json!({
"provider": "generated-asset-sheets",
"message": message,
})),
GeneratedAssetSheetError::Oss(error) => map_oss_error(error, "aliyun-oss"),
}
}
pub(crate) use platform_image::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha;
@@ -3005,6 +3005,7 @@ mod tests {
base_url: base_url.clone(),
api_key: api_key.clone(),
request_timeout_ms: 180_000,
request_deadline: None,
};
let http_client = platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");
@@ -3324,6 +3325,7 @@ mod tests {
base_url: base_url.clone(),
api_key: api_key.clone(),
request_timeout_ms: 180_000,
request_deadline: None,
};
let http_client = platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");

Some files were not shown because too many files have changed in this diff Show More