合并 master 最新更新
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
This commit is contained in:
Generated
+94
-744
File diff suppressed because it is too large
Load Diff
+25
-44
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user