合并 master 并接入 DirectProject 新聊天架构
- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。 - 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。 - DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。 - App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。 - 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。 - 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。 - 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。 - 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
Generated
+1
@@ -2870,6 +2870,7 @@ dependencies = [
|
||||
"platform-oss",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
@@ -2188,6 +2188,8 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
|
||||
match path {
|
||||
"/admin/api/me" => Authenticated,
|
||||
"/admin/api/agc-models" => OwnerOnly,
|
||||
"/admin/api/agc-templates" => AnyTab(&["agc-templates"]),
|
||||
path if path.starts_with("/admin/api/agc-templates/") => AnyTab(&["agc-templates"]),
|
||||
"/admin/api/dashboard" => AnyTab(&["dashboard"]),
|
||||
"/admin/api/overview" => AnyTab(&["overview"]),
|
||||
"/admin/api/external-api-keys" => AnyTab(&["tables"]),
|
||||
@@ -6846,6 +6848,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_template_routes_require_the_template_tab_permission() {
|
||||
for (method, path) in [
|
||||
(Method::GET, "/admin/api/agc-templates"),
|
||||
(Method::PUT, "/admin/api/agc-templates/cocos-empty-2d"),
|
||||
(Method::POST, "/admin/api/agc-templates/import"),
|
||||
] {
|
||||
assert!(enforce_admin_request_permission("owner", &[], &[], &method, path).is_ok());
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["agc-templates".to_string()],
|
||||
&[],
|
||||
&method,
|
||||
path,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["editor-assets".to_string()],
|
||||
&[],
|
||||
&method,
|
||||
path,
|
||||
)
|
||||
.expect_err("unassigned template permission")
|
||||
.status_code(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_tab_permissions_cover_shared_and_sensitive_routes() {
|
||||
assert!(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 后台按项目读取私有快照,并在完整性验证后导出原始工程目录。
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
future::Future,
|
||||
io::Write,
|
||||
sync::{Arc, OnceLock},
|
||||
@@ -18,11 +19,13 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use platform_oss::{
|
||||
OssClient, OssError, OssGetObjectRequest, agc_project_snapshot_file_object_key,
|
||||
agc_project_snapshot_manifest_object_key, project_snapshots::SnapshotDirectoryPage,
|
||||
validate_agc_project_snapshot_channel,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{
|
||||
admin::{
|
||||
AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotDownloadQuery,
|
||||
AdminProjectSnapshotItem, AdminProjectSnapshotStatus, AdminProjectSnapshotsQuery,
|
||||
AdminProjectSnapshotsResponse,
|
||||
},
|
||||
@@ -45,6 +48,7 @@ use crate::{
|
||||
project_snapshots::{MAX_MANIFEST_REQUEST_BODY_BYTES, project_snapshot_oss, validate_manifest},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
};
|
||||
|
||||
const MAX_DIRECTORY_REQUESTS: usize = 100;
|
||||
@@ -55,6 +59,7 @@ static DOWNLOAD_PERMITS: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SnapshotCursor {
|
||||
channel: String,
|
||||
user: Option<String>,
|
||||
project: Option<String>,
|
||||
finished_user: bool,
|
||||
@@ -62,9 +67,10 @@ struct SnapshotCursor {
|
||||
}
|
||||
|
||||
impl SnapshotCursor {
|
||||
fn decode(value: Option<&str>) -> Result<Self, AppError> {
|
||||
fn decode(value: Option<&str>, channel: &str) -> Result<Self, AppError> {
|
||||
let Some(value) = value.filter(|value| !value.is_empty()) else {
|
||||
return Ok(Self {
|
||||
channel: channel.to_string(),
|
||||
finished_user: true,
|
||||
..Self::default()
|
||||
});
|
||||
@@ -77,6 +83,10 @@ impl SnapshotCursor {
|
||||
.map_err(|_| bad_request("项目列表游标无效"))?;
|
||||
let cursor: Self =
|
||||
serde_json::from_slice(&bytes).map_err(|_| bad_request("项目列表游标无效"))?;
|
||||
// 游标只在同一渠道内有效:跨渠道复用会让目录推进落在别的渠道上。
|
||||
if cursor.channel != channel {
|
||||
return Err(bad_request("项目列表游标与渠道不一致"));
|
||||
}
|
||||
for segment in [&cursor.user, &cursor.project].into_iter().flatten() {
|
||||
validate_agc_project_snapshot_project_id(segment)
|
||||
.map_err(|_| bad_request("项目列表游标无效"))?;
|
||||
@@ -115,6 +125,7 @@ trait SnapshotStore: Sync {
|
||||
struct OssSnapshotStore<'a> {
|
||||
oss: &'a OssClient,
|
||||
client: &'a reqwest::Client,
|
||||
channel: String,
|
||||
}
|
||||
|
||||
impl SnapshotStore for OssSnapshotStore<'_> {
|
||||
@@ -125,7 +136,7 @@ impl SnapshotStore for OssSnapshotStore<'_> {
|
||||
limit: usize,
|
||||
) -> Result<SnapshotDirectoryPage, AppError> {
|
||||
self.oss
|
||||
.list_project_snapshot_directories(self.client, user, after, limit)
|
||||
.list_project_snapshot_directories(self.client, &self.channel, user, after, limit)
|
||||
.await
|
||||
.map_err(|_| upstream("读取项目工程目录失败"))
|
||||
}
|
||||
@@ -135,7 +146,7 @@ impl SnapshotStore for OssSnapshotStore<'_> {
|
||||
user: &str,
|
||||
project: &str,
|
||||
) -> Result<Option<AgcProjectSnapshotManifestRequest>, AppError> {
|
||||
let object_key = agc_project_snapshot_manifest_object_key(user, project)
|
||||
let object_key = agc_project_snapshot_manifest_object_key(&self.channel, user, project)
|
||||
.map_err(|_| bad_request("项目工程身份无效"))?;
|
||||
let bytes = match self
|
||||
.oss
|
||||
@@ -169,6 +180,7 @@ impl SnapshotStore for OssSnapshotStore<'_> {
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let digest = file.checksum.strip_prefix("fnv1a64:").unwrap_or_default();
|
||||
let object_key = agc_project_snapshot_file_object_key(
|
||||
&self.channel,
|
||||
user,
|
||||
project,
|
||||
file.size_bytes,
|
||||
@@ -189,7 +201,11 @@ impl SnapshotStore for OssSnapshotStore<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProjectSnapshotItem {
|
||||
fn item(
|
||||
channel: &str,
|
||||
user: String,
|
||||
manifest: &AgcProjectSnapshotManifestRequest,
|
||||
) -> AdminProjectSnapshotItem {
|
||||
AdminProjectSnapshotItem {
|
||||
user_id: user,
|
||||
project_id: manifest.project_id.clone(),
|
||||
@@ -206,6 +222,19 @@ fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProj
|
||||
Some(_) => AdminProjectSnapshotStatus::Partial,
|
||||
None => AdminProjectSnapshotStatus::Unverified,
|
||||
},
|
||||
channel: channel.to_string(),
|
||||
author_display_name: None,
|
||||
author_public_user_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台列表的“用户”列与素材查询同口径:昵称 + 陶泥号,账号不可解析时沿用占位作者。
|
||||
/// 查账号只影响展示,失败不回滚已经读到的清单统计。
|
||||
fn attach_snapshot_authors(state: &AppState, items: &mut [AdminProjectSnapshotItem]) {
|
||||
for entry in items.iter_mut() {
|
||||
let author = resolve_work_author_by_user_id(state, &entry.user_id, None, None);
|
||||
entry.author_display_name = Some(author.display_name);
|
||||
entry.author_public_user_code = author.public_user_code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +260,7 @@ async fn list_snapshots(
|
||||
});
|
||||
};
|
||||
cursor = SnapshotCursor {
|
||||
channel: cursor.channel.clone(),
|
||||
user: Some(user),
|
||||
project: None,
|
||||
finished_user: false,
|
||||
@@ -255,7 +285,7 @@ async fn list_snapshots(
|
||||
for project in projects.directories {
|
||||
scanned += 1;
|
||||
if let Some(manifest) = store.manifest(user, &project).await? {
|
||||
items.push(item(user.to_string(), &manifest));
|
||||
items.push(item(&cursor.channel, user.to_string(), &manifest));
|
||||
}
|
||||
cursor.project = Some(project);
|
||||
}
|
||||
@@ -284,15 +314,69 @@ pub async fn admin_list_project_snapshots(
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Query(query): Query<AdminProjectSnapshotsQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let cursor = SnapshotCursor::decode(query.cursor.as_deref())?;
|
||||
let channel = requested_snapshot_channel(&state, query.channel.as_deref())?;
|
||||
let cursor = SnapshotCursor::decode(query.cursor.as_deref(), &channel)?;
|
||||
let store = OssSnapshotStore {
|
||||
oss: project_snapshot_oss(&state)?,
|
||||
client: state.editor_oss_http_client(),
|
||||
channel,
|
||||
};
|
||||
let response = list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?;
|
||||
let mut response =
|
||||
list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?;
|
||||
attach_snapshot_authors(&state, &mut response.items);
|
||||
Ok(json_success_body(Some(&ctx), response))
|
||||
}
|
||||
|
||||
/// 目标渠道:显式传入时必须合法(空串按未提供处理),缺省用本部署渠道。
|
||||
/// 非法渠道失败关闭,不落到别的渠道。
|
||||
fn requested_snapshot_channel(
|
||||
state: &AppState,
|
||||
requested: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let requested = requested.map(str::trim).filter(|value| !value.is_empty());
|
||||
match requested {
|
||||
Some(channel) => validate_agc_project_snapshot_channel(channel)
|
||||
.map_err(|_| bad_request("项目工程渠道无效")),
|
||||
None => crate::project_snapshots::project_snapshot_channel(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台渠道列表:本部署渠道与远端已存在渠道的并集。
|
||||
/// 远端目录里不符合渠道命名的条目直接跳过,不作为可查询渠道暴露。
|
||||
pub async fn admin_list_project_snapshot_channels(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let default_channel = crate::project_snapshots::project_snapshot_channel(&state)?;
|
||||
let oss = project_snapshot_oss(&state)?;
|
||||
let client = state.editor_oss_http_client();
|
||||
let mut channels = BTreeSet::from([default_channel.clone()]);
|
||||
let mut after: Option<String> = None;
|
||||
for _ in 0..MAX_DIRECTORY_REQUESTS {
|
||||
let page = oss
|
||||
.list_project_snapshot_channels(client, after.as_deref(), 100)
|
||||
.await
|
||||
.map_err(|_| upstream("读取项目工程渠道失败"))?;
|
||||
channels.extend(
|
||||
page.directories
|
||||
.into_iter()
|
||||
.filter(|name| validate_agc_project_snapshot_channel(name).is_ok()),
|
||||
);
|
||||
match page.next_marker {
|
||||
Some(next) => after = Some(next),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
AdminProjectSnapshotChannelsResponse {
|
||||
default_channel,
|
||||
channels: channels.into_iter().collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_file(file: &AgcProjectSnapshotManifestFile, bytes: &[u8]) -> Result<(), AppError> {
|
||||
if bytes.len() as u64 != file.size_bytes
|
||||
|| !agc_project_snapshot_checksum(bytes).eq_ignore_ascii_case(&file.checksum)
|
||||
@@ -441,6 +525,7 @@ pub async fn admin_download_project_snapshot(
|
||||
State(state): State<AppState>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Path((user, project)): Path<(String, String)>,
|
||||
Query(query): Query<AdminProjectSnapshotDownloadQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
validate_agc_project_snapshot_project_id(&user).map_err(bad_request)?;
|
||||
validate_agc_project_snapshot_project_id(&project).map_err(bad_request)?;
|
||||
@@ -457,6 +542,7 @@ pub async fn admin_download_project_snapshot(
|
||||
let store = OssSnapshotStore {
|
||||
oss: project_snapshot_oss(&state)?,
|
||||
client: state.editor_oss_http_client(),
|
||||
channel: requested_snapshot_channel(&state, query.channel.as_deref())?,
|
||||
};
|
||||
let manifest = store.manifest(&user, &project).await?.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("项目工程清单不存在")
|
||||
@@ -581,23 +667,63 @@ mod tests {
|
||||
"project-1",
|
||||
&[("game/empty", b""), ("game/main.js", b"123")],
|
||||
);
|
||||
let projection = item("user-1".to_string(), &manifest);
|
||||
let projection = item("dev", "user-1".to_string(), &manifest);
|
||||
assert_eq!(projection.status, AdminProjectSnapshotStatus::Ready);
|
||||
assert_eq!((projection.file_count, projection.total_bytes), (2, 3));
|
||||
assert_eq!(projection.channel, "dev");
|
||||
manifest.pending_files = Some(2);
|
||||
assert_eq!(
|
||||
item("user-1".into(), &manifest).status,
|
||||
item("release", "user-1".into(), &manifest).status,
|
||||
AdminProjectSnapshotStatus::Partial
|
||||
);
|
||||
let mut legacy = serde_json::to_value(manifest).unwrap();
|
||||
legacy.as_object_mut().unwrap().remove("pendingFiles");
|
||||
legacy.as_object_mut().unwrap().remove("projectName");
|
||||
let manifest = serde_json::from_value(legacy).unwrap();
|
||||
let projection = item("user-1".into(), &manifest);
|
||||
let projection = item("dev", "user-1".into(), &manifest);
|
||||
assert_eq!(projection.status, AdminProjectSnapshotStatus::Unverified);
|
||||
assert_eq!(projection.project_name, "project-1");
|
||||
}
|
||||
|
||||
/// 后台“用户”列与素材查询同口径:能查到账号时给昵称与陶泥号,查不到时给占位作者。
|
||||
#[test]
|
||||
fn project_snapshots_items_resolve_author_profile_for_admin_list() {
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
work_author::{ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE},
|
||||
};
|
||||
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.auth_user_service()
|
||||
.ensure_orphan_work_owner_user("user-1", "user-1", "陶泥用户", "SY-00000007")
|
||||
.expect("fixture user should be inserted");
|
||||
|
||||
let mut items = vec![
|
||||
item("dev", "user-1".to_string(), &manifest("project-1", &[])),
|
||||
item(
|
||||
"dev",
|
||||
"user-missing".to_string(),
|
||||
&manifest("project-2", &[]),
|
||||
),
|
||||
];
|
||||
attach_snapshot_authors(&state, &mut items);
|
||||
|
||||
assert_eq!(items[0].author_display_name.as_deref(), Some("陶泥用户"));
|
||||
assert_eq!(
|
||||
items[0].author_public_user_code.as_deref(),
|
||||
Some("SY-00000007")
|
||||
);
|
||||
assert_eq!(
|
||||
items[1].author_display_name.as_deref(),
|
||||
Some(ORPHAN_WORK_AUTHOR_DISPLAY_NAME)
|
||||
);
|
||||
assert_eq!(
|
||||
items[1].author_public_user_code.as_deref(),
|
||||
Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_snapshots_pagination_continues_directories_without_rescan() {
|
||||
let mut store = Store::default();
|
||||
@@ -610,13 +736,14 @@ mod tests {
|
||||
.manifests
|
||||
.insert((user.into(), project.into()), manifest(project, &[]));
|
||||
}
|
||||
let first = list_snapshots(&store, SnapshotCursor::decode(None).unwrap(), 1)
|
||||
let first = list_snapshots(&store, SnapshotCursor::decode(None, "dev").unwrap(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.items[0].project_id, "project-a");
|
||||
assert_eq!(first.items[0].channel, "dev");
|
||||
let second = list_snapshots(
|
||||
&store,
|
||||
SnapshotCursor::decode(first.next_cursor.as_deref()).unwrap(),
|
||||
SnapshotCursor::decode(first.next_cursor.as_deref(), "dev").unwrap(),
|
||||
1,
|
||||
)
|
||||
.await
|
||||
@@ -624,7 +751,7 @@ mod tests {
|
||||
assert_eq!(second.items[0].project_id, "project-b");
|
||||
let third = list_snapshots(
|
||||
&store,
|
||||
SnapshotCursor::decode(second.next_cursor.as_deref()).unwrap(),
|
||||
SnapshotCursor::decode(second.next_cursor.as_deref(), "dev").unwrap(),
|
||||
1,
|
||||
)
|
||||
.await
|
||||
@@ -638,13 +765,17 @@ mod tests {
|
||||
(Some("user-a".into()), Some("project-a".into()), 1)
|
||||
);
|
||||
assert_eq!(calls[3], (None, Some("user-a".into()), 1));
|
||||
assert!(SnapshotCursor::decode(Some("not-json")).is_err());
|
||||
assert!(SnapshotCursor::decode(Some("not-json"), "dev").is_err());
|
||||
// 游标不能跨渠道复用,否则目录推进会落在别的渠道上。
|
||||
let next = first.next_cursor.as_deref().expect("first page cursor");
|
||||
assert!(SnapshotCursor::decode(Some(next), "release").is_err());
|
||||
let escaped = SnapshotCursor {
|
||||
channel: "dev".into(),
|
||||
user: Some("../user".into()),
|
||||
..SnapshotCursor::default()
|
||||
}
|
||||
.encode();
|
||||
assert!(SnapshotCursor::decode(Some(&escaped)).is_err());
|
||||
assert!(SnapshotCursor::decode(Some(&escaped), "dev").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -887,11 +1018,14 @@ mod tests {
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap();
|
||||
let channel = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL")
|
||||
.unwrap_or_else(|_| "dev".to_string());
|
||||
let store = OssSnapshotStore {
|
||||
oss: &oss,
|
||||
client: &client,
|
||||
channel: channel.clone(),
|
||||
};
|
||||
let mut cursor = SnapshotCursor::decode(None).unwrap();
|
||||
let mut cursor = SnapshotCursor::decode(None, &channel).unwrap();
|
||||
let mut projects = 0;
|
||||
let mut total_files = 0;
|
||||
let mut total_bytes = 0_u64;
|
||||
@@ -938,7 +1072,7 @@ mod tests {
|
||||
}
|
||||
let Some(next) = page.next_cursor else { break };
|
||||
assert!(page_index < 99, "只读 smoke 已达到分页上限");
|
||||
cursor = SnapshotCursor::decode(Some(&next)).unwrap();
|
||||
cursor = SnapshotCursor::decode(Some(&next), &channel).unwrap();
|
||||
}
|
||||
assert!(projects > 0, "真实 bucket 未发现项目清单");
|
||||
eprintln!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,8 @@ pub struct AppConfig {
|
||||
pub editor_bgfilter_circuit_cooldown: Duration,
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub client_download_channel: String,
|
||||
/// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。
|
||||
pub project_snapshot_channel: String,
|
||||
pub log_filter: String,
|
||||
pub otel_enabled: bool,
|
||||
pub admin_username: Option<String>,
|
||||
@@ -179,6 +181,9 @@ pub struct AppConfig {
|
||||
pub project_snapshot_oss_endpoint: String,
|
||||
pub project_snapshot_oss_access_key_id: Option<String>,
|
||||
pub project_snapshot_oss_access_key_secret: Option<String>,
|
||||
/// AGC 模板库独立凭据;只在两项均缺省时成套复用通用 OSS 凭据。
|
||||
pub template_library_oss_access_key_id: Option<String>,
|
||||
pub template_library_oss_access_key_secret: Option<String>,
|
||||
pub spacetime_server_url: String,
|
||||
pub spacetime_database: String,
|
||||
pub spacetime_token: Option<String>,
|
||||
@@ -399,6 +404,7 @@ impl Default for AppConfig {
|
||||
),
|
||||
image_editor_agent_sidebar_enabled: false,
|
||||
client_download_channel: "dev".to_string(),
|
||||
project_snapshot_channel: "dev".to_string(),
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
otel_enabled: false,
|
||||
admin_username: None,
|
||||
@@ -491,6 +497,8 @@ impl Default for AppConfig {
|
||||
project_snapshot_oss_endpoint: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string(),
|
||||
project_snapshot_oss_access_key_id: None,
|
||||
project_snapshot_oss_access_key_secret: None,
|
||||
template_library_oss_access_key_id: None,
|
||||
template_library_oss_access_key_secret: None,
|
||||
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
|
||||
spacetime_database: "genarrative-dev".to_string(),
|
||||
spacetime_token: None,
|
||||
@@ -719,6 +727,12 @@ impl AppConfig {
|
||||
// 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。
|
||||
config.client_download_channel = channel.trim().to_string();
|
||||
}
|
||||
// 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先;
|
||||
// 显式空值或非法值同样保留,由快照入口失败关闭。
|
||||
config.project_snapshot_channel = config.client_download_channel.clone();
|
||||
if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") {
|
||||
config.project_snapshot_channel = channel.trim().to_string();
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
|
||||
{
|
||||
@@ -1156,6 +1170,11 @@ impl AppConfig {
|
||||
"ALIYUN_OSS_ACCESS_KEY_SECRET",
|
||||
]);
|
||||
|
||||
config.template_library_oss_access_key_id =
|
||||
read_first_non_empty_env(&["GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_ID"]);
|
||||
config.template_library_oss_access_key_secret =
|
||||
read_first_non_empty_env(&["GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_SECRET"]);
|
||||
|
||||
if let Some(spacetime_server_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
|
||||
{
|
||||
@@ -3082,6 +3101,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_channel_follows_client_download_channel_unless_overridden() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let previous_snapshot = std::env::var_os("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL");
|
||||
let previous_download = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL");
|
||||
std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
|
||||
}
|
||||
// 未显式配置快照渠道时跟随部署的客户端渠道,缺省是 dev。
|
||||
assert_eq!(AppConfig::from_env().project_snapshot_channel, "dev");
|
||||
unsafe {
|
||||
std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", "release");
|
||||
}
|
||||
assert_eq!(AppConfig::from_env().project_snapshot_channel, "release");
|
||||
// 显式配置优先;空值与非法值同样保留,由快照入口失败关闭。
|
||||
for (value, expected) in [(" qa-2026 ", "qa-2026"), ("", ""), ("Dev", "Dev")] {
|
||||
unsafe {
|
||||
std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value);
|
||||
}
|
||||
assert_eq!(AppConfig::from_env().project_snapshot_channel, expected);
|
||||
}
|
||||
unsafe {
|
||||
match previous_snapshot {
|
||||
Some(value) => std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value),
|
||||
None => std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"),
|
||||
}
|
||||
match previous_download {
|
||||
Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value),
|
||||
None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_prefixed_character_animation_ffmpeg_paths() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -171,6 +171,8 @@ const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image";
|
||||
pub(crate) const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
|
||||
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
|
||||
const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image";
|
||||
/// 项目封面快照资源 kind:项目摘要的封面只取这类资源,不用普通生成图兜底。
|
||||
pub(crate) const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot";
|
||||
const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
|
||||
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
|
||||
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
|
||||
@@ -724,6 +726,93 @@ pub struct EditorProjectListResponse {
|
||||
projects: Vec<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
/// 项目列表视图:`full` 保留历史语义(内联媒体修复 + 画布与全量资源),
|
||||
/// `summary` 只回传项目身份、标题、更新时间和封面摘要。
|
||||
///
|
||||
/// 站内路由与 `/api/external/v1` 共用同一套视图取值;缺省必须保持 `full`,
|
||||
/// 未知取值失败关闭,不能静默回落。
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum EditorProjectListView {
|
||||
#[default]
|
||||
Full,
|
||||
Summary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorProjectListQuery {
|
||||
#[serde(default)]
|
||||
pub(crate) view: EditorProjectListView,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorProjectSummary {
|
||||
pub(crate) project_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) updated_at: String,
|
||||
pub(crate) cover: Option<EditorProjectCoverSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorProjectCoverSummary {
|
||||
pub(crate) resource_id: String,
|
||||
pub(crate) object_key: String,
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorProjectSummaryListResponse {
|
||||
pub(crate) projects: Vec<EditorProjectSummary>,
|
||||
}
|
||||
|
||||
/// 项目摘要只依赖记录本身:封面取最新且存在非空 `objectKey` 的 `project-cover-snapshot`
|
||||
/// 资源,不回传画布、图层与全量资源,也不做内联媒体修复。
|
||||
///
|
||||
/// 需要它的调用方是「只确认项目身份」的消费者(画布绑定前置查询、MCP 项目列表等):
|
||||
/// 账号项目增长后完整列表会带上每个项目的画布与全量资源,既慢又可能超出调用方预算。
|
||||
pub(crate) fn editor_project_summary_from_record(
|
||||
record: EditorProjectRecord,
|
||||
) -> EditorProjectSummary {
|
||||
let cover = record
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|resource| {
|
||||
(resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND))
|
||||
.then_some(resource)
|
||||
.zip(
|
||||
resource
|
||||
.object_key
|
||||
.as_deref()
|
||||
.filter(|object_key| !object_key.trim().is_empty()),
|
||||
)
|
||||
})
|
||||
.max_by(|(left, _), (right, _)| {
|
||||
left.updated_at
|
||||
.cmp(&right.updated_at)
|
||||
.then_with(|| left.resource_id.cmp(&right.resource_id))
|
||||
})
|
||||
.map(|(resource, object_key)| EditorProjectCoverSummary {
|
||||
resource_id: resource.resource_id.clone(),
|
||||
object_key: object_key.to_string(),
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
updated_at: resource.updated_at.clone(),
|
||||
});
|
||||
|
||||
EditorProjectSummary {
|
||||
project_id: record.project_id,
|
||||
title: record.title,
|
||||
updated_at: record.updated_at,
|
||||
cover,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectDeleteResponse {
|
||||
@@ -1932,6 +2021,7 @@ pub async fn get_editor_generation_pricing(
|
||||
|
||||
pub async fn list_editor_projects(
|
||||
State(state): State<EditorProjectState>,
|
||||
Query(query): Query<EditorProjectListQuery>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
@@ -1941,6 +2031,18 @@ pub async fn list_editor_projects(
|
||||
.list_editor_projects(owner_user_id)
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
// 摘要视图按记录直接投影:既不修复内联媒体,也不把画布与全量资源写进响应。
|
||||
if query.view == EditorProjectListView::Summary {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectSummaryListResponse {
|
||||
projects: project_records
|
||||
.into_iter()
|
||||
.map(editor_project_summary_from_record)
|
||||
.collect(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut projects = Vec::with_capacity(project_records.len());
|
||||
for project in project_records {
|
||||
projects.push(editor_project_payload_from_record(
|
||||
|
||||
@@ -258,6 +258,53 @@ async fn narrow_project_reads_preserve_owner_and_use_media_repair_results() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_list_summary_view_skips_media_repair_and_full_payload() {
|
||||
let projects = Arc::new(RecordingProjects::default());
|
||||
let media = Arc::new(RecordingMediaRepair::default());
|
||||
let router = metadata_router(projects.clone(), media.clone());
|
||||
|
||||
let (status, body) = send(&router, "GET", "/projects?view=summary", Value::Null).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let summary = body.pointer("/projects/0").expect("摘要列表应带项目元素");
|
||||
assert_eq!(summary["projectId"], "project-fixture");
|
||||
assert_eq!(summary["title"], "原名称");
|
||||
// 摘要按记录直接投影,因此保留记录原时间,也不会带上画布 / 图层 / 全量资源。
|
||||
assert_eq!(summary["updatedAt"], "0.000000Z");
|
||||
for field in ["canvas", "layers", "resources"] {
|
||||
assert!(summary.get(field).is_none(), "摘要视图不得回传 {field}");
|
||||
}
|
||||
assert!(
|
||||
media.calls.lock().unwrap().is_empty(),
|
||||
"摘要视图不得触发内联媒体修复"
|
||||
);
|
||||
|
||||
let (status, full) = send(&router, "GET", "/projects", Value::Null).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
full.pointer("/projects/0/updatedAt").unwrap(),
|
||||
"media-repaired"
|
||||
);
|
||||
assert_eq!(media.calls.lock().unwrap().len(), 1);
|
||||
|
||||
let unknown_view = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/projects?view=compact")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
unknown_view.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"未知 view 不得静默回落到 full"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_project_writes_preserve_owner_idempotency_and_revision() {
|
||||
let projects = Arc::new(RecordingProjects::default());
|
||||
|
||||
@@ -17,7 +17,7 @@ use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
|
||||
ExternalGenerationJobRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -36,13 +36,14 @@ use crate::{
|
||||
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
|
||||
EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest,
|
||||
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
|
||||
EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse,
|
||||
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
||||
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
||||
editor_asset_payload_from_record, editor_idempotent_create_id,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner,
|
||||
enqueue_editor_image_generation_for_owner,
|
||||
editor_project_summary_from_record, enqueue_editor_background_removal_for_owner,
|
||||
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner,
|
||||
enqueue_editor_ui_design_asset_extraction_for_owner,
|
||||
ensure_generic_editor_image_generation_contract, map_editor_project_error,
|
||||
normalize_editor_persisted_media_src, normalize_optional_string,
|
||||
@@ -71,22 +72,6 @@ const OPENAPI_JSON: &str =
|
||||
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
|
||||
const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500;
|
||||
const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";
|
||||
const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ExternalEditorProjectListView {
|
||||
#[default]
|
||||
Full,
|
||||
Summary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectListQuery {
|
||||
#[serde(default)]
|
||||
view: ExternalEditorProjectListView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -225,31 +210,6 @@ pub struct ExternalEditorProjectListResponse {
|
||||
projects: Vec<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectSummary {
|
||||
project_id: String,
|
||||
title: String,
|
||||
updated_at: String,
|
||||
cover: Option<ExternalEditorProjectCoverSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectCoverSummary {
|
||||
resource_id: String,
|
||||
object_key: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectSummaryListResponse {
|
||||
projects: Vec<ExternalEditorProjectSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectDeleteResponse {
|
||||
@@ -334,7 +294,7 @@ pub async fn create_external_editor_project(
|
||||
|
||||
pub async fn list_external_editor_projects(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ExternalEditorProjectListQuery>,
|
||||
Query(query): Query<EditorProjectListQuery>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
@@ -346,7 +306,7 @@ pub async fn list_external_editor_projects(
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
match query.view {
|
||||
ExternalEditorProjectListView::Full => Ok(json_success_body(
|
||||
EditorProjectListView::Full => Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectListResponse {
|
||||
projects: projects
|
||||
@@ -355,55 +315,18 @@ pub async fn list_external_editor_projects(
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
ExternalEditorProjectListView::Summary => Ok(json_success_body(
|
||||
EditorProjectListView::Summary => Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectSummaryListResponse {
|
||||
EditorProjectSummaryListResponse {
|
||||
projects: projects
|
||||
.into_iter()
|
||||
.map(external_editor_project_summary_from_record)
|
||||
.map(editor_project_summary_from_record)
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_editor_project_summary_from_record(
|
||||
record: EditorProjectRecord,
|
||||
) -> ExternalEditorProjectSummary {
|
||||
let cover = record
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|resource| {
|
||||
(resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND))
|
||||
.then_some(resource)
|
||||
.zip(
|
||||
resource
|
||||
.object_key
|
||||
.as_deref()
|
||||
.filter(|object_key| !object_key.trim().is_empty()),
|
||||
)
|
||||
})
|
||||
.max_by(|(left, _), (right, _)| {
|
||||
left.updated_at
|
||||
.cmp(&right.updated_at)
|
||||
.then_with(|| left.resource_id.cmp(&right.resource_id))
|
||||
})
|
||||
.map(|(resource, object_key)| ExternalEditorProjectCoverSummary {
|
||||
resource_id: resource.resource_id.clone(),
|
||||
object_key: object_key.to_string(),
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
updated_at: resource.updated_at.clone(),
|
||||
});
|
||||
|
||||
ExternalEditorProjectSummary {
|
||||
project_id: record.project_id,
|
||||
title: record.title,
|
||||
updated_at: record.updated_at,
|
||||
cover,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_recent_external_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1244,9 +1167,11 @@ fn serialize_external_editor_image_sequence_frames(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::editor_project::PROJECT_COVER_SNAPSHOT_ASSET_KIND;
|
||||
use axum::{Router, body::Body, routing::post};
|
||||
use spacetime_client::{
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectRecord,
|
||||
EditorProjectResourceRecord,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -1400,29 +1325,29 @@ mod tests {
|
||||
let without_view = "http://localhost/api/external/v1/editor/projects"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&without_view)
|
||||
let Query(query) = Query::<EditorProjectListQuery>::try_from_uri(&without_view)
|
||||
.expect("缺省 view 应保持 full 兼容语义");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Full);
|
||||
assert_eq!(query.view, EditorProjectListView::Full);
|
||||
|
||||
let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&explicit_full)
|
||||
let Query(query) = Query::<EditorProjectListQuery>::try_from_uri(&explicit_full)
|
||||
.expect("显式 full 应合法");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Full);
|
||||
assert_eq!(query.view, EditorProjectListView::Full);
|
||||
|
||||
let summary = "http://localhost/api/external/v1/editor/projects?view=summary"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&summary)
|
||||
.expect("summary 应合法");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Summary);
|
||||
let Query(query) =
|
||||
Query::<EditorProjectListQuery>::try_from_uri(&summary).expect("summary 应合法");
|
||||
assert_eq!(query.view, EditorProjectListView::Summary);
|
||||
|
||||
let unknown = "http://localhost/api/external/v1/editor/projects?view=compact"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
assert!(
|
||||
Query::<ExternalEditorProjectListQuery>::try_from_uri(&unknown).is_err(),
|
||||
Query::<EditorProjectListQuery>::try_from_uri(&unknown).is_err(),
|
||||
"未知 view 不得静默回落到 full"
|
||||
);
|
||||
}
|
||||
@@ -1456,7 +1381,7 @@ mod tests {
|
||||
),
|
||||
]);
|
||||
|
||||
let summary = external_editor_project_summary_from_record(project);
|
||||
let summary = editor_project_summary_from_record(project);
|
||||
let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化");
|
||||
|
||||
assert_eq!(
|
||||
@@ -1496,7 +1421,7 @@ mod tests {
|
||||
),
|
||||
]);
|
||||
|
||||
let summary = external_editor_project_summary_from_record(project);
|
||||
let summary = editor_project_summary_from_record(project);
|
||||
assert_eq!(summary.cover, None);
|
||||
assert_eq!(
|
||||
serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"],
|
||||
@@ -1529,7 +1454,7 @@ mod tests {
|
||||
.expect("完整项目列表应可序列化");
|
||||
let summaries = projects
|
||||
.into_iter()
|
||||
.map(external_editor_project_summary_from_record)
|
||||
.map(editor_project_summary_from_record)
|
||||
.collect::<Vec<_>>();
|
||||
let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod admin;
|
||||
mod admin_accounts;
|
||||
mod admin_project_snapshots;
|
||||
mod admin_recharge;
|
||||
mod admin_templates;
|
||||
mod agc_models;
|
||||
mod ai_tasks;
|
||||
mod aliyun_matting;
|
||||
|
||||
@@ -43,10 +43,31 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/admin/api/project-snapshots",
|
||||
get(crate::admin_project_snapshots::admin_list_project_snapshots),
|
||||
),
|
||||
(
|
||||
"/admin/api/project-snapshots/channels",
|
||||
get(crate::admin_project_snapshots::admin_list_project_snapshot_channels),
|
||||
),
|
||||
(
|
||||
"/admin/api/project-snapshots/{user_id}/{project_id}/download",
|
||||
get(crate::admin_project_snapshots::admin_download_project_snapshot),
|
||||
),
|
||||
(
|
||||
"/admin/api/agc-templates",
|
||||
get(crate::admin_templates::admin_list_agc_templates),
|
||||
),
|
||||
(
|
||||
"/admin/api/agc-templates/{id}",
|
||||
axum::routing::put(crate::admin_templates::admin_update_agc_template)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)),
|
||||
),
|
||||
(
|
||||
"/admin/api/agc-templates/import",
|
||||
axum::routing::post(crate::admin_templates::admin_import_agc_templates).layer(
|
||||
axum::extract::DefaultBodyLimit::max(
|
||||
crate::admin_templates::AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES,
|
||||
),
|
||||
),
|
||||
),
|
||||
(
|
||||
"/admin/api/agc-models",
|
||||
get(crate::agc_models::admin_get_agc_models)
|
||||
@@ -213,10 +234,14 @@ mod route_contract_tests {
|
||||
|
||||
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
|
||||
("/admin/api/project-snapshots", &["GET"]),
|
||||
("/admin/api/project-snapshots/channels", &["GET"]),
|
||||
(
|
||||
"/admin/api/project-snapshots/{user_id}/{project_id}/download",
|
||||
&["GET"],
|
||||
),
|
||||
("/admin/api/agc-templates", &["GET"]),
|
||||
("/admin/api/agc-templates/{id}", &["PUT"]),
|
||||
("/admin/api/agc-templates/import", &["POST"]),
|
||||
("/admin/api/agc-models", &["GET", "PUT"]),
|
||||
("/admin/api/accounts", &["GET", "POST"]),
|
||||
("/admin/api/accounts/{account_id}", &["PUT"]),
|
||||
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
use platform_oss::{
|
||||
OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess,
|
||||
agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key,
|
||||
validate_agc_project_snapshot_channel,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::agc_project_snapshots::{
|
||||
@@ -67,7 +68,9 @@ pub async fn upload_project_snapshot_file(
|
||||
.strip_prefix("fnv1a64:")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let channel = project_snapshot_channel(&state)?;
|
||||
let object_key = agc_project_snapshot_file_object_key(
|
||||
&channel,
|
||||
auth.claims().user_id(),
|
||||
&query.project_id,
|
||||
size_bytes,
|
||||
@@ -146,8 +149,10 @@ pub async fn upload_project_snapshot_manifest(
|
||||
consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?;
|
||||
validate_manifest(&payload)?;
|
||||
let user_id = auth.claims().user_id().to_string();
|
||||
let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id)
|
||||
.map_err(|error| bad_request(error.to_string()))?;
|
||||
let channel = project_snapshot_channel(&state)?;
|
||||
let object_key =
|
||||
agc_project_snapshot_manifest_object_key(&channel, &user_id, &payload.project_id)
|
||||
.map_err(|error| bad_request(error.to_string()))?;
|
||||
// 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。
|
||||
// 读不到或解析失败时只跳过回收,绝不据此删除任何对象。
|
||||
let previous = read_project_snapshot_manifest(&state, &object_key).await;
|
||||
@@ -181,7 +186,8 @@ pub async fn upload_project_snapshot_manifest(
|
||||
})?;
|
||||
// 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集,
|
||||
// 不会出现清单引用了刚被删掉的对象。
|
||||
reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await;
|
||||
reclaim_unreferenced_objects(&state, oss, &channel, &user_id, previous.as_ref(), &payload)
|
||||
.await;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
@@ -293,6 +299,7 @@ async fn read_project_snapshot_manifest(
|
||||
async fn reclaim_unreferenced_objects(
|
||||
state: &AppState,
|
||||
oss: &platform_oss::OssClient,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
previous: Option<&AgcProjectSnapshotManifestRequest>,
|
||||
next: &AgcProjectSnapshotManifestRequest,
|
||||
@@ -330,6 +337,7 @@ async fn reclaim_unreferenced_objects(
|
||||
continue;
|
||||
};
|
||||
let Ok(object_key) = agc_project_snapshot_file_object_key(
|
||||
channel,
|
||||
user_id,
|
||||
&previous.project_id,
|
||||
file.size_bytes,
|
||||
@@ -433,6 +441,15 @@ pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::Os
|
||||
})
|
||||
}
|
||||
|
||||
/// 本部署的快照渠道:配置文件缺省跟随客户端下载渠道,显式配置优先。
|
||||
/// 渠道名非法时失败关闭(503),不静默改写到其它渠道。
|
||||
pub(crate) fn project_snapshot_channel(state: &AppState) -> Result<String, AppError> {
|
||||
validate_agc_project_snapshot_channel(&state.config.project_snapshot_channel).map_err(|_| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("AGC 项目快照渠道配置无效")
|
||||
})
|
||||
}
|
||||
|
||||
fn bad_request(m: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(m)
|
||||
}
|
||||
@@ -567,4 +584,26 @@ mod tests {
|
||||
consume_user_upload_quota(user, ProjectSnapshotUploadKind::Manifest)
|
||||
.expect("清单配额独立计数");
|
||||
}
|
||||
|
||||
/// 渠道决定对象键的第一层目录:非法配置必须失败关闭,不能落到别的渠道。
|
||||
#[test]
|
||||
fn project_snapshot_channel_fails_closed_on_invalid_configuration() {
|
||||
use crate::{config::AppConfig, state::AppState};
|
||||
|
||||
let mut config = AppConfig::default();
|
||||
config.project_snapshot_channel = "release".to_string();
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
assert_eq!(
|
||||
project_snapshot_channel(&state).expect("valid channel"),
|
||||
"release"
|
||||
);
|
||||
|
||||
for invalid in ["", " Dev", "dev/2", "dev-"] {
|
||||
let mut config = AppConfig::default();
|
||||
config.project_snapshot_channel = invalid.to_string();
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let error = project_snapshot_channel(&state).expect_err("非法渠道必须失败关闭");
|
||||
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ use platform_auth::{
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField};
|
||||
use platform_matting::{MattingClient, MattingConfig};
|
||||
use platform_oss::template_library::TemplateLibraryStore;
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
|
||||
#[cfg(test)]
|
||||
@@ -281,6 +282,7 @@ pub struct AppStateInner {
|
||||
oss_client: Option<OssClient>,
|
||||
/// AGC 项目快照专用 OSS 客户端:bucket 与凭据可以独立于资源 bucket。
|
||||
project_snapshot_oss_client: Option<OssClient>,
|
||||
template_library_store: Option<TemplateLibraryStore>,
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
auth_store: InMemoryAuthStore,
|
||||
/// 当前进程工作集所基于的正式认证投影版本;跨节点写入使用它做 CAS。
|
||||
@@ -561,6 +563,7 @@ impl AppState {
|
||||
)?;
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let project_snapshot_oss_client = build_project_snapshot_oss_client(&config)?;
|
||||
let template_library_store = build_template_library_store(&config);
|
||||
let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new(
|
||||
SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| {
|
||||
SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string())
|
||||
@@ -687,6 +690,7 @@ impl AppState {
|
||||
test_external_background_removal_enqueue: Arc::new(Mutex::new(None)),
|
||||
oss_client,
|
||||
project_snapshot_oss_client,
|
||||
template_library_store,
|
||||
auth_store,
|
||||
auth_projection_version: AtomicI64::new(auth_projection_version),
|
||||
auth_projection_synced_revision: AtomicU64::new(initial_auth_store_revision),
|
||||
@@ -1393,6 +1397,10 @@ impl AppState {
|
||||
self.project_snapshot_oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn template_library_store(&self) -> Option<&TemplateLibraryStore> {
|
||||
self.template_library_store.as_ref()
|
||||
}
|
||||
|
||||
pub fn password_entry_service(&self) -> &PasswordEntryService {
|
||||
&self.password_entry_service
|
||||
}
|
||||
@@ -2394,6 +2402,47 @@ impl AdminRuntime {
|
||||
/// 目标 bucket 独立于资源 bucket:专用凭据未配置时回退 `ALIYUN_OSS_*`,而 bucket 与
|
||||
/// endpoint 默认指向 AGC 发行 bucket。凭据缺失或只配置一半时返回 `None`;路由层把
|
||||
/// "未配置" 当作失败关闭,不写空对象也不推进客户端索引。
|
||||
fn build_template_library_store(config: &AppConfig) -> Option<TemplateLibraryStore> {
|
||||
let dedicated = config.template_library_oss_access_key_id.is_some()
|
||||
|| config.template_library_oss_access_key_secret.is_some();
|
||||
let (id, secret) = if dedicated {
|
||||
(
|
||||
config.template_library_oss_access_key_id.as_deref(),
|
||||
config.template_library_oss_access_key_secret.as_deref(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
config.oss_access_key_id.as_deref(),
|
||||
config.oss_access_key_secret.as_deref(),
|
||||
)
|
||||
};
|
||||
let (Some(id), Some(secret)) = (id, secret) else {
|
||||
if dedicated {
|
||||
warn!("模板库独立凭据不完整,后台模板管理仅提供只读能力");
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if id.trim().is_empty() || secret.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let result = OssConfig::new(
|
||||
"agc-dev".to_string(),
|
||||
"oss-rg-china-mainland.aliyuncs.com".to_string(),
|
||||
id.trim().to_string(),
|
||||
secret.to_string(),
|
||||
config.oss_read_expire_seconds,
|
||||
config.oss_post_expire_seconds,
|
||||
config.oss_post_max_size_bytes,
|
||||
config.oss_success_action_status,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|config| TemplateLibraryStore::new(OssClient::new(config)).ok());
|
||||
if result.is_none() {
|
||||
warn!("模板库存储配置不可用,后台模板管理仅提供只读能力");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn build_project_snapshot_oss_client(
|
||||
config: &AppConfig,
|
||||
) -> Result<Option<OssClient>, AppStateInitError> {
|
||||
@@ -2741,6 +2790,22 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn template_library_credentials_never_mix_dedicated_and_general_pairs() {
|
||||
let mut config = AppConfig::default();
|
||||
assert!(build_template_library_store(&config).is_none());
|
||||
config.oss_access_key_id = Some("general-id".to_string());
|
||||
config.oss_access_key_secret = Some("general-secret".to_string());
|
||||
config.oss_bucket = Some("unrelated-assets-bucket".to_string());
|
||||
assert!(build_template_library_store(&config).is_some());
|
||||
config.template_library_oss_access_key_id = Some("dedicated-id".to_string());
|
||||
assert!(build_template_library_store(&config).is_none());
|
||||
config.template_library_oss_access_key_secret = Some("dedicated-secret".to_string());
|
||||
assert!(build_template_library_store(&config).is_some());
|
||||
config.template_library_oss_access_key_id = None;
|
||||
assert!(build_template_library_store(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_summaries_redact_all_runtime_credentials() {
|
||||
const SENSITIVE_KEY_LURE: &str = "ISSUE_148_DEBUG_SECRET_LURE";
|
||||
@@ -2751,6 +2816,8 @@ mod tests {
|
||||
editor_bgfilter_token: secret(),
|
||||
aliyun_matting_access_key_id: secret(),
|
||||
aliyun_matting_access_key_secret: secret(),
|
||||
template_library_oss_access_key_id: secret(),
|
||||
template_library_oss_access_key_secret: secret(),
|
||||
admin_username: Some("debug-admin".to_string()),
|
||||
admin_password: secret(),
|
||||
internal_api_secret: secret(),
|
||||
|
||||
@@ -11,6 +11,7 @@ spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls-tls"], optional = true }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
platform-oss = { workspace = true, optional = true }
|
||||
|
||||
@@ -3,6 +3,7 @@ mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
pub mod template_library;
|
||||
|
||||
mod asset_object_core;
|
||||
#[cfg(feature = "server-service")]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
//! cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml
|
||||
//! ```
|
||||
//!
|
||||
//! 冒烟只写入 `agc/project-snapshots/v1/` 下的固定探针对象,并在结束时删除;
|
||||
//! 冒烟只写入 `agc/project-snapshots/v2/dev/` 下的固定探针对象,并在结束时删除;
|
||||
//! 任何一步失败都会打印 `[FAIL]` 并以非 0 退出码结束,方便 CI 或人工判定。
|
||||
|
||||
use std::{
|
||||
@@ -29,6 +29,7 @@ const DEFAULT_BUCKET: &str = "agc-dev";
|
||||
const DEFAULT_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com";
|
||||
const SMOKE_USER_ID: &str = "smoke-user";
|
||||
const SMOKE_PROJECT_ID: &str = "smoke-project";
|
||||
const SMOKE_CHANNEL: &str = "dev";
|
||||
const SMOKE_RELATIVE_PATH: &str = "smoke/README.txt";
|
||||
const SMOKE_BODY: &[u8] = b"agc project snapshot live smoke\n";
|
||||
const SMOKE_CHECKSUM_DIGEST: &str = "0123456789abcdef";
|
||||
@@ -117,6 +118,7 @@ async fn run() -> SmokeResult<()> {
|
||||
|
||||
// 3. 项目快照文件键:写入 → 读回。
|
||||
let file_key = agc_project_snapshot_file_object_key(
|
||||
SMOKE_CHANNEL,
|
||||
SMOKE_USER_ID,
|
||||
SMOKE_PROJECT_ID,
|
||||
SMOKE_BODY.len() as u64,
|
||||
@@ -124,8 +126,9 @@ async fn run() -> SmokeResult<()> {
|
||||
SMOKE_RELATIVE_PATH,
|
||||
)
|
||||
.map_err(|error| format!("构造项目快照文件键失败({})", oss_error_label(&error)))?;
|
||||
let manifest_key = agc_project_snapshot_manifest_object_key(SMOKE_USER_ID, SMOKE_PROJECT_ID)
|
||||
.map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?;
|
||||
let manifest_key =
|
||||
agc_project_snapshot_manifest_object_key(SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID)
|
||||
.map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?;
|
||||
|
||||
let result = write_and_verify(&client, &http, &bucket, &file_key, &manifest_key).await;
|
||||
for key in [&file_key, &manifest_key] {
|
||||
|
||||
@@ -13,6 +13,7 @@ use tracing::{info, warn};
|
||||
|
||||
pub mod client_downloads;
|
||||
pub mod project_snapshots;
|
||||
pub mod template_library;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -2037,44 +2038,69 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result<String, OssEr
|
||||
/// AGC 内部对象前缀:只允许服务端写入,客户端直传票据、公开对象键与 legacy
|
||||
/// 公开路径都不覆盖这些前缀。
|
||||
pub const AGC_ERROR_REPORTS_INTERNAL_PREFIX: &str = "agc/error-reports/v1/";
|
||||
pub const AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX: &str = "agc/project-snapshots/v1/";
|
||||
/// 项目快照当前前缀:第二层是部署渠道,读写都只落在本部署渠道下。
|
||||
pub const AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX: &str = "agc/project-snapshots/v2/";
|
||||
/// 无渠道的历史项目快照前缀:不再写入、不再枚举,但仍必须保持服务端私有。
|
||||
pub const AGC_PROJECT_SNAPSHOT_LEGACY_INTERNAL_PREFIX: &str = "agc/project-snapshots/v1/";
|
||||
|
||||
const AGC_INTERNAL_OBJECT_PREFIXES: [&str; 2] = [
|
||||
const AGC_INTERNAL_OBJECT_PREFIXES: [&str; 3] = [
|
||||
AGC_ERROR_REPORTS_INTERNAL_PREFIX,
|
||||
AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX,
|
||||
AGC_PROJECT_SNAPSHOT_LEGACY_INTERNAL_PREFIX,
|
||||
];
|
||||
|
||||
/// 项目快照渠道名:与 AGC 客户端更新渠道同形(小写字母开头,只含小写字母、
|
||||
/// 数字与连字符)。渠道在对象键里是第一层目录,非法值直接拒绝而不是回落。
|
||||
pub fn validate_agc_project_snapshot_channel(raw: &str) -> Result<String, OssError> {
|
||||
let allowed = !raw.is_empty()
|
||||
&& raw.len() <= 32
|
||||
&& raw.as_bytes()[0].is_ascii_lowercase()
|
||||
&& raw
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !raw.ends_with('-');
|
||||
if !allowed {
|
||||
return Err(OssError::InvalidRequest("项目快照渠道名非法".to_string()));
|
||||
}
|
||||
Ok(raw.to_string())
|
||||
}
|
||||
|
||||
/// 项目快照文件对象键:
|
||||
/// `agc/project-snapshots/v1/{user}/{project}/files/{size}-{digest}/{relativePath}`。
|
||||
/// `agc/project-snapshots/v2/{channel}/{user}/{project}/files/{size}-{digest}/{relativePath}`。
|
||||
///
|
||||
/// 键里同时带字节数与内容摘要,既让同一内容重复提交落在同一个对象上,也让
|
||||
/// "对象已存在且长度一致" 可以作为内容一致的判据;相对路径按原始大小写保留,
|
||||
/// 不走 `put_object` 的低位规范化。
|
||||
pub fn agc_project_snapshot_file_object_key(
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
size_bytes: u64,
|
||||
checksum_digest: &str,
|
||||
relative_path: &str,
|
||||
) -> Result<String, OssError> {
|
||||
let channel = validate_agc_project_snapshot_channel(channel)?;
|
||||
let user = validate_internal_key_segment(user_id, "用户标识")?;
|
||||
let project = validate_internal_key_segment(project_id, "项目标识")?;
|
||||
let digest = validate_internal_checksum_digest(checksum_digest)?;
|
||||
let relative_path = validate_internal_relative_path(relative_path)?;
|
||||
Ok(format!(
|
||||
"{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/files/{size_bytes}-{digest}/{relative_path}"
|
||||
"{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/files/{size_bytes}-{digest}/{relative_path}"
|
||||
))
|
||||
}
|
||||
|
||||
/// 项目快照清单对象键:`agc/project-snapshots/v1/{user}/{project}/manifest.json`。
|
||||
/// 项目快照清单对象键:
|
||||
/// `agc/project-snapshots/v2/{channel}/{user}/{project}/manifest.json`。
|
||||
pub fn agc_project_snapshot_manifest_object_key(
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<String, OssError> {
|
||||
let channel = validate_agc_project_snapshot_channel(channel)?;
|
||||
let user = validate_internal_key_segment(user_id, "用户标识")?;
|
||||
let project = validate_internal_key_segment(project_id, "项目标识")?;
|
||||
Ok(format!(
|
||||
"{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/manifest.json"
|
||||
"{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/manifest.json"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2615,14 +2641,20 @@ fn build_v4_additional_headers(headers: &BTreeMap<String, String>) -> String {
|
||||
}
|
||||
|
||||
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
||||
params
|
||||
let mut encoded = params
|
||||
.iter()
|
||||
.map(|(key, value)| (encode_url_query_value(key), encode_url_query_value(value)))
|
||||
.collect::<Vec<_>>();
|
||||
encoded.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
encoded
|
||||
.into_iter()
|
||||
// OSS V4 的空值子资源只保留名称,例如 versioning;不套用 S3 的尾随等号。
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
encode_url_query_value(key),
|
||||
encode_url_query_value(value)
|
||||
)
|
||||
if value.is_empty() {
|
||||
key
|
||||
} else {
|
||||
format!("{key}={value}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
@@ -3681,7 +3713,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyAssetPrefix::from_object_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/manifest.json"
|
||||
"agc/project-snapshots/v2/dev/user-1/project-1/manifest.json"
|
||||
),
|
||||
None,
|
||||
"AGC 内部前缀不能经由通用对象键解析变成客户端可写前缀"
|
||||
@@ -3709,6 +3741,7 @@ mod tests {
|
||||
#[test]
|
||||
fn agc_project_snapshot_object_keys_preserve_case_and_reject_traversal() {
|
||||
let file_key = agc_project_snapshot_file_object_key(
|
||||
"dev",
|
||||
"user-1",
|
||||
"gameagent-1a2b3c4d",
|
||||
1234,
|
||||
@@ -3718,12 +3751,12 @@ mod tests {
|
||||
.expect("file key");
|
||||
assert_eq!(
|
||||
file_key,
|
||||
"agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML"
|
||||
"agc/project-snapshots/v2/dev/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML"
|
||||
);
|
||||
assert_eq!(
|
||||
agc_project_snapshot_manifest_object_key("user-1", "gameagent-1a2b3c4d")
|
||||
agc_project_snapshot_manifest_object_key("release", "user-1", "gameagent-1a2b3c4d")
|
||||
.expect("manifest key"),
|
||||
"agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/manifest.json"
|
||||
"agc/project-snapshots/v2/release/user-1/gameagent-1a2b3c4d/manifest.json"
|
||||
);
|
||||
|
||||
for (user, project, path) in [
|
||||
@@ -3734,20 +3767,64 @@ mod tests {
|
||||
("user-1", "project-1", "game\\index.html"),
|
||||
] {
|
||||
assert!(
|
||||
agc_project_snapshot_file_object_key(user, project, 1, "abcdef", path).is_err(),
|
||||
agc_project_snapshot_file_object_key("dev", user, project, 1, "abcdef", path)
|
||||
.is_err(),
|
||||
"越界键片段必须被拒绝:{user} {project} {path}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
agc_project_snapshot_file_object_key("user-1", "project-1", 1, "not-hex", "game/a.txt")
|
||||
.is_err(),
|
||||
agc_project_snapshot_file_object_key(
|
||||
"dev",
|
||||
"user-1",
|
||||
"project-1",
|
||||
1,
|
||||
"not-hex",
|
||||
"game/a.txt"
|
||||
)
|
||||
.is_err(),
|
||||
"摘要必须是十六进制"
|
||||
);
|
||||
}
|
||||
|
||||
/// 渠道是对象键的第一层:非法渠道必须失败关闭,不能悄悄换成一个默认渠道。
|
||||
#[test]
|
||||
fn agc_project_snapshot_channel_is_validated_before_it_reaches_the_key() {
|
||||
assert_eq!(
|
||||
validate_agc_project_snapshot_channel("release").expect("channel"),
|
||||
"release"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_agc_project_snapshot_channel("dev-internal-2").expect("channel"),
|
||||
"dev-internal-2"
|
||||
);
|
||||
for invalid in [
|
||||
"",
|
||||
" dev",
|
||||
"dev ",
|
||||
"Dev",
|
||||
"dev_internal",
|
||||
"dev/internal",
|
||||
"-dev",
|
||||
"dev-",
|
||||
"2dev",
|
||||
"dev.",
|
||||
&"d".repeat(33),
|
||||
] {
|
||||
assert!(
|
||||
validate_agc_project_snapshot_channel(invalid).is_err(),
|
||||
"非法渠道必须被拒绝:{invalid}"
|
||||
);
|
||||
assert!(
|
||||
agc_project_snapshot_manifest_object_key(invalid, "user-1", "project-1").is_err(),
|
||||
"非法渠道不能进入对象键:{invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_object_prefixes_cover_agc_snapshots_but_reject_everything_else() {
|
||||
let file_key = agc_project_snapshot_file_object_key(
|
||||
"dev",
|
||||
"user-1",
|
||||
"project-1",
|
||||
7,
|
||||
@@ -3759,6 +3836,14 @@ mod tests {
|
||||
normalize_internal_object_key(&file_key).expect("snapshot key is internal"),
|
||||
file_key
|
||||
);
|
||||
// 无渠道的历史项目快照对象仍然必须保持服务端私有。
|
||||
assert_eq!(
|
||||
normalize_internal_object_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/manifest.json"
|
||||
)
|
||||
.expect("legacy snapshot key stays internal"),
|
||||
"agc/project-snapshots/v1/user-1/project-1/manifest.json"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_internal_object_key("agc/error-reports/v1/batch.zip")
|
||||
.expect("error report key stays internal"),
|
||||
|
||||
@@ -37,7 +37,7 @@ fn directory_segment(value: &str) -> Result<String, OssError> {
|
||||
}
|
||||
|
||||
fn list_query(
|
||||
user_id: Option<&str>,
|
||||
prefix: &str,
|
||||
after: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<BTreeMap<String, String>, OssError> {
|
||||
@@ -46,15 +46,8 @@ fn list_query(
|
||||
"项目目录分页大小必须为 1 到 100".to_string(),
|
||||
));
|
||||
}
|
||||
let prefix = match user_id {
|
||||
Some(user) => format!(
|
||||
"{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{}/",
|
||||
directory_segment(user)?
|
||||
),
|
||||
None => AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX.to_string(),
|
||||
};
|
||||
let mut query = BTreeMap::from([
|
||||
("prefix".to_string(), prefix.clone()),
|
||||
("prefix".to_string(), prefix.to_string()),
|
||||
("delimiter".to_string(), "/".to_string()),
|
||||
("max-keys".to_string(), limit.to_string()),
|
||||
]);
|
||||
@@ -67,6 +60,12 @@ fn list_query(
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
/// 渠道根前缀:`agc/project-snapshots/v2/{channel}/`。
|
||||
fn channel_root_prefix(channel: &str) -> Result<String, OssError> {
|
||||
let channel = validate_agc_project_snapshot_channel(channel)?;
|
||||
Ok(format!("{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/"))
|
||||
}
|
||||
|
||||
fn parse_directory_page(
|
||||
body: &[u8],
|
||||
query: &BTreeMap<String, String>,
|
||||
@@ -119,15 +118,40 @@ fn parse_directory_page(
|
||||
}
|
||||
|
||||
impl OssClient {
|
||||
/// 第一层只列用户目录,第二层只列项目目录,不枚举文件对象或其它 bucket 前缀。
|
||||
/// 在本部署渠道下逐层列目录:`{channel}/` 下只列用户,用户下只列项目,
|
||||
/// 不枚举文件对象,也不跨渠道。
|
||||
pub async fn list_project_snapshot_directories(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
channel: &str,
|
||||
user_id: Option<&str>,
|
||||
after: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SnapshotDirectoryPage, OssError> {
|
||||
let query = list_query(user_id, after, limit)?;
|
||||
let mut prefix = channel_root_prefix(channel)?;
|
||||
if let Some(user) = user_id {
|
||||
prefix.push_str(&format!("{}/", directory_segment(user)?));
|
||||
}
|
||||
self.list_project_snapshot_prefix(client, list_query(&prefix, after, limit)?)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 列出远端已存在的渠道目录:只在 v2 根下取第一层,不进入用户或项目。
|
||||
pub async fn list_project_snapshot_channels(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
after: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SnapshotDirectoryPage, OssError> {
|
||||
let query = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, after, limit)?;
|
||||
self.list_project_snapshot_prefix(client, query).await
|
||||
}
|
||||
|
||||
async fn list_project_snapshot_prefix(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
query: BTreeMap<String, String>,
|
||||
) -> Result<SnapshotDirectoryPage, OssError> {
|
||||
let mut target = build_object_url(&self.config.bucket, &self.config.endpoint, "")
|
||||
.map_err(OssError::InvalidRequest)?;
|
||||
target.set_query(Some(&build_canonical_query_string(&query)));
|
||||
@@ -177,11 +201,12 @@ pub(super) fn is_snapshot_file_key(key: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
let parts = path.split('/').collect::<Vec<_>>();
|
||||
parts.len() >= 5
|
||||
&& directory_segment(parts[0]).is_ok()
|
||||
parts.len() >= 6
|
||||
&& validate_agc_project_snapshot_channel(parts[0]).is_ok()
|
||||
&& directory_segment(parts[1]).is_ok()
|
||||
&& parts[2] == "files"
|
||||
&& parts[3].split_once('-').is_some_and(|(size, digest)| {
|
||||
&& directory_segment(parts[2]).is_ok()
|
||||
&& parts[3] == "files"
|
||||
&& parts[4].split_once('-').is_some_and(|(size, digest)| {
|
||||
size == "0" && digest.eq_ignore_ascii_case("cbf29ce484222325")
|
||||
})
|
||||
}
|
||||
@@ -192,7 +217,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn snapshot_object_url_preserves_literal_reserved_characters() {
|
||||
let key = "agc/project-snapshots/v1/user/project/files/1-abcd/game/图像 #100%.txt";
|
||||
let key = "agc/project-snapshots/v2/dev/user/project/files/1-abcd/game/图像 #100%.txt";
|
||||
let url = build_object_url("bucket", "oss-cn-shanghai.aliyuncs.com", key).unwrap();
|
||||
assert_eq!(url.fragment(), None);
|
||||
assert_eq!(url.query(), None);
|
||||
@@ -202,22 +227,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn snapshot_directory_query_is_prefix_scoped_and_bounded() {
|
||||
let query = list_query(Some("user-1"), Some("project-1"), 20).unwrap();
|
||||
assert_eq!(query["prefix"], "agc/project-snapshots/v1/user-1/");
|
||||
assert_eq!(
|
||||
query["marker"],
|
||||
"agc/project-snapshots/v1/user-1/project-1/"
|
||||
);
|
||||
let prefix = channel_root_prefix("dev").unwrap();
|
||||
assert_eq!(prefix, "agc/project-snapshots/v2/dev/");
|
||||
let query = list_query(&prefix, Some("project-1"), 20).unwrap();
|
||||
assert_eq!(query["prefix"], "agc/project-snapshots/v2/dev/");
|
||||
assert_eq!(query["marker"], "agc/project-snapshots/v2/dev/project-1/");
|
||||
assert_eq!(query["delimiter"], "/");
|
||||
assert!(list_query(Some("../user"), None, 20).is_err());
|
||||
assert!(list_query(None, Some("escape/path"), 20).is_err());
|
||||
assert!(list_query(None, None, 101).is_err());
|
||||
assert!(list_query(&prefix, Some("escape/path"), 20).is_err());
|
||||
assert!(list_query(&prefix, None, 101).is_err());
|
||||
assert!(channel_root_prefix("../escape").is_err());
|
||||
assert!(channel_root_prefix("Dev").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_directory_page_decodes_only_direct_children_and_advancing_cursor() {
|
||||
let query = list_query(Some("user-1"), None, 20).unwrap();
|
||||
let xml = br#"<ListBucketResult><Prefix>agc/project-snapshots/v1/user-1/</Prefix><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><NextMarker>agc/project-snapshots/v1/user-1/project-1/</NextMarker><CommonPrefixes><Prefix>agc/project-snapshots/v1/user-1/project-1/</Prefix></CommonPrefixes></ListBucketResult>"#;
|
||||
let query = list_query("agc/project-snapshots/v2/dev/user-1/", None, 20).unwrap();
|
||||
// 渠道枚举只读 v2 根的第一层,不进入任何渠道目录内部。
|
||||
let channels = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, Some("dev"), 20).unwrap();
|
||||
assert_eq!(channels["prefix"], "agc/project-snapshots/v2/");
|
||||
assert_eq!(channels["marker"], "agc/project-snapshots/v2/dev/");
|
||||
let xml = br#"<ListBucketResult><Prefix>agc/project-snapshots/v2/dev/user-1/</Prefix><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><NextMarker>agc/project-snapshots/v2/dev/user-1/project-1/</NextMarker><CommonPrefixes><Prefix>agc/project-snapshots/v2/dev/user-1/project-1/</Prefix></CommonPrefixes></ListBucketResult>"#;
|
||||
let page = parse_directory_page(xml, &query).unwrap();
|
||||
assert_eq!(page.directories, ["project-1"]);
|
||||
assert_eq!(page.next_marker.as_deref(), Some("project-1"));
|
||||
@@ -238,14 +267,18 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_internal_put_is_only_allowed_for_empty_snapshot_content_key() {
|
||||
assert!(is_snapshot_file_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt"
|
||||
"agc/project-snapshots/v2/dev/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt"
|
||||
));
|
||||
assert!(!is_snapshot_file_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/manifest.json"
|
||||
"agc/project-snapshots/v2/dev/user-1/project-1/manifest.json"
|
||||
));
|
||||
assert!(!is_snapshot_file_key("agc/error-reports/v1/report.zip"));
|
||||
assert!(!is_snapshot_file_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/files/1-cbf29ce484222325/game/file.txt"
|
||||
"agc/project-snapshots/v2/dev/user-1/project-1/files/1-cbf29ce484222325/game/file.txt"
|
||||
));
|
||||
// 历史(无渠道)布局的对象键不是当前快照文件键。
|
||||
assert!(!is_snapshot_file_key(
|
||||
"agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -461,6 +461,9 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.to_bytes();
|
||||
let list: Value = serde_json::from_slice(&list_body).unwrap();
|
||||
assert_eq!(list["deployments"], json!([]));
|
||||
wait_for_persisted_status(&state.config, &id, super::DeploymentStatus::Stopped)
|
||||
.await
|
||||
.expect("persisted deployment reaches stopped");
|
||||
let state_file = state.config.state_file.clone();
|
||||
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
|
||||
recovered_config.state_file = state_file.clone();
|
||||
@@ -659,6 +662,24 @@ async fn wait_for_status(
|
||||
Err(())
|
||||
}
|
||||
|
||||
async fn wait_for_persisted_status(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
expected: super::DeploymentStatus,
|
||||
) -> Result<(), ()> {
|
||||
for _ in 0..100 {
|
||||
if super::load_deployments(config)
|
||||
.ok()
|
||||
.and_then(|deployments| deployments.get(id).map(|record| record.public.status))
|
||||
.is_some_and(|status| status == expected)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_web_url_is_derived_from_instance_id_and_configured_domain() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::creation_entry_config::{
|
||||
};
|
||||
|
||||
/// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。
|
||||
pub const ADMIN_TAB_PERMISSIONS: [&str; 17] = [
|
||||
pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [
|
||||
"dashboard",
|
||||
"overview",
|
||||
"tables",
|
||||
@@ -26,6 +26,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 17] = [
|
||||
"editor-generation-pricing",
|
||||
"editor-showcase",
|
||||
"editor-assets",
|
||||
"agc-templates",
|
||||
"error-reports",
|
||||
"project-snapshots",
|
||||
];
|
||||
@@ -36,6 +37,26 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 17] = [
|
||||
pub struct AdminProjectSnapshotsQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
/// 目标渠道;缺省用本部署渠道。
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
/// 后台可查看的快照渠道列表。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminProjectSnapshotChannelsResponse {
|
||||
/// 本部署渠道:上传与默认查询都用它。
|
||||
pub default_channel: String,
|
||||
/// 本部署渠道与远端已存在渠道的并集(升序,去重)。
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
|
||||
/// 私有工程快照下载查询:只接受渠道。
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminProjectSnapshotDownloadQuery {
|
||||
/// 目标渠道;缺省用本部署渠道。
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -58,6 +79,14 @@ pub struct AdminProjectSnapshotItem {
|
||||
pub file_count: u32,
|
||||
pub total_bytes: u64,
|
||||
pub status: AdminProjectSnapshotStatus,
|
||||
/// 项目所在渠道。
|
||||
pub channel: String,
|
||||
/// 项目归属用户昵称,与素材查询同口径;账号不可解析时为占位作者。
|
||||
#[serde(default)]
|
||||
pub author_display_name: Option<String>,
|
||||
/// 项目归属用户陶泥号,与素材查询同口径;账号不可解析时为占位账号。
|
||||
#[serde(default)]
|
||||
pub author_public_user_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -87,6 +116,107 @@ pub struct AdminLoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// AGC 模板管理快照;revision 对应读取到的完整 OSS 清单字节。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAgcTemplateListResponse {
|
||||
pub revision: String,
|
||||
pub writable: bool,
|
||||
pub templates: Vec<AdminAgcTemplatePayload>,
|
||||
}
|
||||
|
||||
/// 合并上架与下架条目的后台展示投影。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAgcTemplatePayload {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub runtime: String,
|
||||
pub engine: String,
|
||||
pub engine_version: String,
|
||||
pub template_version: String,
|
||||
pub enabled: bool,
|
||||
pub cover_url: String,
|
||||
pub zip_size_bytes: u64,
|
||||
}
|
||||
|
||||
/// 仅修改模板展示字段及上下架状态,不接受包、引擎或版本字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminUpdateAgcTemplateRequest {
|
||||
pub expected_revision: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cover: Option<AdminAgcTemplateCoverInput>,
|
||||
}
|
||||
|
||||
/// 待保存的封面原始图片;格式、字节和尺寸由后端验证。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminAgcTemplateCoverInput {
|
||||
pub content_type: String,
|
||||
pub data_base64: String,
|
||||
}
|
||||
|
||||
/// 后台批量导入模板的 manifest(`multipart/form-data` 的 `manifest` 文本字段)。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminImportAgcTemplatesManifest {
|
||||
/// 上传前读到的清单字节摘要,锁内用于 CAS;过期返回 409。
|
||||
pub expected_revision: String,
|
||||
pub templates: Vec<AdminImportAgcTemplateItem>,
|
||||
}
|
||||
|
||||
/// 单条导入模板:ZIP 与可选封面通过字段名引用同一请求里的文件字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminImportAgcTemplateItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub runtime: String,
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
#[serde(default)]
|
||||
pub engine_version: String,
|
||||
pub template_version: String,
|
||||
pub entry: String,
|
||||
/// 该条目的 ZIP 文件字段名(例如 `zip_0`)。
|
||||
pub zip_field: String,
|
||||
/// 该条目的封面文件字段名(例如 `cover_0`);与 CLI 源布局一致,封面必填。
|
||||
pub cover_field: String,
|
||||
}
|
||||
|
||||
/// 批量导入结果:最新快照 + 逐条结果。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminImportAgcTemplatesResponse {
|
||||
pub revision: String,
|
||||
pub writable: bool,
|
||||
pub templates: Vec<AdminAgcTemplatePayload>,
|
||||
pub imported: Vec<AdminImportAgcTemplateResult>,
|
||||
}
|
||||
|
||||
/// 单条导入结果。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminImportAgcTemplateResult {
|
||||
pub id: String,
|
||||
pub template_version: String,
|
||||
pub zip_size_bytes: u64,
|
||||
pub zip_sha256: String,
|
||||
/// 同一版本上传完全相同的字节时按内容复用既有对象,没有新写内容。
|
||||
pub reused_objects: bool,
|
||||
}
|
||||
|
||||
// 登录成功后返回管理员访问令牌与基础会话信息。
|
||||
|
||||
/// 后台创作入口开关列表响应。
|
||||
@@ -1153,10 +1283,67 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
ADMIN_TAB_PERMISSIONS, AdminAgcTemplateListResponse, AdminAgcTemplatePayload,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminEditorImageSequenceFramePayload,
|
||||
AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn agc_template_snapshot_uses_camel_case_and_assignable_tab_permission() {
|
||||
let value = serde_json::to_value(AdminAgcTemplateListResponse {
|
||||
revision: "a".repeat(64),
|
||||
writable: true,
|
||||
templates: vec![AdminAgcTemplatePayload {
|
||||
id: "cocos-empty-2d".to_owned(),
|
||||
title: "二维模板".to_owned(),
|
||||
summary: "简介".to_owned(),
|
||||
tags: vec!["cocos".to_owned()],
|
||||
runtime: "cocos".to_owned(),
|
||||
engine: "cocos-creator".to_owned(),
|
||||
engine_version: "3.8.8".to_owned(),
|
||||
template_version: "0.1.0".to_owned(),
|
||||
enabled: false,
|
||||
cover_url: "https://example.test/cover.svg".to_owned(),
|
||||
zip_size_bytes: 1024,
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"revision": "a".repeat(64), "writable": true,
|
||||
"templates": [{"id": "cocos-empty-2d", "title": "二维模板", "summary": "简介",
|
||||
"tags": ["cocos"], "runtime": "cocos", "engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8", "templateVersion": "0.1.0", "enabled": false,
|
||||
"coverUrl": "https://example.test/cover.svg", "zipSizeBytes": 1024}]
|
||||
})
|
||||
);
|
||||
assert!(ADMIN_TAB_PERMISSIONS.contains(&"agc-templates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_template_edit_rejects_uneditable_and_unknown_cover_fields() {
|
||||
let request = json!({"expectedRevision": "a".repeat(64), "title": "模板", "summary": "",
|
||||
"tags": [], "enabled": true});
|
||||
let parsed: AdminUpdateAgcTemplateRequest =
|
||||
serde_json::from_value(request.clone()).unwrap();
|
||||
assert!(parsed.cover.is_none());
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), request);
|
||||
for field in ["id", "templateVersion", "runtime", "zipKey"] {
|
||||
let mut invalid = request.clone();
|
||||
invalid[field] = json!("不可修改");
|
||||
assert!(serde_json::from_value::<AdminUpdateAgcTemplateRequest>(invalid).is_err());
|
||||
}
|
||||
let mut with_cover = request;
|
||||
with_cover["cover"] = json!({"contentType": "image/png", "dataBase64": "AQID"});
|
||||
let parsed: AdminUpdateAgcTemplateRequest =
|
||||
serde_json::from_value(with_cover.clone()).unwrap();
|
||||
assert_eq!(parsed.cover.unwrap().data_base64, "AQID");
|
||||
with_cover["cover"]["url"] = json!("https://example.test/image.png");
|
||||
assert!(serde_json::from_value::<AdminUpdateAgcTemplateRequest>(with_cover).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refund_manual_review_resolution_request_uses_camel_case_contract() {
|
||||
let value = serde_json::to_value(AdminRechargeRefundManualReviewResolveRequest {
|
||||
|
||||
@@ -534,6 +534,13 @@ mod tests {
|
||||
assert_eq!(normalized, r#"["dashboard","tracking","editor-assets"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_template_tab_can_be_assigned_without_changing_account_schema() {
|
||||
let normalized = normalize_tab_permissions_json(r#"["agc-templates","agc-templates"]"#)
|
||||
.expect("template management permission");
|
||||
assert_eq!(normalized, r#"["agc-templates"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tab_permission_is_rejected() {
|
||||
let error = normalize_tab_permissions_json(r#"["dashboard","accounts"]"#)
|
||||
|
||||
Reference in New Issue
Block a user